From ce559f28e085b50143af9c59b2e1f41a614c0914 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:33:25 +0100 Subject: [PATCH 001/333] feat(auth): add StateStore trait for pluggable OAuth state storage (#614) * feat(auth): add StateStore trait for pluggable OAuth state storage * fix(examples): use CLI server_url for transport connection --- crates/rmcp/src/transport.rs | 6 +- crates/rmcp/src/transport/auth.rs | 336 ++++++++++++++++++++-- examples/clients/src/auth/oauth_client.rs | 2 +- 3 files changed, 320 insertions(+), 24 deletions(-) diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index aeb8c7954..bf9e74648 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -99,7 +99,11 @@ pub use io::stdio; pub mod auth; #[cfg(feature = "auth")] #[cfg_attr(docsrs, doc(cfg(feature = "auth")))] -pub use auth::{AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient}; +pub use auth::{ + AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, CredentialStore, + InMemoryCredentialStore, InMemoryStateStore, StateStore, StoredAuthorizationState, + StoredCredentials, +}; // #[cfg(feature = "transport-ws")] // #[cfg_attr(docsrs, doc(cfg(feature = "transport-ws")))] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 91b8cad0c..6a5567f48 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -74,6 +74,93 @@ impl CredentialStore for InMemoryCredentialStore { } } +/// Stored authorization state for OAuth2 PKCE flow +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredAuthorizationState { + pub pkce_verifier: String, + pub csrf_token: String, + pub created_at: u64, +} + +impl StoredAuthorizationState { + pub fn new(pkce_verifier: &PkceCodeVerifier, csrf_token: &CsrfToken) -> Self { + Self { + pkce_verifier: pkce_verifier.secret().to_string(), + csrf_token: csrf_token.secret().to_string(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + } + } + + pub fn into_pkce_verifier(self) -> PkceCodeVerifier { + PkceCodeVerifier::new(self.pkce_verifier) + } +} + +/// Trait for storing and retrieving OAuth2 authorization state +/// +/// Implementations of this trait can provide custom storage backends +/// for OAuth2 PKCE flow state, such as Redis or database storage. +/// +/// Implementors are responsible for expiring stale states (e.g., abandoned +/// authorization flows). Use [`StoredAuthorizationState::created_at`] for +/// TTL-based expiration. +#[async_trait] +pub trait StateStore: Send + Sync { + async fn save( + &self, + csrf_token: &str, + state: StoredAuthorizationState, + ) -> Result<(), AuthError>; + + async fn load(&self, csrf_token: &str) -> Result, AuthError>; + + async fn delete(&self, csrf_token: &str) -> Result<(), AuthError>; +} + +/// In-memory state store (default implementation) +/// +/// This store keeps authorization state in memory only and does not persist +/// between application restarts or across multiple server instances. +#[derive(Debug, Default, Clone)] +pub struct InMemoryStateStore { + states: Arc>>, +} + +impl InMemoryStateStore { + pub fn new() -> Self { + Self { + states: Arc::new(RwLock::new(HashMap::new())), + } + } +} + +#[async_trait] +impl StateStore for InMemoryStateStore { + async fn save( + &self, + csrf_token: &str, + state: StoredAuthorizationState, + ) -> Result<(), AuthError> { + self.states + .write() + .await + .insert(csrf_token.to_string(), state); + Ok(()) + } + + async fn load(&self, csrf_token: &str) -> Result, AuthError> { + Ok(self.states.read().await.get(csrf_token).cloned()) + } + + async fn delete(&self, csrf_token: &str) -> Result<(), AuthError> { + self.states.write().await.remove(csrf_token); + Ok(()) + } +} + /// HTTP client with OAuth 2.0 authorization #[derive(Clone)] pub struct AuthClient { @@ -210,7 +297,7 @@ pub struct AuthorizationManager { metadata: Option, oauth_client: Option, credential_store: Arc, - state: RwLock>, + state_store: Arc, base_url: Url, } @@ -234,12 +321,6 @@ pub struct ClientRegistrationResponse { pub additional_fields: HashMap, } -#[derive(Debug)] -struct AuthorizationState { - pkce_verifier: PkceCodeVerifier, - csrf_token: CsrfToken, -} - /// SEP-991: URL-based Client IDs /// Validate that the client_id is a valid URL with https scheme and non-root pathname fn is_https_url(value: &str) -> bool { @@ -290,7 +371,7 @@ impl AuthorizationManager { metadata: None, oauth_client: None, credential_store: Arc::new(InMemoryCredentialStore::new()), - state: RwLock::new(None), + state_store: Arc::new(InMemoryStateStore::new()), base_url, }; @@ -306,6 +387,21 @@ impl AuthorizationManager { self.credential_store = Arc::new(store); } + /// Set a custom state store for OAuth2 authorization flow state + /// + /// This should be called before initiating the authorization flow. + pub fn set_state_store(&mut self, store: S) { + self.state_store = Arc::new(store); + } + + /// Set OAuth2 authorization metadata + /// + /// This should be called after discovering metadata via `discover_metadata()` + /// and before creating an `AuthorizationSession`. + pub fn set_metadata(&mut self, metadata: AuthorizationMetadata) { + self.metadata = Some(metadata); + } + /// Initialize from stored credentials if available /// /// This will load credentials from the credential store and configure @@ -527,11 +623,11 @@ impl AuthorizationManager { let (auth_url, csrf_token) = auth_request.url(); - // store pkce verifier for later use - *self.state.write().await = Some(AuthorizationState { - pkce_verifier, - csrf_token, - }); + // store pkce verifier for later use via state store + let stored_state = StoredAuthorizationState::new(&pkce_verifier, &csrf_token); + self.state_store + .save(csrf_token.secret(), stored_state) + .await?; Ok(auth_url.to_string()) } @@ -548,17 +644,17 @@ impl AuthorizationManager { .as_ref() .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; - let AuthorizationState { - pkce_verifier, - csrf_token: expected_csrf_token, - } = - self.state.write().await.take().ok_or_else(|| { + // Load state from state store using CSRF token as key + let stored_state = + self.state_store.load(csrf_token).await?.ok_or_else(|| { AuthError::InternalError("Authorization state not found".to_string()) })?; - if csrf_token != expected_csrf_token.secret() { - return Err(AuthError::InternalError("CSRF token mismatch".to_string())); - } + // Delete state after retrieval (one-time use) + self.state_store.delete(csrf_token).await?; + + // Reconstruct the PKCE verifier + let pkce_verifier = stored_state.into_pkce_verifier(); let http_client = reqwest::ClientBuilder::new() .redirect(reqwest::redirect::Policy::none()) @@ -1353,9 +1449,15 @@ impl OAuthState { #[cfg(test)] mod tests { + use std::sync::Arc; + + use oauth2::{CsrfToken, PkceCodeVerifier}; use url::Url; - use super::{AuthorizationManager, is_https_url}; + use super::{ + AuthError, AuthorizationManager, InMemoryStateStore, StateStore, StoredAuthorizationState, + is_https_url, + }; // SEP-991: URL-based Client IDs // Tests adapted from the TypeScript SDK's isHttpsUrl test suite @@ -1551,4 +1653,194 @@ mod tests { "https://auth.example.com/tenant1/subtenant/.well-known/openid-configuration" ); } + + // StateStore and StoredAuthorizationState tests + + #[tokio::test] + async fn test_in_memory_state_store_save_and_load() { + let store = InMemoryStateStore::new(); + let pkce = PkceCodeVerifier::new("test-verifier".to_string()); + let csrf = CsrfToken::new("test-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + // Save state + store.save("test-csrf", state).await.unwrap(); + + // Load state + let loaded = store.load("test-csrf").await.unwrap(); + assert!(loaded.is_some()); + let loaded = loaded.unwrap(); + assert_eq!(loaded.csrf_token, "test-csrf"); + assert_eq!(loaded.pkce_verifier, "test-verifier"); + } + + #[tokio::test] + async fn test_in_memory_state_store_load_nonexistent() { + let store = InMemoryStateStore::new(); + let result = store.load("nonexistent").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_in_memory_state_store_delete() { + let store = InMemoryStateStore::new(); + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save("csrf", state).await.unwrap(); + store.delete("csrf").await.unwrap(); + + let result = store.load("csrf").await.unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_stored_authorization_state_serialization() { + let pkce = PkceCodeVerifier::new("my-verifier".to_string()); + let csrf = CsrfToken::new("my-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + // Serialize to JSON + let json = serde_json::to_string(&state).unwrap(); + + // Deserialize back + let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.pkce_verifier, "my-verifier"); + assert_eq!(deserialized.csrf_token, "my-csrf"); + } + + #[test] + fn test_stored_authorization_state_into_pkce_verifier() { + let pkce = PkceCodeVerifier::new("original-verifier".to_string()); + let csrf = CsrfToken::new("csrf-token".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + let recovered = state.into_pkce_verifier(); + assert_eq!(recovered.secret(), "original-verifier"); + } + + #[test] + fn test_stored_authorization_state_created_at() { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + // created_at should be a reasonable timestamp (after year 2020) + assert!(state.created_at > 1577836800); // Jan 1, 2020 + } + + #[tokio::test] + async fn test_in_memory_state_store_overwrite() { + let store = InMemoryStateStore::new(); + let csrf_key = "same-csrf"; + + // Save first state + let pkce1 = PkceCodeVerifier::new("verifier-1".to_string()); + let csrf1 = CsrfToken::new(csrf_key.to_string()); + let state1 = StoredAuthorizationState::new(&pkce1, &csrf1); + store.save(csrf_key, state1).await.unwrap(); + + // Overwrite with second state + let pkce2 = PkceCodeVerifier::new("verifier-2".to_string()); + let csrf2 = CsrfToken::new(csrf_key.to_string()); + let state2 = StoredAuthorizationState::new(&pkce2, &csrf2); + store.save(csrf_key, state2).await.unwrap(); + + // Should get the second state + let loaded = store.load(csrf_key).await.unwrap().unwrap(); + assert_eq!(loaded.pkce_verifier, "verifier-2"); + } + + #[tokio::test] + async fn test_in_memory_state_store_concurrent_access() { + let store = Arc::new(InMemoryStateStore::new()); + let mut handles = vec![]; + + // Spawn 10 concurrent tasks that each save and load their own state + for i in 0..10 { + let store = Arc::clone(&store); + let handle = tokio::spawn(async move { + let csrf_key = format!("csrf-{}", i); + let verifier = format!("verifier-{}", i); + + let pkce = PkceCodeVerifier::new(verifier.clone()); + let csrf = CsrfToken::new(csrf_key.clone()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save(&csrf_key, state).await.unwrap(); + let loaded = store.load(&csrf_key).await.unwrap().unwrap(); + assert_eq!(loaded.pkce_verifier, verifier); + + store.delete(&csrf_key).await.unwrap(); + let deleted = store.load(&csrf_key).await.unwrap(); + assert!(deleted.is_none()); + }); + handles.push(handle); + } + + // Wait for all tasks to complete + for handle in handles { + handle.await.unwrap(); + } + } + + #[tokio::test] + async fn test_custom_state_store_with_authorization_manager() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + // Custom state store that tracks calls + #[derive(Debug, Default)] + struct TrackingStateStore { + inner: InMemoryStateStore, + save_count: AtomicUsize, + load_count: AtomicUsize, + delete_count: AtomicUsize, + } + + #[async_trait::async_trait] + impl StateStore for TrackingStateStore { + async fn save( + &self, + csrf_token: &str, + state: StoredAuthorizationState, + ) -> Result<(), AuthError> { + self.save_count.fetch_add(1, Ordering::SeqCst); + self.inner.save(csrf_token, state).await + } + + async fn load( + &self, + csrf_token: &str, + ) -> Result, AuthError> { + self.load_count.fetch_add(1, Ordering::SeqCst); + self.inner.load(csrf_token).await + } + + async fn delete(&self, csrf_token: &str) -> Result<(), AuthError> { + self.delete_count.fetch_add(1, Ordering::SeqCst); + self.inner.delete(csrf_token).await + } + } + + // Verify custom store works standalone + let store = TrackingStateStore::default(); + let pkce = PkceCodeVerifier::new("test-verifier".to_string()); + let csrf = CsrfToken::new("test-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save("test-csrf", state).await.unwrap(); + assert_eq!(store.save_count.load(Ordering::SeqCst), 1); + + let _ = store.load("test-csrf").await.unwrap(); + assert_eq!(store.load_count.load(Ordering::SeqCst), 1); + + store.delete("test-csrf").await.unwrap(); + assert_eq!(store.delete_count.load(Ordering::SeqCst), 1); + + // Verify custom store can be set on AuthorizationManager + let mut manager = AuthorizationManager::new("http://localhost").await.unwrap(); + manager.set_state_store(TrackingStateStore::default()); + } } diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 9b131652e..ab7867cf1 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -173,7 +173,7 @@ async fn main() -> Result<()> { let client = AuthClient::new(reqwest::Client::default(), am); let transport = StreamableHttpClientTransport::with_client( client, - StreamableHttpClientTransportConfig::with_uri(MCP_SERVER_URL), + StreamableHttpClientTransportConfig::with_uri(server_url.as_str()), ); // Create client and connect to MCP server From 69dbd5a3cee231788d978c4d728f18b9f29f5bd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:52:00 -0500 Subject: [PATCH 002/333] chore(deps): update rig-core requirement from 0.15.1 to 0.28.0 (#616) * chore(deps): update rig-core requirement from 0.15.1 to 0.28.0 Updates the requirements on [rig-core](https://github.com/0xPlaygrounds/rig) to permit the latest version. - [Release notes](https://github.com/0xPlaygrounds/rig/releases) - [Commits](https://github.com/0xPlaygrounds/rig/compare/rig-core-v0.15.1...rig-core-v0.28.0) --- updated-dependencies: - dependency-name: rig-core dependency-version: 0.28.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * chore(deps): manual fixes required --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alex Hancock --- examples/rig-integration/Cargo.toml | 2 +- examples/rig-integration/src/chat.rs | 94 +++++++++++---------- examples/rig-integration/src/main.rs | 4 +- examples/rig-integration/src/mcp_adaptor.rs | 8 +- 4 files changed, 55 insertions(+), 53 deletions(-) diff --git a/examples/rig-integration/Cargo.toml b/examples/rig-integration/Cargo.toml index 079ef0dc2..4cc0ee926 100644 --- a/examples/rig-integration/Cargo.toml +++ b/examples/rig-integration/Cargo.toml @@ -13,7 +13,7 @@ readme = { workspace = true } publish = false [dependencies] -rig-core = "0.15.1" +rig-core = "0.28.0" tokio = { version = "1", features = ["full"] } rmcp = { workspace = true, features = [ "client", diff --git a/examples/rig-integration/src/chat.rs b/examples/rig-integration/src/chat.rs index bc093689c..bc50be1ac 100644 --- a/examples/rig-integration/src/chat.rs +++ b/examples/rig-integration/src/chat.rs @@ -1,15 +1,16 @@ use futures::StreamExt; use rig::{ - agent::Agent, - completion::{AssistantContent, CompletionModel}, - message::Message, - streaming::StreamingChat, + agent::{Agent, MultiTurnStreamItem}, + completion::CompletionModel, + message::{Message, Text}, + streaming::{StreamedAssistantContent, StreamingChat}, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; pub async fn cli_chatbot(chatbot: Agent) -> anyhow::Result<()> where - M: CompletionModel, + M: CompletionModel + 'static, + M::StreamingResponse: Send, { let mut chat_log = vec![]; @@ -28,49 +29,52 @@ where if input == ":q" { break; } - match chatbot.stream_chat(input, chat_log.clone()).await { - Ok(mut response) => { - tracing::info!(%input); - chat_log.push(Message::user(input)); - stream_output_agent_start(&mut output).await?; - let mut message_buf = String::new(); - while let Some(message) = response.next().await { - match message { - Ok(AssistantContent::Text(text)) => { - message_buf.push_str(&text.text); - output_agent(text.text, &mut output).await?; - } - Ok(AssistantContent::ToolCall(tool_call)) => { - let name = tool_call.function.name; - let arguments = tool_call.function.arguments; - chat_log.push(Message::assistant(format!( - "Calling tool: {name} with args: {arguments}" - ))); - let result = chatbot.tools.call(&name, arguments.to_string()).await; - match result { - Ok(tool_call_result) => { - stream_output_agent_finished(&mut output).await?; - stream_output_toolcall(&tool_call_result, &mut output).await?; - stream_output_agent_start(&mut output).await?; - chat_log.push(Message::user(tool_call_result)); - } - Err(e) => { - output_error(e, &mut output).await?; - } - } - } - Err(error) => { - output_error(error, &mut output).await?; - } - } + + tracing::info!(%input); + chat_log.push(Message::user(input)); + + let mut response = chatbot.stream_chat(input, chat_log.clone()).await; + stream_output_agent_start(&mut output).await?; + let mut message_buf = String::new(); + + while let Some(message) = response.next().await { + match message { + Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text( + Text { text }, + ))) => { + message_buf.push_str(&text); + output_agent(&text, &mut output).await?; + } + Ok(MultiTurnStreamItem::StreamAssistantItem( + StreamedAssistantContent::ToolCall(tool_call), + )) => { + let name = &tool_call.function.name; + let arguments = &tool_call.function.arguments; + stream_output_toolcall( + format!("Calling tool: {name} with args: {arguments}"), + &mut output, + ) + .await?; + } + Ok(MultiTurnStreamItem::StreamUserItem(user_content)) => { + // Tool results are streamed back as user items + stream_output_toolcall(format!("Tool result: {:?}", user_content), &mut output) + .await?; + } + Ok(MultiTurnStreamItem::FinalResponse(final_response)) => { + tracing::info!("Final response received: {:?}", final_response); + } + Ok(_) => { + // Handle other stream items (reasoning, deltas, etc.) + } + Err(error) => { + output_error(error, &mut output).await?; } - chat_log.push(Message::assistant(message_buf)); - stream_output_agent_finished(&mut output).await?; - } - Err(error) => { - output_error(error, &mut output).await?; } } + + chat_log.push(Message::assistant(message_buf)); + stream_output_agent_finished(&mut output).await?; } Ok(()) diff --git a/examples/rig-integration/src/main.rs b/examples/rig-integration/src/main.rs index c1a55b420..c9fe81190 100644 --- a/examples/rig-integration/src/main.rs +++ b/examples/rig-integration/src/main.rs @@ -29,14 +29,14 @@ async fn main() -> anyhow::Result<()> { let config = config::Config::retrieve("config.toml").await?; let deepseek_client = { if let Some(key) = config.deepseek_key { - deepseek::Client::new(&key) + deepseek::Client::new(&key)? } else { deepseek::Client::from_env() } }; let cohere_client = { if let Some(key) = config.cohere_key { - cohere::Client::new(&key) + cohere::Client::new(&key)? } else { cohere::Client::from_env() } diff --git a/examples/rig-integration/src/mcp_adaptor.rs b/examples/rig-integration/src/mcp_adaptor.rs index 286e58d51..f5397c63b 100644 --- a/examples/rig-integration/src/mcp_adaptor.rs +++ b/examples/rig-integration/src/mcp_adaptor.rs @@ -20,8 +20,7 @@ impl RigTool for McpToolAdaptor { fn definition( &self, _prompt: String, - ) -> std::pin::Pin + Send + Sync + '_>> - { + ) -> std::pin::Pin + Send + '_>> { Box::pin(std::future::ready(rig::completion::ToolDefinition { name: self.name(), description: self @@ -37,9 +36,8 @@ impl RigTool for McpToolAdaptor { fn call( &self, args: String, - ) -> std::pin::Pin< - Box> + Send + Sync + '_>, - > { + ) -> std::pin::Pin> + Send + '_>> + { let server = self.server.clone(); Box::pin(async move { let call_mcp_tool_result = server From c4a68295e070a1c75d440bda5e21287576f37d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maksim=20Mad=C5=BEar?= Date: Tue, 13 Jan 2026 22:15:19 +0100 Subject: [PATCH 003/333] fix: use Semaphore instead of Notify in OneshotTransport to prevent race condition (#611) --- crates/rmcp/src/transport.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index bf9e74648..d51917387 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -177,7 +177,7 @@ where { message: Option>, sender: tokio::sync::mpsc::Sender>, - finished_signal: Arc, + termination: Arc, } impl OneshotTransport @@ -192,7 +192,7 @@ where Self { message: Some(message), sender, - finished_signal: Arc::new(tokio::sync::Notify::new()), + termination: Arc::new(tokio::sync::Semaphore::new(0)), }, receiver, ) @@ -212,21 +212,22 @@ where let sender = self.sender.clone(); let terminate = matches!(item, TxJsonRpcMessage::::Response(_)) || matches!(item, TxJsonRpcMessage::::Error(_)); - let signal = self.finished_signal.clone(); + let termination = self.termination.clone(); async move { sender.send(item).await?; if terminate { - signal.notify_waiters(); + termination.add_permits(1); } Ok(()) } } async fn receive(&mut self) -> Option> { - if self.message.is_none() { - self.finished_signal.notified().await; + if let Some(msg) = self.message.take() { + return Some(msg); } - self.message.take() + let _ = self.termination.acquire().await; + None } fn close(&mut self) -> impl Future> + Send { From cc96f379ba75a4702b8a4fde78ebbed9b91030d9 Mon Sep 17 00:00:00 2001 From: Tyler Mailman Date: Tue, 13 Jan 2026 16:25:06 -0500 Subject: [PATCH 004/333] feat(service): add close() method for graceful connection shutdown (#588) This PR primarily fixes #572 by enabling graceful shutdown without consuming self. While implementing this, I noticed delete_session() is spawned as a background task, which means close() may return before HTTP session cleanup completes. Since this is part of the same shutdown lifecycle and can cause resource leaks/races, I'm including a small, localized fix to ensure cleanup is completed before close() returns. If maintainers prefer, I can split the cleanup timing change into a follow-up PR. Changes: - Add close(&mut self) for graceful shutdown without consuming - Add close_with_timeout() for bounded shutdown operations - Add is_closed() to check connection state - Move HTTP delete_session from background spawn to inline cleanup - Add 5-second timeout on session cleanup to prevent indefinite hangs - Add Drop impl with debug log if dropped without explicit close Fixes #572 --- crates/rmcp/src/service.rs | 106 +++++++++++++-- .../src/transport/streamable_http_client.rs | 89 +++++++----- crates/rmcp/tests/test_close_connection.rs | 127 ++++++++++++++++++ 3 files changed, 277 insertions(+), 45 deletions(-) create mode 100644 crates/rmcp/tests/test_close_connection.rs diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 5fc8934fa..e0fd76425 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -434,7 +434,7 @@ impl Peer { pub struct RunningService> { service: Arc, peer: Peer, - handle: tokio::task::JoinHandle, + handle: Option>, cancellation_token: CancellationToken, dg: DropGuard, } @@ -459,14 +459,104 @@ impl> RunningService { pub fn cancellation_token(&self) -> RunningServiceCancellationToken { RunningServiceCancellationToken(self.cancellation_token.clone()) } + + /// Returns true if the service has been closed or cancelled. #[inline] - pub async fn waiting(self) -> Result { - self.handle.await + pub fn is_closed(&self) -> bool { + self.handle.is_none() || self.cancellation_token.is_cancelled() + } + + /// Wait for the service to complete. + /// + /// This will block until the service loop terminates (either due to + /// cancellation, transport closure, or an error). + #[inline] + pub async fn waiting(mut self) -> Result { + match self.handle.take() { + Some(handle) => handle.await, + None => Ok(QuitReason::Closed), + } + } + + /// Gracefully close the connection and wait for cleanup to complete. + /// + /// This method cancels the service, waits for the background task to finish + /// (which includes calling `transport.close()`), and ensures all cleanup + /// operations complete before returning. + /// + /// Unlike [`cancel`](Self::cancel), this method takes `&mut self` and can be + /// called without consuming the `RunningService`. After calling this method, + /// the service is considered closed and subsequent operations will fail. + /// + /// # Example + /// + /// ```rust,ignore + /// let mut client = ().serve(transport).await?; + /// // ... use the client ... + /// client.close().await?; + /// ``` + pub async fn close(&mut self) -> Result { + if let Some(handle) = self.handle.take() { + // Disarm the drop guard so it doesn't try to cancel again + // We need to cancel manually and wait for completion + self.cancellation_token.cancel(); + handle.await + } else { + // Already closed + Ok(QuitReason::Closed) + } } - pub async fn cancel(self) -> Result { - let RunningService { dg, handle, .. } = self; - dg.disarm().cancel(); - handle.await + + /// Gracefully close the connection with a timeout. + /// + /// Similar to [`close`](Self::close), but returns after the specified timeout + /// if the cleanup doesn't complete in time. This is useful for ensuring + /// a bounded shutdown time. + /// + /// Returns `Ok(Some(reason))` if shutdown completed within the timeout, + /// `Ok(None)` if the timeout was reached, or `Err` if there was a join error. + pub async fn close_with_timeout( + &mut self, + timeout: Duration, + ) -> Result, tokio::task::JoinError> { + if let Some(handle) = self.handle.take() { + self.cancellation_token.cancel(); + match tokio::time::timeout(timeout, handle).await { + Ok(result) => result.map(Some), + Err(_elapsed) => { + tracing::warn!( + "close_with_timeout: cleanup did not complete within {:?}", + timeout + ); + Ok(None) + } + } + } else { + Ok(Some(QuitReason::Closed)) + } + } + + /// Cancel the service and wait for cleanup to complete. + /// + /// This consumes the `RunningService` and ensures the connection is properly + /// closed. For a non-consuming alternative, see [`close`](Self::close). + pub async fn cancel(mut self) -> Result { + // Disarm the drop guard since we're handling cancellation explicitly + let _ = std::mem::replace(&mut self.dg, self.cancellation_token.clone().drop_guard()); + self.close().await + } +} + +impl> Drop for RunningService { + fn drop(&mut self) { + if self.handle.is_some() && !self.cancellation_token.is_cancelled() { + tracing::debug!( + "RunningService dropped without explicit close(). \ + The connection will be closed asynchronously. \ + For guaranteed cleanup, call close() or cancel() before dropping." + ); + } + // The DropGuard will handle cancellation } } @@ -847,7 +937,7 @@ where RunningService { service, peer: peer_return, - handle, + handle: Some(handle), cancellation_token: ct.clone(), dg: ct.drop_guard(), } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 61f5074b3..a398346d7 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -333,37 +333,10 @@ impl Worker for StreamableHttpClientWorker { } None }; - // delete session when drop guard is dropped - if let Some(session_id) = &session_id { - let ct = transport_task_ct.clone(); - let client = self.client.clone(); - let session_id = session_id.clone(); - let url = config.uri.clone(); - let auth_header = config.auth_header.clone(); - tokio::spawn(async move { - ct.cancelled().await; - let delete_session_result = client - .delete_session(url, session_id.clone(), auth_header.clone()) - .await; - match delete_session_result { - Ok(_) => { - tracing::info!(session_id = session_id.as_ref(), "delete session success") - } - Err(StreamableHttpError::ServerDoesNotSupportDeleteSession) => { - tracing::info!( - session_id = session_id.as_ref(), - "server doesn't support delete session" - ) - } - Err(e) => { - tracing::error!( - session_id = session_id.as_ref(), - "fail to delete session: {e}" - ); - } - }; - }); - } + // Store session info for cleanup when run() exits (not spawned, so cleanup completes before close() returns) + let session_cleanup_info = session_id.as_ref().map(|sid| { + (self.client.clone(), config.uri.clone(), sid.clone(), config.auth_header.clone()) + }); context.send_to_handler(message).await?; let initialized_notification = context.recv_from_handler().await?; @@ -438,20 +411,23 @@ impl Worker for StreamableHttpClientWorker { } }); } - loop { + // Main event loop - capture exit reason so we can do cleanup before returning + let loop_result: Result<(), WorkerQuitReason> = 'main_loop: loop { let event = tokio::select! { _ = transport_task_ct.cancelled() => { tracing::debug!("cancelled"); - return Err(WorkerQuitReason::Cancelled); + break 'main_loop Err(WorkerQuitReason::Cancelled); } message = context.recv_from_handler() => { - let message = message?; - Event::ClientMessage(message) + match message { + Ok(msg) => Event::ClientMessage(msg), + Err(e) => break 'main_loop Err(e), + } }, message = sse_worker_rx.recv() => { let Some(message) = message else { tracing::trace!("transport dropped, exiting"); - return Err(WorkerQuitReason::HandlerTerminated); + break 'main_loop Err(WorkerQuitReason::HandlerTerminated); }; Event::ServerMessage(message) }, @@ -526,7 +502,9 @@ impl Worker for StreamableHttpClientWorker { } Event::ServerMessage(json_rpc_message) => { // send the message to the handler - context.send_to_handler(json_rpc_message).await?; + if let Err(e) = context.send_to_handler(json_rpc_message).await { + break 'main_loop Err(e); + } } Event::StreamResult(result) => { if result.is_err() { @@ -537,7 +515,44 @@ impl Worker for StreamableHttpClientWorker { } } } + }; + + // Cleanup session before returning (ensures close() waits for session deletion) + // Use a timeout to prevent indefinite hangs if the server is unresponsive + if let Some((client, url, session_id, auth_header)) = session_cleanup_info { + const SESSION_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + match tokio::time::timeout( + SESSION_CLEANUP_TIMEOUT, + client.delete_session(url, session_id.clone(), auth_header), + ) + .await + { + Ok(Ok(_)) => { + tracing::info!(session_id = session_id.as_ref(), "delete session success") + } + Ok(Err(StreamableHttpError::ServerDoesNotSupportDeleteSession)) => { + tracing::info!( + session_id = session_id.as_ref(), + "server doesn't support delete session" + ) + } + Ok(Err(e)) => { + tracing::error!( + session_id = session_id.as_ref(), + "fail to delete session: {e}" + ); + } + Err(_elapsed) => { + tracing::warn!( + session_id = session_id.as_ref(), + "session cleanup timed out after {:?}", + SESSION_CLEANUP_TIMEOUT + ); + } + } } + + loop_result } } diff --git a/crates/rmcp/tests/test_close_connection.rs b/crates/rmcp/tests/test_close_connection.rs new file mode 100644 index 000000000..903c8d551 --- /dev/null +++ b/crates/rmcp/tests/test_close_connection.rs @@ -0,0 +1,127 @@ +//cargo test --test test_close_connection --features "client server" + +mod common; +use std::time::Duration; + +use common::handlers::{TestClientHandler, TestServer}; +use rmcp::{service::QuitReason, ServiceExt}; + +/// Test that close() properly shuts down the connection +#[tokio::test] +async fn test_close_method() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client + let handler = TestClientHandler::new(true, true); + let mut client = handler.serve(client_transport).await?; + + // Verify client is not closed + assert!(!client.is_closed()); + + // Call close() and verify it returns + let result = client.close().await?; + assert!(matches!(result, QuitReason::Cancelled)); + + // Verify client is now closed + assert!(client.is_closed()); + + // Calling close() again should return Closed immediately + let result = client.close().await?; + assert!(matches!(result, QuitReason::Closed)); + + // Wait for server to finish + server_handle.await??; + Ok(()) +} + +/// Test that close_with_timeout() respects the timeout +#[tokio::test] +async fn test_close_with_timeout() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client + let handler = TestClientHandler::new(true, true); + let mut client = handler.serve(client_transport).await?; + + // Close with a reasonable timeout + let result = client.close_with_timeout(Duration::from_secs(5)).await?; + assert!(result.is_some()); + assert!(matches!(result.unwrap(), QuitReason::Cancelled)); + + // Verify client is now closed + assert!(client.is_closed()); + + // Wait for server to finish + server_handle.await??; + Ok(()) +} + +/// Test that cancel() still works and consumes self +#[tokio::test] +async fn test_cancel_method() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + + // Start client + let handler = TestClientHandler::new(true, true); + let client = handler.serve(client_transport).await?; + + // Cancel should work as before + let result = client.cancel().await?; + assert!(matches!(result, QuitReason::Cancelled)); + + // Wait for server to finish + server_handle.await??; + Ok(()) +} + +/// Test that dropping without close() logs a debug message (we can't easily test +/// the log output, but we can verify the drop doesn't panic) +#[tokio::test] +async fn test_drop_without_close() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + // Start server that will handle the drop + let server_handle = tokio::spawn(async move { + let server = TestServer::new().serve(server_transport).await?; + // The server should close when the client drops + let result = server.waiting().await?; + // Server should detect closure + assert!(matches!(result, QuitReason::Closed | QuitReason::Cancelled)); + anyhow::Ok(()) + }); + + // Create and immediately drop the client + { + let handler = TestClientHandler::new(true, true); + let _client = handler.serve(client_transport).await?; + // Client dropped here without calling close() + } + + // Give the async cleanup a moment to run + tokio::time::sleep(Duration::from_millis(100)).await; + + // Wait for server to finish (it should detect the closure) + server_handle.await??; + Ok(()) +} From 46e149ee8b54525b23681c5c415646caae9dceb3 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 13 Jan 2026 21:29:35 -0500 Subject: [PATCH 005/333] chore: clean up optional dependencies (#546) --- crates/rmcp/Cargo.toml | 6 +++--- crates/rmcp/src/transport/streamable_http_client.rs | 7 ++++++- crates/rmcp/tests/test_close_connection.rs | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index b86f2abec..f7af815ae 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -25,7 +25,7 @@ tokio-util = { version = "0.7" } pin-project-lite = "0.2" pastey = { version = "0.2.0", optional = true } # oauth2 support -oauth2 = { version = "5.0", optional = true } +oauth2 = { version = "5.0", optional = true, default-features = false, features = ["reqwest"] } # for auto generate schema schemars = { version = "1.0", optional = true, features = ["chrono04"] } @@ -108,7 +108,7 @@ client-side-sse = ["dep:sse-stream", "dep:http"] # Streamable HTTP client transport-streamable-http-client = ["client-side-sse", "transport-worker"] -transport-streamable-http-client-reqwest = ["transport-streamable-http-client", "reqwest"] +transport-streamable-http-client-reqwest = ["transport-streamable-http-client", "__reqwest"] transport-async-rw = ["tokio/io-util", "tokio-util/codec"] transport-io = ["transport-async-rw", "tokio/io-std"] @@ -207,4 +207,4 @@ path = "tests/test_task.rs" [[test]] name = "test_streamable_http_priming" required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] -path = "tests/test_streamable_http_priming.rs" \ No newline at end of file +path = "tests/test_streamable_http_priming.rs" diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index a398346d7..35140c1b2 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -335,7 +335,12 @@ impl Worker for StreamableHttpClientWorker { }; // Store session info for cleanup when run() exits (not spawned, so cleanup completes before close() returns) let session_cleanup_info = session_id.as_ref().map(|sid| { - (self.client.clone(), config.uri.clone(), sid.clone(), config.auth_header.clone()) + ( + self.client.clone(), + config.uri.clone(), + sid.clone(), + config.auth_header.clone(), + ) }); context.send_to_handler(message).await?; diff --git a/crates/rmcp/tests/test_close_connection.rs b/crates/rmcp/tests/test_close_connection.rs index 903c8d551..b3bb5b638 100644 --- a/crates/rmcp/tests/test_close_connection.rs +++ b/crates/rmcp/tests/test_close_connection.rs @@ -4,7 +4,7 @@ mod common; use std::time::Duration; use common::handlers::{TestClientHandler, TestServer}; -use rmcp::{service::QuitReason, ServiceExt}; +use rmcp::{ServiceExt, service::QuitReason}; /// Test that close() properly shuts down the connection #[tokio::test] From acb06ea81970cc3318440dc981900ac15cdd4d66 Mon Sep 17 00:00:00 2001 From: Pavel Bezglasny Date: Wed, 14 Jan 2026 16:47:50 +0100 Subject: [PATCH 006/333] fix(build): fix build of the project when no features are selected (#606) --- crates/rmcp/Cargo.toml | 22 ++++++++++++++++++++++ crates/rmcp/src/error.rs | 1 + crates/rmcp/src/lib.rs | 7 ++++++- crates/rmcp/src/transport.rs | 9 ++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index f7af815ae..b9c0baa24 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -208,3 +208,25 @@ path = "tests/test_task.rs" name = "test_streamable_http_priming" required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] path = "tests/test_streamable_http_priming.rs" + + +[[test]] +name = "test_custom_request" +required-features = ["server", "client"] +path = "tests/test_custom_request.rs" + +[[test]] +name = "test_prompt_macros" +required-features = ["server", "client"] +path = "tests/test_prompt_macros.rs" + +[[test]] +name = "test_sampling" +required-features = ["server", "client"] +path = "tests/test_sampling.rs" + + +[[test]] +name = "test_close_connection" +required-features = ["server", "client"] +path = "tests/test_close_connection.rs" diff --git a/crates/rmcp/src/error.rs b/crates/rmcp/src/error.rs index f51a7158c..51f60acb6 100644 --- a/crates/rmcp/src/error.rs +++ b/crates/rmcp/src/error.rs @@ -20,6 +20,7 @@ impl std::error::Error for ErrorData {} /// This is an unified error type for the errors could be returned by the service. #[derive(Debug, thiserror::Error)] +#[allow(clippy::large_enum_variant)] pub enum RmcpError { #[error("Service error: {0}")] Service(#[from] ServiceError), diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 3ab7c5d9e..f1f1e4067 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -104,9 +104,14 @@ //! //! ```rust //! use anyhow::Result; -//! use rmcp::{model::CallToolRequestParam, service::ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}}; +//! use rmcp::{model::CallToolRequestParam, service::ServiceExt}; +//! #[cfg(feature = "transport-child-process")] +//! #[cfg_attr(docsrs, doc(cfg(feature = "transport-child-process")))] +//! use rmcp::transport::{TokioChildProcess, ConfigureCommandExt}; //! use tokio::process::Command; //! +//! #[cfg(feature = "transport-child-process")] +//! #[cfg_attr(docsrs, doc(cfg(feature = "transport-child-process")))] //! async fn client() -> Result<()> { //! let service = ().serve(TokioChildProcess::new(Command::new("uvx").configure(|cmd| { //! cmd.arg("mcp-server-git"); diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index d51917387..5b9318d96 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -40,10 +40,15 @@ //! //! ```rust //! # use rmcp::{ -//! # ServiceExt, serve_client, serve_server, +//! # ServiceExt, serve_server, //! # }; +//! #[cfg(feature = "client")] +//! #[cfg_attr(docsrs, doc(cfg(feature = "client")))] +//! # use rmcp::serve_client; //! //! // create transport from tcp stream +//! #[cfg(feature = "client")] +//! #[cfg_attr(docsrs, doc(cfg(feature = "client")))] //! async fn client() -> Result<(), Box> { //! let stream = tokio::net::TcpSocket::new_v4()? //! .connect("127.0.0.1:8001".parse()?) @@ -55,6 +60,8 @@ //! } //! //! // create transport from std io +//! #[cfg(feature = "client")] +//! #[cfg_attr(docsrs, doc(cfg(feature = "client")))] //! async fn io() -> Result<(), Box> { //! let client = ().serve((tokio::io::stdin(), tokio::io::stdout())).await?; //! let tools = client.peer().list_tools(Default::default()).await?; From 81f858836d1a10d19bf2a292c8fe11b2fab3f7fd Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 14 Jan 2026 10:53:05 -0500 Subject: [PATCH 007/333] feat: provide blanket implementations for ClientHandler and ServerHandler traits (#609) * feat!: implement ServerHandler for Box and Arc where H is a ServerHandler * feat!: implement ClientHandler for Box and Arc where H is a ClientHandler * test: test Box and Arc have blanket implementations for handler traits * refactor: deduplicate blanket implementations with macros --- crates/rmcp/src/handler/client.rs | 114 ++++++++++++ crates/rmcp/src/handler/server.rs | 205 +++++++++++++++++++++ crates/rmcp/src/handler/server/router.rs | 2 +- crates/rmcp/tests/test_handler_wrappers.rs | 28 +++ 4 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 crates/rmcp/tests/test_handler_wrappers.rs diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 15b1c0c00..cd238310f 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -1,4 +1,6 @@ pub mod progress; +use std::sync::Arc; + use crate::{ error::ErrorData as McpError, model::*, @@ -210,3 +212,115 @@ impl ClientHandler for ClientInfo { self.clone() } } + +macro_rules! impl_client_handler_for_wrapper { + ($wrapper:ident) => { + impl ClientHandler for $wrapper { + fn ping( + &self, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).ping(context) + } + + fn create_message( + &self, + params: CreateMessageRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).create_message(params, context) + } + + fn list_roots( + &self, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).list_roots(context) + } + + fn create_elicitation( + &self, + request: CreateElicitationRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).create_elicitation(request, context) + } + + fn on_custom_request( + &self, + request: CustomRequest, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).on_custom_request(request, context) + } + + fn on_cancelled( + &self, + params: CancelledNotificationParam, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_cancelled(params, context) + } + + fn on_progress( + &self, + params: ProgressNotificationParam, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_progress(params, context) + } + + fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_logging_message(params, context) + } + + fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_resource_updated(params, context) + } + + fn on_resource_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_resource_list_changed(context) + } + + fn on_tool_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_tool_list_changed(context) + } + + fn on_prompt_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_prompt_list_changed(context) + } + + fn on_custom_notification( + &self, + notification: CustomNotification, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_custom_notification(notification, context) + } + + fn get_info(&self) -> ClientInfo { + (**self).get_info() + } + } + }; +} + +impl_client_handler_for_wrapper!(Box); +impl_client_handler_for_wrapper!(Arc); diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index f10cfa7c8..073ee1086 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::{ error::ErrorData as McpError, model::*, @@ -327,3 +329,206 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { std::future::ready(Err(McpError::method_not_found::())) } } + +macro_rules! impl_server_handler_for_wrapper { + ($wrapper:ident) => { + impl ServerHandler for $wrapper { + fn enqueue_task( + &self, + request: CallToolRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).enqueue_task(request, context) + } + + fn ping( + &self, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).ping(context) + } + + fn initialize( + &self, + request: InitializeRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).initialize(request, context) + } + + fn complete( + &self, + request: CompleteRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).complete(request, context) + } + + fn set_level( + &self, + request: SetLevelRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).set_level(request, context) + } + + fn get_prompt( + &self, + request: GetPromptRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).get_prompt(request, context) + } + + fn list_prompts( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).list_prompts(request, context) + } + + fn list_resources( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).list_resources(request, context) + } + + fn list_resource_templates( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + Send + '_ + { + (**self).list_resource_templates(request, context) + } + + fn read_resource( + &self, + request: ReadResourceRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).read_resource(request, context) + } + + fn subscribe( + &self, + request: SubscribeRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).subscribe(request, context) + } + + fn unsubscribe( + &self, + request: UnsubscribeRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).unsubscribe(request, context) + } + + fn call_tool( + &self, + request: CallToolRequestParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).call_tool(request, context) + } + + fn list_tools( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).list_tools(request, context) + } + + fn on_custom_request( + &self, + request: CustomRequest, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).on_custom_request(request, context) + } + + fn on_cancelled( + &self, + notification: CancelledNotificationParam, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_cancelled(notification, context) + } + + fn on_progress( + &self, + notification: ProgressNotificationParam, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_progress(notification, context) + } + + fn on_initialized( + &self, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_initialized(context) + } + + fn on_roots_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_roots_list_changed(context) + } + + fn on_custom_notification( + &self, + notification: CustomNotification, + context: NotificationContext, + ) -> impl Future + Send + '_ { + (**self).on_custom_notification(notification, context) + } + + fn get_info(&self) -> ServerInfo { + (**self).get_info() + } + + fn list_tasks( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).list_tasks(request, context) + } + + fn get_task_info( + &self, + request: GetTaskInfoParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).get_task_info(request, context) + } + + fn get_task_result( + &self, + request: GetTaskResultParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).get_task_result(request, context) + } + + fn cancel_task( + &self, + request: CancelTaskParam, + context: RequestContext, + ) -> impl Future> + Send + '_ { + (**self).cancel_task(request, context) + } + } + }; +} + +impl_server_handler_for_wrapper!(Box); +impl_server_handler_for_wrapper!(Arc); diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index 0b9080818..1f34ba5b2 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -133,6 +133,6 @@ where } fn get_info(&self) -> ::Info { - self.service.get_info() + ServerHandler::get_info(&self.service) } } diff --git a/crates/rmcp/tests/test_handler_wrappers.rs b/crates/rmcp/tests/test_handler_wrappers.rs new file mode 100644 index 000000000..e1faddc91 --- /dev/null +++ b/crates/rmcp/tests/test_handler_wrappers.rs @@ -0,0 +1,28 @@ +// cargo test --test test_handler_wrappers --features "client server" + +mod common; + +use std::sync::Arc; + +use common::handlers::{TestClientHandler, TestServer}; +use rmcp::{ClientHandler, ServerHandler}; + +#[test] +fn test_wrapped_server_handlers() { + // This test asserts that, when T: ServerHandler, both Box and Arc also implement ServerHandler. + fn accepts_server_handler(_handler: H) {} + + accepts_server_handler(Box::new(TestServer::new())); + accepts_server_handler(Arc::new(TestServer::new())); +} + +#[test] +fn test_wrapped_client_handlers() { + // This test asserts that, when T: ClientHandler, both Box and Arc also implement ClientHandler. + fn accepts_client_handler(_handler: H) {} + + let client = TestClientHandler::new(false, false); + + accepts_client_handler(Box::new(client.clone())); + accepts_client_handler(Arc::new(client)); +} From 95d3e3f9403ea853f18ef692143cecf8bc156b20 Mon Sep 17 00:00:00 2001 From: Xing <16152581+tonyxwz@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:28:54 +0100 Subject: [PATCH 008/333] fix: use the json rpc error from the initialize response and bubble it up to the client (#569) --- crates/rmcp/src/service/client.rs | 9 +++- .../rmcp/tests/test_client_initialization.rs | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 crates/rmcp/tests/test_client_initialization.rs diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 75cdc8fa6..e6991f38a 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -8,7 +8,7 @@ use crate::{ ArgumentInfo, CallToolRequest, CallToolRequestParam, CallToolResult, CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParam, CompleteResult, - CompletionContext, CompletionInfo, GetPromptRequest, GetPromptRequestParam, + CompletionContext, CompletionInfo, ErrorData, GetPromptRequest, GetPromptRequestParam, GetPromptResult, InitializeRequest, InitializedNotification, JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest, @@ -44,6 +44,9 @@ pub enum ClientInitializeError { context: Cow<'static, str>, }, + #[error("JSON-RPC error: {0}")] + JsonRpcError(ErrorData), + #[error("Cancelled")] Cancelled, } @@ -92,6 +95,10 @@ where ServerJsonRpcMessage::Response(JsonRpcResponse { id, result, .. }) => { break Ok((result, id)); } + // Handle JSON-RPC error responses + ServerJsonRpcMessage::Error(error) => { + break Err(ClientInitializeError::JsonRpcError(error.error)); + } // Server could send logging messages before handshake ServerJsonRpcMessage::Notification(mut notification) => { let ServerNotification::LoggingMessageNotification(logging) = diff --git a/crates/rmcp/tests/test_client_initialization.rs b/crates/rmcp/tests/test_client_initialization.rs new file mode 100644 index 000000000..fed6eceed --- /dev/null +++ b/crates/rmcp/tests/test_client_initialization.rs @@ -0,0 +1,51 @@ +// cargo test --features "server client" --package rmcp test_client_initialization +mod common; + +use std::borrow::Cow; + +use common::handlers::TestClientHandler; +use rmcp::{ + ServiceExt, + model::{ + ErrorCode, ErrorData, JsonRpcError, JsonRpcVersion2_0, RequestId, ServerJsonRpcMessage, + }, + transport::{IntoTransport, Transport}, +}; + +#[tokio::test] +async fn test_client_init_handles_jsonrpc_error() { + let (server_transport, client_transport) = tokio::io::duplex(1024); + let mut server = IntoTransport::::into_transport(server_transport); + + let client_handle = tokio::spawn(async move { + TestClientHandler::new(true, true) + .serve(client_transport) + .await + }); + + tokio::spawn(async move { + let _init_request = server.receive().await; + + let error_msg = ServerJsonRpcMessage::Error(JsonRpcError { + jsonrpc: JsonRpcVersion2_0, + id: RequestId::Number(1), + error: ErrorData { + code: ErrorCode(-32600), + message: Cow::Borrowed("Invalid Request"), + data: None, + }, + }); + let _: Result<(), _> = server.send(error_msg).await; + }); + + let result = client_handle.await.unwrap(); + + assert!(result.is_err()); + match result { + Err(rmcp::service::ClientInitializeError::JsonRpcError(error_data)) => { + assert_eq!(error_data.code, ErrorCode(-32600)); + assert_eq!(error_data.message, "Invalid Request"); + } + _ => panic!("Expected ClientInitializeError::JsonRpcError"), + } +} From 9b629c609fc844ed220d0cd5c7ca85d208a20c76 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 14 Jan 2026 16:01:28 -0500 Subject: [PATCH 009/333] chore: add pre-commit hook for conventional commit verification (#619) --- .githooks/commit-msg | 50 ++++++++++++++++++++++++++++++++++++++++++++ crates/rmcp/build.rs | 23 ++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100755 .githooks/commit-msg create mode 100644 crates/rmcp/build.rs diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 000000000..a59728be6 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,50 @@ +#!/bin/sh +# Verify commit message follows conventional commit format +# https://www.conventionalcommits.org/ +# +# Uses npx commitlint if Node.js is available (same as CI), +# otherwise falls back to basic shell regex validation. + +commit_msg_file="$1" +commit_msg=$(cat "$commit_msg_file") + +# Skip merge commits +if echo "$commit_msg" | grep -qE "^Merge "; then + exit 0 +fi + +# Try to use commitlint via npx if Node.js is available +if command -v npx >/dev/null 2>&1; then + # Run commitlint with config-conventional rules (same as CI) + echo "$commit_msg" | npx --yes @commitlint/cli@latest --extends @commitlint/config-conventional + exit $? +fi + +# Fallback: basic shell regex validation if Node.js is not available +echo "Note: Node.js not found, using basic commit message validation." +echo " Install Node.js for full commitlint validation (same as CI)." +echo "" + +# Conventional commit types from @commitlint/config-conventional +types="build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test" + +# Pattern: type(optional-scope): description +# The description must start with lowercase and not end with period +pattern="^($types)(\(.+\))?(!)?: .+" + +if ! echo "$commit_msg" | head -1 | grep -qE "$pattern"; then + echo "ERROR: Commit message does not follow conventional commit format." + echo "" + echo "Expected format: (): " + echo "" + echo "Valid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test" + echo "" + echo "Examples:" + echo " feat: add new feature" + echo " fix(parser): resolve parsing issue" + echo " docs: update README" + echo "" + echo "Your commit message:" + echo " $(head -1 "$commit_msg_file")" + exit 1 +fi diff --git a/crates/rmcp/build.rs b/crates/rmcp/build.rs new file mode 100644 index 000000000..05702b405 --- /dev/null +++ b/crates/rmcp/build.rs @@ -0,0 +1,23 @@ +// Install git hooks on build +fn main() { + // Only run in the workspace root (not when building as a dependency) + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let workspace_root = std::path::Path::new(&manifest_dir) + .parent() + .and_then(|p| p.parent()); + + if let Some(root) = workspace_root { + let githooks_dir = root.join(".githooks"); + let git_dir = root.join(".git"); + + // Only configure if we're in the actual workspace (not a dependency) + // and git directory exists + if githooks_dir.exists() && git_dir.exists() { + // Configure git to use our hooks directory + let _ = std::process::Command::new("git") + .args(["config", "core.hooksPath", ".githooks"]) + .current_dir(root) + .output(); + } + } +} From e49aef65d1f1260010ab36c43b980d904536a25c Mon Sep 17 00:00:00 2001 From: Pavel Bezglasny Date: Wed, 14 Jan 2026 22:15:03 +0100 Subject: [PATCH 010/333] chore(elicitation): improve enum schema builder, small changes of elicitation builder (#608) --- crates/rmcp/src/model/elicitation_schema.rs | 458 ++++++++++++++------ 1 file changed, 331 insertions(+), 127 deletions(-) diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index f81f36b72..a02e07f4c 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -16,7 +16,7 @@ //! .build(); //! ``` -use std::{borrow::Cow, collections::BTreeMap}; +use std::{borrow::Cow, collections::BTreeMap, marker::PhantomData}; use serde::{Deserialize, Serialize}; @@ -632,6 +632,12 @@ pub enum EnumSchema { Legacy(LegacyEnumSchema), } +/// Marker type for single-select enum builder +#[derive(Debug)] +pub struct SingleSelect; +/// Marker type for multi-select enum builder +#[derive(Debug)] +pub struct MultiSelect; /// Builder for EnumSchema /// Allows to create various enum schema types (single/multi select, titled/untitled) /// with validation of provided parameters @@ -649,11 +655,9 @@ pub enum EnumSchema { /// .build(); /// ``` #[derive(Debug)] -pub struct EnumSchemaBuilder { +pub struct EnumSchemaBuilder { /// Enum values enum_values: Vec, - /// If true generate SingleSelect EnumSchema, MultiSelect otherwise - single_select: bool, /// If true generate Titled EnumSchema, UnTitled otherwise titled: bool, /// Title of EnumSchema @@ -668,37 +672,50 @@ pub struct EnumSchemaBuilder { max_items: Option, /// Default values for enum default: Vec, + select_type: PhantomData, } -impl Default for EnumSchemaBuilder { +/// Default implementation for single-select enum builder +impl Default for EnumSchemaBuilder { fn default() -> Self { Self { title: None, description: None, - single_select: true, titled: false, enum_titles: Vec::new(), enum_values: Vec::new(), min_items: None, max_items: None, default: Vec::new(), + select_type: PhantomData, } } } -/// Enum selection builder -impl EnumSchemaBuilder { - pub fn new(values: Vec) -> EnumSchemaBuilder { - EnumSchemaBuilder { - enum_values: values, - single_select: true, - titled: false, - ..Default::default() - } +/// Common enum schema builder methods +impl EnumSchemaBuilder { + /// Set title of enum schema + pub fn title(mut self, value: impl Into>) -> Self { + self.title = Some(value.into()); + self + } + + /// Set description of enum schema + pub fn description(mut self, value: impl Into>) -> Self { + self.description = Some(value.into()); + self + } + + /// Set enum as untitled + /// Clears any previously set titles + pub fn untitled(mut self) -> Self { + self.enum_titles = Vec::new(); + self.titled = false; + self } /// Set titles to enum values. Also, implicitly set this enum schema as titled - pub fn enum_titles(mut self, titles: Vec) -> Result { + pub fn enum_titles(mut self, titles: Vec) -> Result, String> { if titles.len() != self.enum_values.len() { return Err(format!( "Provided number of titles do not match number of values: expected {}, but got {}", @@ -710,64 +727,102 @@ impl EnumSchemaBuilder { self.enum_titles = titles; Ok(self) } +} - /// Set enum as single-select - /// If it was multi-select, clear default values - pub fn single_select(mut self) -> EnumSchemaBuilder { - if !self.single_select { - self.default = Vec::new(); +/// Enum selection builder for single-select enums +impl EnumSchemaBuilder { + pub fn new(values: Vec) -> EnumSchemaBuilder { + EnumSchemaBuilder { + enum_values: values, + ..Default::default() } - self.single_select = true; - self } - /// Set enum as multi-select - /// If it was single-select, clear default value - pub fn multiselect(mut self) -> EnumSchemaBuilder { - if self.single_select { - self.default = Vec::new(); + /// Transition to multi-select enum builder. + /// + /// Clears any previously set default values and resets min/max items. + /// After this transition, you can use `min_items()`, `max_items()`, and + /// `with_default()` for multi-select semantics. + pub fn multiselect(self) -> EnumSchemaBuilder { + EnumSchemaBuilder { + enum_values: self.enum_values, + titled: self.titled, + title: self.title, + description: self.description, + enum_titles: self.enum_titles, + min_items: None, + max_items: None, + default: Vec::new(), // Clear default for multi-select + select_type: PhantomData, } - self.single_select = false; - self - } - - /// Set enum as untitled - /// Clears any previously set titles - pub fn untitled(mut self) -> EnumSchemaBuilder { - self.enum_titles = Vec::new(); - self.titled = false; - self } - /// Set default value for single-select enum - pub fn single_select_default( + /// Set default value + pub fn with_default( mut self, - default_value: String, - ) -> Result { - if !self.enum_values.contains(&default_value) { + default_value: impl Into, + ) -> Result, String> { + let value: String = default_value.into(); + if !self.enum_values.contains(&value) { return Err("Provided default value is not in enum values".to_string()); } - if !self.single_select { - return Err( - "Set single default value available only when the builder is set to single-select. \ - Use multi_select_default method for multi-select options".to_string(), - ); - } - self.default = vec![default_value]; + self.default = vec![value]; Ok(self) } - /// Set default value for multi-select enum - pub fn multi_select_default( + /// Build enum schema + pub fn build(mut self) -> EnumSchema { + match self.titled { + false => EnumSchema::Single(SingleSelectEnumSchema::Untitled( + UntitledSingleSelectEnumSchema { + type_: StringTypeConst, + title: self.title, + description: self.description, + enum_: self.enum_values, + default: self.default.pop(), + }, + )), + true => EnumSchema::Single(SingleSelectEnumSchema::Titled( + TitledSingleSelectEnumSchema { + type_: StringTypeConst, + title: self.title, + description: self.description, + one_of: self + .enum_titles + .into_iter() + .zip(self.enum_values) + .map(|(title, const_)| ConstTitle { const_, title }) + .collect(), + default: self.default.pop(), + }, + )), + } + } +} + +/// Enum selection builder for multi-select enums +impl EnumSchemaBuilder { + /// Set enum as single-select + /// If it was multi-select, clear default values + pub fn single_select(self) -> EnumSchemaBuilder { + EnumSchemaBuilder { + enum_values: self.enum_values, + titled: self.titled, + title: self.title, + description: self.description, + enum_titles: self.enum_titles, + min_items: None, + max_items: None, + default: Vec::new(), // Clear default for single-select + select_type: PhantomData, + } + } + + /// Set default values + pub fn with_default( mut self, default_values: Vec, - ) -> Result { - if self.single_select { - return Err( - "Set multiple default values available only when the builder is set to multi-select. \ - Use single_select_default method for single-select options".to_string(), - ); - } + ) -> Result, String> { for value in &default_values { if !self.enum_values.contains(value) { return Err("One of the provided default values is not in enum values".to_string()); @@ -790,7 +845,7 @@ impl EnumSchemaBuilder { } /// Set minimal number of items for multi-select enum options - pub fn min_items(mut self, value: u64) -> Result { + pub fn min_items(mut self, value: u64) -> Result, String> { if let Some(max) = self.max_items && value > max { @@ -801,7 +856,7 @@ impl EnumSchemaBuilder { } /// Set maximal number of items for multi-select enum options - pub fn max_items(mut self, value: u64) -> Result { + pub fn max_items(mut self, value: u64) -> Result, String> { if let Some(min) = self.min_items && value < min { @@ -811,45 +866,10 @@ impl EnumSchemaBuilder { Ok(self) } - /// Set title of enum schema - pub fn title(mut self, value: impl Into>) -> Self { - self.title = Some(value.into()); - self - } - - /// Set description of enum schema - pub fn description(mut self, value: impl Into>) -> Self { - self.description = Some(value.into()); - self - } - /// Build enum schema - pub fn build(mut self) -> EnumSchema { - match (self.single_select, self.titled) { - (true, false) => EnumSchema::Single(SingleSelectEnumSchema::Untitled( - UntitledSingleSelectEnumSchema { - type_: StringTypeConst, - title: self.title, - description: self.description, - enum_: self.enum_values, - default: self.default.pop(), - }, - )), - (true, true) => EnumSchema::Single(SingleSelectEnumSchema::Titled( - TitledSingleSelectEnumSchema { - type_: StringTypeConst, - title: self.title, - description: self.description, - one_of: self - .enum_titles - .into_iter() - .zip(self.enum_values) - .map(|(title, const_)| ConstTitle { const_, title }) - .collect(), - default: self.default.pop(), - }, - )), - (false, false) => EnumSchema::Multi(MultiSelectEnumSchema::Untitled( + pub fn build(self) -> EnumSchema { + match self.titled { + false => EnumSchema::Multi(MultiSelectEnumSchema::Untitled( UntitledMultiSelectEnumSchema { type_: ArrayTypeConst, title: self.title, @@ -867,28 +887,26 @@ impl EnumSchemaBuilder { }, }, )), - (false, true) => { - EnumSchema::Multi(MultiSelectEnumSchema::Titled(TitledMultiSelectEnumSchema { - type_: ArrayTypeConst, - title: self.title, - description: self.description, - min_items: self.min_items, - max_items: self.max_items, - items: TitledItems { - any_of: self - .enum_titles - .into_iter() - .zip(self.enum_values) - .map(|(title, const_)| ConstTitle { const_, title }) - .collect(), - }, - default: if self.default.is_empty() { - None - } else { - Some(self.default) - }, - })) - } + true => EnumSchema::Multi(MultiSelectEnumSchema::Titled(TitledMultiSelectEnumSchema { + type_: ArrayTypeConst, + title: self.title, + description: self.description, + min_items: self.min_items, + max_items: self.max_items, + items: TitledItems { + any_of: self + .enum_titles + .into_iter() + .zip(self.enum_values) + .map(|(title, const_)| ConstTitle { const_, title }) + .collect(), + }, + default: if self.default.is_empty() { + None + } else { + Some(self.default) + }, + })), } } } @@ -909,9 +927,14 @@ impl EnumSchema { /// ``` /// use rmcp::model::*; /// - /// let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]); + /// let enum_schema = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]). + /// with_default("A"). + /// expect("Default value should be valid"). + /// enum_titles(vec!["Option A".to_string(), "Option B".to_string()]). + /// expect("Number of titles should match number of values"). + /// build(); /// ``` - pub fn builder(values: Vec) -> EnumSchemaBuilder { + pub fn builder(values: Vec) -> EnumSchemaBuilder { EnumSchemaBuilder::new(values) } } @@ -1375,25 +1398,37 @@ impl ElicitationSchemaBuilder { /// Add a required enum property using values. Creates an untitled single-select enum. #[deprecated( - since = "0.12.0", + since = "0.13.0", note = "Use ElicitationSchemaBuilder::required_enum_schema with EnumSchema::builder instead" )] pub fn required_enum(self, name: impl Into, values: Vec) -> Self { self.required_property( name, - PrimitiveSchema::Enum(EnumSchema::builder(values).build()), + PrimitiveSchema::Enum(EnumSchema::Legacy(LegacyEnumSchema { + type_: StringTypeConst, + title: None, + description: None, + enum_: values, + enum_names: None, + })), ) } /// Add an optional enum property using values. Creates an untitled single-select enum. #[deprecated( - since = "0.12.0", + since = "0.13.0", note = "Use ElicitationSchemaBuilder::optional_enum_schema with EnumSchema::builder instead" )] pub fn optional_enum(self, name: impl Into, values: Vec) -> Self { self.property( name, - PrimitiveSchema::Enum(EnumSchema::builder(values).build()), + PrimitiveSchema::Enum(EnumSchema::Legacy(LegacyEnumSchema { + type_: StringTypeConst, + title: None, + description: None, + enum_: values, + enum_names: None, + })), ) } @@ -1559,6 +1594,25 @@ mod tests { Ok(()) } + #[test] + fn test_enum_schema_legacy_serialization() -> anyhow::Result<()> { + let schema = EnumSchema::Legacy(LegacyEnumSchema { + type_: StringTypeConst, + title: Some("Legacy Enum".into()), + description: Some("A legacy enum schema".into()), + enum_: vec!["A".to_string(), "B".to_string()], + enum_names: Some(vec!["Option A".to_string(), "Option B".to_string()]), + }); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!(json["title"], "Legacy Enum"); + assert_eq!(json["description"], "A legacy enum schema"); + assert_eq!(json["enum"], json!(["A", "B"])); + assert_eq!(json["enumNames"], json!(["Option A", "Option B"])); + Ok(()) + } + #[test] fn test_enum_schema_titled_multi_select_serialization() -> anyhow::Result<()> { let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) @@ -1590,6 +1644,156 @@ mod tests { Ok(()) } + #[test] + fn test_enum_schema_single_select_with_default() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec![ + "red".to_string(), + "green".to_string(), + "blue".to_string(), + ]) + .with_default("green") + .map_err(|e| anyhow!("{e}"))? + .description("Favorite color") + .build(); + + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!(json["enum"], json!(["red", "green", "blue"])); + assert_eq!(json["default"], "green"); + assert_eq!(json["description"], "Favorite color"); + Ok(()) + } + + #[test] + fn test_enum_schema_multi_select_with_default() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec![ + "red".to_string(), + "green".to_string(), + "blue".to_string(), + ]) + .multiselect() + .with_default(vec!["red".to_string(), "blue".to_string()]) + .map_err(|e| anyhow!("{e}"))? + .min_items(1) + .map_err(|e| anyhow!("{e}"))? + .max_items(3) + .map_err(|e| anyhow!("{e}"))? + .build(); + + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "array"); + assert_eq!(json["items"]["enum"], json!(["red", "green", "blue"])); + assert_eq!(json["default"], json!(["red", "blue"])); + assert_eq!(json["minItems"], 1); + assert_eq!(json["maxItems"], 3); + Ok(()) + } + + #[test] + fn test_enum_schema_transition_clears_defaults() -> anyhow::Result<()> { + // Start with single-select with default + let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) + .with_default("A") + .map_err(|e| anyhow!("{e}"))?; + + // Transition to multi-select should clear the default + let schema = builder.multiselect().build(); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "array"); + assert!(json["default"].is_null()); + Ok(()) + } + + #[test] + fn test_enum_schema_multi_to_single_transition() -> anyhow::Result<()> { + // Start with multi-select with defaults + let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string(), "C".to_string()]) + .multiselect() + .with_default(vec!["A".to_string(), "B".to_string()]) + .map_err(|e| anyhow!("{e}"))? + .min_items(1) + .map_err(|e| anyhow!("{e}"))?; + + // Transition back to single-select should clear defaults and min/max items + let schema = builder.single_select().build(); + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert!(json["default"].is_null()); + assert!(json["minItems"].is_null()); + assert!(json["maxItems"].is_null()); + Ok(()) + } + + #[test] + fn test_enum_schema_invalid_single_default() { + let result = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]).with_default("C"); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Provided default value is not in enum values" + ); + } + + #[test] + fn test_enum_schema_invalid_multi_default() { + let result = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) + .multiselect() + .with_default(vec!["A".to_string(), "C".to_string()]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "One of the provided default values is not in enum values" + ); + } + + #[test] + fn test_enum_schema_titled_with_default() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .enum_titles(vec![ + "United States".to_string(), + "United Kingdom".to_string(), + ]) + .map_err(|e| anyhow!("{e}"))? + .with_default("UK") + .map_err(|e| anyhow!("{e}"))? + .build(); + + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!(json["default"], "UK"); + assert_eq!( + json["oneOf"], + json!([ + {"const": "US", "title": "United States"}, + {"const": "UK", "title": "United Kingdom"} + ]) + ); + Ok(()) + } + + #[test] + fn test_enum_schema_untitled_after_titled() -> anyhow::Result<()> { + let schema = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]) + .enum_titles(vec!["Option A".to_string(), "Option B".to_string()]) + .map_err(|e| anyhow!("{e}"))? + .untitled() + .build(); + + let json = serde_json::to_value(&schema)?; + + assert_eq!(json["type"], "string"); + assert_eq!(json["enum"], json!(["A", "B"])); + assert!(json["oneOf"].is_null()); + Ok(()) + } + #[test] fn test_primitive_schema_enum_deserialization() { // Test that enum schemas deserialize as Enum variant, not String From 2d1456e5b87bef34e1189086ef0d78081b1899fe Mon Sep 17 00:00:00 2001 From: DJ Chen <80285637+dj707chen@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:54:57 -0600 Subject: [PATCH 011/333] fix(docs): add `-p parameter` to the `cargo run` commands in documentation (#592) * fix(docs): Add -p mcp-client-examples to cargo run commands in clients/README.md * fix(docs): Add -p mcp-server-examples to cargo run commands in examples/servers/README.md * fix(docs): Add -p parameter to cargo run commands in other documentation --- docs/OAUTH_SUPPORT.md | 4 ++-- examples/clients/README.md | 14 +++++++------- examples/clients/src/auth/oauth_client.rs | 2 +- examples/clients/src/progress_client.rs | 2 +- examples/clients/src/sampling_stdio.rs | 2 +- examples/servers/README.md | 16 ++++++++-------- examples/servers/src/sampling_stdio.rs | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 112caa36a..3142c62b1 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -84,10 +84,10 @@ rmcp = { version = "0.1", features = ["auth", "transport-streamable-http-client- ```bash # Run the OAuth server -cargo run --example servers_complex_auth_streamhttp +cargo run -p mcp-server-examples --example servers_complex_auth_streamhttp # Run the OAuth client (in another terminal) -cargo run --example clients_oauth_client +cargo run -p mcp-client-examples --example clients_oauth_client ``` ## Authorization Flow Description diff --git a/examples/clients/README.md b/examples/clients/README.md index 1012867f8..e066cd77d 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -63,7 +63,7 @@ A client demonstrating how to use the sampling tool. A client that communicates with an MCP server using progress notifications. -- Launches the `cargo run --example clients_progress_client -- --transport {stdio|http|all}` to test the progress notifications +- Launches the `cargo run -p mcp-client-examples --example clients_progress_client -- --transport {stdio|http|all}` to test the progress notifications - Connects to the server using different transport methods - Tests the progress notifications - The http transport should run the server first @@ -75,22 +75,22 @@ Each example can be run using Cargo: ```bash # Run the Git standard I/O client example -cargo run --example clients_git_stdio +cargo run -p mcp-client-examples --example clients_git_stdio # Run the streamable HTTP client example -cargo run --example clients_streamable_http +cargo run -p mcp-client-examples --example clients_streamable_http # Run the full-featured standard I/O client example -cargo run --example clients_everything_stdio +cargo run -p mcp-client-examples --example clients_everything_stdio # Run the client collection example -cargo run --example clients_collection +cargo run -p mcp-client-examples --example clients_collection # Run the OAuth client example -cargo run --example clients_oauth_client +cargo run -p mcp-client-examples --example clients_oauth_client # Run the sampling standard I/O client example -cargo run --example clients_sampling_stdio +cargo run -p mcp-client-examples --example clients_sampling_stdio ``` ## Dependencies diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index ab7867cf1..4f94a3ced 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -97,7 +97,7 @@ async fn main() -> Result<()> { // Get server URL and client metadata URL from CLI (with defaults) // // Usage: - // cargo run --example clients_oauth_client -- + // cargo run -p mcp-client-examples --example clients_oauth_client -- let args: Vec = env::args().collect(); let server_url = args .get(1) diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index c795ce22e..888738ba3 100644 --- a/examples/clients/src/progress_client.rs +++ b/examples/clients/src/progress_client.rs @@ -200,7 +200,7 @@ async fn test_stdio_transport(records: u32) -> Result<()> { Ok(()) } -// Test HTTP transport, must run the server with `cargo run --example servers_progress_demo -- http` in the servers directory +// Test HTTP transport, must run the server with `cargo run -p mcp-client-examples --example servers_progress_demo -- http` in the servers directory async fn test_http_transport(http_url: &str, records: u32) -> Result<()> { tracing::info!("Testing HTTP Streaming Transport"); tracing::info!("====================================="); diff --git a/examples/clients/src/sampling_stdio.rs b/examples/clients/src/sampling_stdio.rs index b30a3c26a..642d315ac 100644 --- a/examples/clients/src/sampling_stdio.rs +++ b/examples/clients/src/sampling_stdio.rs @@ -12,7 +12,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; /// /// This client demonstrates how to handle sampling requests from servers. /// It includes a mock LLM that generates simple responses. -/// Run with: cargo run --example clients_sampling_stdio +/// Run with: cargo run -p mcp-client-examples --example clients_sampling_stdio #[derive(Clone, Debug, Default)] pub struct SamplingDemoClient; diff --git a/examples/servers/README.md b/examples/servers/README.md index 9a42670a4..946a2433f 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -68,7 +68,7 @@ A server that demonstrates progress notifications during long-running operations - Provides a stream_processor tool that generates progress notifications - Demonstrates progress notifications during long-running operations -- Can be run with `cargo run --example servers_progress_demo -- {stdio|http|all}` +- Can be run with `cargo run -p mcp-server-examples --example servers_progress_demo -- {stdio|http|all}` ### Simple Auth Streamable HTTP Server (`simple_auth_streamhttp.rs`) @@ -95,25 +95,25 @@ Each example can be run using Cargo: ```bash # Run the counter standard I/O server -cargo run --example servers_counter_stdio +cargo run -p mcp-server-examples --example servers_counter_stdio # Run the memory standard I/O server -cargo run --example servers_memory_stdio +cargo run -p mcp-server-examples --example servers_memory_stdio # Run the counter streamable HTTP server -cargo run --example servers_counter_streamhttp +cargo run -p mcp-server-examples --example servers_counter_streamhttp # Run the elicitation standard I/O server -cargo run --example servers_elicitation_stdio +cargo run -p mcp-server-examples --example servers_elicitation_stdio # Run the prompt standard I/O server -cargo run --example servers_prompt_stdio +cargo run -p mcp-server-examples --example servers_prompt_stdio # Run the simple auth streamable HTTP server -cargo run --example servers_simple_auth_streamhttp +cargo run -p mcp-server-examples --example servers_simple_auth_streamhttp # Run the complex auth streamable HTTP server -cargo run --example servers_complex_auth_streamhttp +cargo run -p mcp-server-examples --example servers_complex_auth_streamhttp ``` ## Testing with MCP Inspector diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 307ab6820..1ef6309b8 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -12,7 +12,7 @@ use tracing_subscriber::{self, EnvFilter}; /// Simple Sampling Demo Server /// /// This server demonstrates how to request LLM sampling from clients. -/// Run with: cargo run --example servers_sampling_stdio +/// Run with: cargo run -p mcp-server-examples --example servers_sampling_stdio #[derive(Clone, Debug, Default)] pub struct SamplingDemoServer; From 48e989b7115940cadd8c989127103c56fc1340bf Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 15 Jan 2026 13:24:30 -0500 Subject: [PATCH 012/333] chore: release v0.13.0 (#620) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 15 +++++++++++++++ crates/rmcp/CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a935e7ad5..188aa4d52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "0.12.0", path = "./crates/rmcp" } -rmcp-macros = { version = "0.12.0", path = "./crates/rmcp-macros" } +rmcp = { version = "0.13.0", path = "./crates/rmcp" } +rmcp-macros = { version = "0.13.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "0.12.0" +version = "0.13.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 0f861e0dc..7cd42c54c 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.12.0...rmcp-macros-v0.13.0) - 2026-01-15 + +### Added + +- *(task)* add task support (SEP-1686) ([#536](https://github.com/modelcontextprotocol/rust-sdk/pull/536)) + +### Fixed + +- *(docs)* add spreadsheet-mcp to Built with rmcp ([#582](https://github.com/modelcontextprotocol/rust-sdk/pull/582)) + +### Other + +- update README external links ([#603](https://github.com/modelcontextprotocol/rust-sdk/pull/603)) +- clarity and formatting ([#602](https://github.com/modelcontextprotocol/rust-sdk/pull/602)) + ## [0.12.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.11.0...rmcp-macros-v0.12.0) - 2025-12-18 ### Other diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index fa32806b4..7feff31cf 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.12.0...rmcp-v0.13.0) - 2026-01-15 + +### Added + +- provide blanket implementations for ClientHandler and ServerHandler traits ([#609](https://github.com/modelcontextprotocol/rust-sdk/pull/609)) +- *(service)* add close() method for graceful connection shutdown ([#588](https://github.com/modelcontextprotocol/rust-sdk/pull/588)) +- *(auth)* add StateStore trait for pluggable OAuth state storage ([#614](https://github.com/modelcontextprotocol/rust-sdk/pull/614)) +- *(elicitation)* implement SEP-1330 Elicitation Enum Schema Improvements ([#539](https://github.com/modelcontextprotocol/rust-sdk/pull/539)) +- *(task)* add task support (SEP-1686) ([#536](https://github.com/modelcontextprotocol/rust-sdk/pull/536)) + +### Fixed + +- use the json rpc error from the initialize response and bubble it up to the client ([#569](https://github.com/modelcontextprotocol/rust-sdk/pull/569)) +- *(build)* fix build of the project when no features are selected ([#606](https://github.com/modelcontextprotocol/rust-sdk/pull/606)) +- use Semaphore instead of Notify in OneshotTransport to prevent race condition ([#611](https://github.com/modelcontextprotocol/rust-sdk/pull/611)) +- add OpenID Connect discovery support per spec-2025-11-25 4.3 ([#598](https://github.com/modelcontextprotocol/rust-sdk/pull/598)) +- only try to refresh access tokens if we have a refresh token or an expiry time ([#594](https://github.com/modelcontextprotocol/rust-sdk/pull/594)) +- *(docs)* add spreadsheet-mcp to Built with rmcp ([#582](https://github.com/modelcontextprotocol/rust-sdk/pull/582)) + +### Other + +- *(elicitation)* improve enum schema builder, small changes of elicitation builder ([#608](https://github.com/modelcontextprotocol/rust-sdk/pull/608)) +- add pre-commit hook for conventional commit verification ([#619](https://github.com/modelcontextprotocol/rust-sdk/pull/619)) +- clean up optional dependencies ([#546](https://github.com/modelcontextprotocol/rust-sdk/pull/546)) +- re-export ServerSseMessage from session module ([#612](https://github.com/modelcontextprotocol/rust-sdk/pull/612)) +- Implement SEP-1699: Support SSE Polling via Server-Side Disconnect ([#604](https://github.com/modelcontextprotocol/rust-sdk/pull/604)) +- update README external links ([#603](https://github.com/modelcontextprotocol/rust-sdk/pull/603)) +- clarity and formatting ([#602](https://github.com/modelcontextprotocol/rust-sdk/pull/602)) +- Add optional icons field to RawResourceTemplate ([#589](https://github.com/modelcontextprotocol/rust-sdk/pull/589)) + ## [0.12.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.11.0...rmcp-v0.12.0) - 2025-12-18 ### Added From 9e881a645b69763580a0f84168e1490d292548ae Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:16:52 -0500 Subject: [PATCH 013/333] Implement SEP-1319: Decouple Request Payload from RPC Methods (#617) * feat: implement SEP-1319 Decouple Request Payload from RPC Methods * test: update tests * fix: update handler trait methods to use new types * fix: update examples * fix: correct deprecation version * fix: update wrapper macros to use new *Params type names --- crates/rmcp-macros/src/prompt_handler.rs | 4 +- crates/rmcp-macros/src/tool_handler.rs | 4 +- crates/rmcp/src/handler/client.rs | 8 +- crates/rmcp/src/handler/server.rs | 68 ++-- crates/rmcp/src/handler/server/tool.rs | 7 +- crates/rmcp/src/lib.rs | 5 +- crates/rmcp/src/model.rs | 330 ++++++++++++++++-- crates/rmcp/src/model/meta.rs | 52 +++ crates/rmcp/src/service/client.rs | 56 +-- crates/rmcp/src/service/server.rs | 13 +- crates/rmcp/tests/common/handlers.rs | 4 +- crates/rmcp/tests/test_completion.rs | 6 +- crates/rmcp/tests/test_elicitation.rs | 61 ++-- crates/rmcp/tests/test_logging.rs | 17 +- crates/rmcp/tests/test_message_protocol.rs | 40 ++- .../client_json_rpc_message_schema.json | 155 ++++++-- ...lient_json_rpc_message_schema_current.json | 155 ++++++-- .../server_json_rpc_message_schema.json | 36 +- ...erver_json_rpc_message_schema_current.json | 36 +- crates/rmcp/tests/test_notification.rs | 7 +- crates/rmcp/tests/test_progress_subscriber.rs | 5 +- crates/rmcp/tests/test_prompt_handler.rs | 2 +- crates/rmcp/tests/test_prompt_macros.rs | 10 +- crates/rmcp/tests/test_sampling.rs | 18 +- crates/rmcp/tests/test_tool_macros.rs | 8 +- examples/clients/src/collection.rs | 5 +- examples/clients/src/everything_stdio.rs | 17 +- examples/clients/src/git_stdio.rs | 5 +- examples/clients/src/progress_client.rs | 9 +- examples/clients/src/sampling_stdio.rs | 5 +- examples/clients/src/streamable_http.rs | 6 +- examples/rig-integration/src/mcp_adaptor.rs | 5 +- examples/servers/src/common/counter.rs | 11 +- examples/servers/src/completion_stdio.rs | 2 +- examples/servers/src/sampling_stdio.rs | 8 +- examples/simple-chat-client/src/tool.rs | 5 +- examples/transport/src/named-pipe.rs | 3 +- examples/transport/src/unix_socket.rs | 3 +- 38 files changed, 913 insertions(+), 278 deletions(-) diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index c37f1eabf..7e534f92f 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -28,7 +28,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { let prompt_context = rmcp::handler::server::prompt::PromptContext::new( @@ -51,7 +51,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result, + _request: Option, _context: RequestContext, ) -> Result { let prompts = #router_expr.list_all(); diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index b1b53c429..e37fa88e7 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -29,7 +29,7 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); @@ -46,7 +46,7 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, + _request: Option, _context: rmcp::service::RequestContext, ) -> Result { Ok(rmcp::model::ListToolsResult{ diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index cd238310f..86539b87b 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -85,7 +85,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { fn create_message( &self, - params: CreateMessageRequestParam, + params: CreateMessageRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err( @@ -118,7 +118,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { /// Real clients should override this to provide user interaction. fn create_elicitation( &self, - request: CreateElicitationRequestParam, + request: CreateElicitationRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { // Default implementation declines all requests - real clients should override this @@ -225,7 +225,7 @@ macro_rules! impl_client_handler_for_wrapper { fn create_message( &self, - params: CreateMessageRequestParam, + params: CreateMessageRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).create_message(params, context) @@ -240,7 +240,7 @@ macro_rules! impl_client_handler_for_wrapper { fn create_elicitation( &self, - request: CreateElicitationRequestParam, + request: CreateElicitationRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).create_elicitation(request, context) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 073ee1086..ee744b776 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -137,7 +137,7 @@ impl Service for H { pub trait ServerHandler: Sized + Send + Sync + 'static { fn enqueue_task( &self, - _request: CallToolRequestParam, + _request: CallToolRequestParams, _context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err(McpError::internal_error( @@ -154,7 +154,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { // handle requests fn initialize( &self, - request: InitializeRequestParam, + request: InitializeRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { if context.peer.peer_info().is_none() { @@ -164,49 +164,49 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { } fn complete( &self, - request: CompleteRequestParam, + request: CompleteRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Ok(CompleteResult::default())) } fn set_level( &self, - request: SetLevelRequestParam, + request: SetLevelRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn get_prompt( &self, - request: GetPromptRequestParam, + request: GetPromptRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn list_prompts( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Ok(ListPromptsResult::default())) } fn list_resources( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Ok(ListResourcesResult::default())) } fn list_resource_templates( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Ok(ListResourceTemplatesResult::default())) } fn read_resource( &self, - request: ReadResourceRequestParam, + request: ReadResourceRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err( @@ -215,28 +215,28 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { } fn subscribe( &self, - request: SubscribeRequestParam, + request: SubscribeRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn unsubscribe( &self, - request: UnsubscribeRequestParam, + request: UnsubscribeRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn call_tool( &self, - request: CallToolRequestParam, + request: CallToolRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn list_tools( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Ok(ListToolsResult::default())) @@ -297,7 +297,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { fn list_tasks( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err(McpError::method_not_found::())) @@ -305,7 +305,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { fn get_task_info( &self, - request: GetTaskInfoParam, + request: GetTaskInfoParams, context: RequestContext, ) -> impl Future> + Send + '_ { std::future::ready(Err(McpError::method_not_found::())) @@ -313,7 +313,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { fn get_task_result( &self, - request: GetTaskResultParam, + request: GetTaskResultParams, context: RequestContext, ) -> impl Future> + Send + '_ { let _ = (request, context); @@ -322,7 +322,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { fn cancel_task( &self, - request: CancelTaskParam, + request: CancelTaskParams, context: RequestContext, ) -> impl Future> + Send + '_ { let _ = (request, context); @@ -335,7 +335,7 @@ macro_rules! impl_server_handler_for_wrapper { impl ServerHandler for $wrapper { fn enqueue_task( &self, - request: CallToolRequestParam, + request: CallToolRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).enqueue_task(request, context) @@ -350,7 +350,7 @@ macro_rules! impl_server_handler_for_wrapper { fn initialize( &self, - request: InitializeRequestParam, + request: InitializeRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).initialize(request, context) @@ -358,7 +358,7 @@ macro_rules! impl_server_handler_for_wrapper { fn complete( &self, - request: CompleteRequestParam, + request: CompleteRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).complete(request, context) @@ -366,7 +366,7 @@ macro_rules! impl_server_handler_for_wrapper { fn set_level( &self, - request: SetLevelRequestParam, + request: SetLevelRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).set_level(request, context) @@ -374,7 +374,7 @@ macro_rules! impl_server_handler_for_wrapper { fn get_prompt( &self, - request: GetPromptRequestParam, + request: GetPromptRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).get_prompt(request, context) @@ -382,7 +382,7 @@ macro_rules! impl_server_handler_for_wrapper { fn list_prompts( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).list_prompts(request, context) @@ -390,7 +390,7 @@ macro_rules! impl_server_handler_for_wrapper { fn list_resources( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).list_resources(request, context) @@ -398,7 +398,7 @@ macro_rules! impl_server_handler_for_wrapper { fn list_resource_templates( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { @@ -407,7 +407,7 @@ macro_rules! impl_server_handler_for_wrapper { fn read_resource( &self, - request: ReadResourceRequestParam, + request: ReadResourceRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).read_resource(request, context) @@ -415,7 +415,7 @@ macro_rules! impl_server_handler_for_wrapper { fn subscribe( &self, - request: SubscribeRequestParam, + request: SubscribeRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).subscribe(request, context) @@ -423,7 +423,7 @@ macro_rules! impl_server_handler_for_wrapper { fn unsubscribe( &self, - request: UnsubscribeRequestParam, + request: UnsubscribeRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).unsubscribe(request, context) @@ -431,7 +431,7 @@ macro_rules! impl_server_handler_for_wrapper { fn call_tool( &self, - request: CallToolRequestParam, + request: CallToolRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).call_tool(request, context) @@ -439,7 +439,7 @@ macro_rules! impl_server_handler_for_wrapper { fn list_tools( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).list_tools(request, context) @@ -497,7 +497,7 @@ macro_rules! impl_server_handler_for_wrapper { fn list_tasks( &self, - request: Option, + request: Option, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).list_tasks(request, context) @@ -505,7 +505,7 @@ macro_rules! impl_server_handler_for_wrapper { fn get_task_info( &self, - request: GetTaskInfoParam, + request: GetTaskInfoParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).get_task_info(request, context) @@ -513,7 +513,7 @@ macro_rules! impl_server_handler_for_wrapper { fn get_task_result( &self, - request: GetTaskResultParam, + request: GetTaskResultParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).get_task_result(request, context) @@ -521,7 +521,7 @@ macro_rules! impl_server_handler_for_wrapper { fn cancel_task( &self, - request: CancelTaskParam, + request: CancelTaskParams, context: RequestContext, ) -> impl Future> + Send + '_ { (**self).cancel_task(request, context) diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index 16435e429..c98aef0d5 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -15,7 +15,7 @@ pub use super::{ use crate::{ RoleServer, handler::server::wrapper::Parameters, - model::{CallToolRequestParam, CallToolResult, IntoContents, JsonObject}, + model::{CallToolRequestParams, CallToolResult, IntoContents, JsonObject}, service::RequestContext, }; @@ -39,11 +39,12 @@ pub struct ToolCallContext<'s, S> { impl<'s, S> ToolCallContext<'s, S> { pub fn new( service: &'s S, - CallToolRequestParam { + CallToolRequestParams { + meta: _, name, arguments, task, - }: CallToolRequestParam, + }: CallToolRequestParams, request_context: RequestContext, ) -> Self { Self { diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index f1f1e4067..5f543d278 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -104,7 +104,7 @@ //! //! ```rust //! use anyhow::Result; -//! use rmcp::{model::CallToolRequestParam, service::ServiceExt}; +//! use rmcp::{model::CallToolRequestParams, service::ServiceExt}; //! #[cfg(feature = "transport-child-process")] //! #[cfg_attr(docsrs, doc(cfg(feature = "transport-child-process")))] //! use rmcp::transport::{TokioChildProcess, ConfigureCommandExt}; @@ -127,7 +127,8 @@ //! //! // Call tool 'git_status' with arguments = {"repo_path": "."} //! let tool_result = service -//! .call_tool(CallToolRequestParam { +//! .call_tool(CallToolRequestParams { +//! meta: None, //! name: "git_status".into(), //! arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(), //! task: None, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 36cd8fb90..ae6bdb069 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -718,7 +718,7 @@ impl CustomRequest { const_string!(InitializeResultMethod = "initialize"); /// # Initialization /// This request is sent from the client to the server when it first connects, asking it to begin initialization. -pub type InitializeRequest = Request; +pub type InitializeRequest = Request; const_string!(InitializedNotificationMethod = "notifications/initialized"); /// This notification is sent from the client to the server after initialization has finished. @@ -731,7 +731,10 @@ pub type InitializedNotification = NotificationNoParam, /// The MCP protocol version this client supports pub protocol_version: ProtocolVersion, /// The capabilities this client supports (sampling, roots, etc.) @@ -740,6 +743,19 @@ pub struct InitializeRequestParam { pub client_info: Implementation, } +impl RequestParamsMeta for InitializeRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`InitializeRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use InitializeRequestParams instead")] +pub type InitializeRequestParam = InitializeRequestParams; + /// The server's response to an initialization request. /// /// Contains the server's protocol version, capabilities, and implementation @@ -760,7 +776,7 @@ pub struct InitializeResult { } pub type ServerInfo = InitializeResult; -pub type ClientInfo = InitializeRequestParam; +pub type ClientInfo = InitializeRequestParams; #[allow(clippy::derivable_impls)] impl Default for ServerInfo { @@ -778,6 +794,7 @@ impl Default for ServerInfo { impl Default for ClientInfo { fn default() -> Self { ClientInfo { + meta: None, protocol_version: ProtocolVersion::default(), capabilities: ClientCapabilities::default(), client_info: Implementation::from_build_env(), @@ -843,10 +860,26 @@ impl Implementation { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct PaginatedRequestParam { +pub struct PaginatedRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, } + +impl RequestParamsMeta for PaginatedRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`PaginatedRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use PaginatedRequestParams instead")] +pub type PaginatedRequestParam = PaginatedRequestParams; // ============================================================================= // PROGRESS AND PAGINATION // ============================================================================= @@ -910,7 +943,7 @@ macro_rules! paginated_result { const_string!(ListResourcesRequestMethod = "resources/list"); /// Request to list all available resources from a server pub type ListResourcesRequest = - RequestOptionalParam; + RequestOptionalParam; paginated_result!(ListResourcesResult { resources: Vec @@ -919,7 +952,7 @@ paginated_result!(ListResourcesResult { const_string!(ListResourceTemplatesRequestMethod = "resources/templates/list"); /// Request to list all available resource templates from a server pub type ListResourceTemplatesRequest = - RequestOptionalParam; + RequestOptionalParam; paginated_result!(ListResourceTemplatesResult { resource_templates: Vec @@ -930,11 +963,27 @@ const_string!(ReadResourceRequestMethod = "resources/read"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct ReadResourceRequestParam { +pub struct ReadResourceRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, /// The URI of the resource to read pub uri: String, } +impl RequestParamsMeta for ReadResourceRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`ReadResourceRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use ReadResourceRequestParams instead")] +pub type ReadResourceRequestParam = ReadResourceRequestParams; + /// Result containing the contents of a read resource #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -944,7 +993,7 @@ pub struct ReadResourceResult { } /// Request to read a specific resource -pub type ReadResourceRequest = Request; +pub type ReadResourceRequest = Request; const_string!(ResourceListChangedNotificationMethod = "notifications/resources/list_changed"); /// Notification sent when the list of available resources changes @@ -956,24 +1005,58 @@ const_string!(SubscribeRequestMethod = "resources/subscribe"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct SubscribeRequestParam { +pub struct SubscribeRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, /// The URI of the resource to subscribe to pub uri: String, } + +impl RequestParamsMeta for SubscribeRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`SubscribeRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use SubscribeRequestParams instead")] +pub type SubscribeRequestParam = SubscribeRequestParams; + /// Request to subscribe to resource updates -pub type SubscribeRequest = Request; +pub type SubscribeRequest = Request; const_string!(UnsubscribeRequestMethod = "resources/unsubscribe"); /// Parameters for unsubscribing from resource updates #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct UnsubscribeRequestParam { +pub struct UnsubscribeRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, /// The URI of the resource to unsubscribe from pub uri: String, } + +impl RequestParamsMeta for UnsubscribeRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`UnsubscribeRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use UnsubscribeRequestParams instead")] +pub type UnsubscribeRequestParam = UnsubscribeRequestParams; + /// Request to unsubscribe from resource updates -pub type UnsubscribeRequest = Request; +pub type UnsubscribeRequest = Request; const_string!(ResourceUpdatedNotificationMethod = "notifications/resources/updated"); /// Parameters for a resource update notification @@ -994,7 +1077,8 @@ pub type ResourceUpdatedNotification = const_string!(ListPromptsRequestMethod = "prompts/list"); /// Request to list all available prompts from a server -pub type ListPromptsRequest = RequestOptionalParam; +pub type ListPromptsRequest = + RequestOptionalParam; paginated_result!(ListPromptsResult { prompts: Vec @@ -1005,13 +1089,30 @@ const_string!(GetPromptRequestMethod = "prompts/get"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct GetPromptRequestParam { +pub struct GetPromptRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, } + +impl RequestParamsMeta for GetPromptRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`GetPromptRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use GetPromptRequestParams instead")] +pub type GetPromptRequestParam = GetPromptRequestParams; + /// Request to get a specific prompt -pub type GetPromptRequest = Request; +pub type GetPromptRequest = Request; const_string!(PromptListChangedNotificationMethod = "notifications/prompts/list_changed"); /// Notification sent when the list of available prompts changes @@ -1045,12 +1146,29 @@ const_string!(SetLevelRequestMethod = "logging/setLevel"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct SetLevelRequestParam { +pub struct SetLevelRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, /// The desired logging level pub level: LoggingLevel, } + +impl RequestParamsMeta for SetLevelRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`SetLevelRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use SetLevelRequestParams instead")] +pub type SetLevelRequestParam = SetLevelRequestParams; + /// Request to set the logging level -pub type SetLevelRequest = Request; +pub type SetLevelRequest = Request; const_string!(LoggingMessageNotificationMethod = "notifications/message"); /// Parameters for a logging message notification @@ -1075,7 +1193,7 @@ pub type LoggingMessageNotification = // ============================================================================= const_string!(CreateMessageRequestMethod = "sampling/createMessage"); -pub type CreateMessageRequest = Request; +pub type CreateMessageRequest = Request; /// Represents the role of a participant in a conversation or message exchange. /// @@ -1128,10 +1246,19 @@ pub enum ContextInclusion { /// This structure contains all the necessary information for a client to /// generate an LLM response, including conversation history, model preferences, /// and generation parameters. +/// +/// This implements `TaskAugmentedRequestParamsMeta` as sampling requests can be +/// long-running and may benefit from task-based execution. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct CreateMessageRequestParam { +pub struct CreateMessageRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Task metadata for async task management (SEP-1319) + #[serde(skip_serializing_if = "Option::is_none")] + pub task: Option, /// The conversation history and current messages pub messages: Vec, /// Preferences for model selection and behavior @@ -1156,6 +1283,28 @@ pub struct CreateMessageRequestParam { pub metadata: Option, } +impl RequestParamsMeta for CreateMessageRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +impl TaskAugmentedRequestParamsMeta for CreateMessageRequestParams { + fn task(&self) -> Option<&JsonObject> { + self.task.as_ref() + } + fn task_mut(&mut self) -> &mut Option { + &mut self.task + } +} + +/// Deprecated: Use [`CreateMessageRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use CreateMessageRequestParams instead")] +pub type CreateMessageRequestParam = CreateMessageRequestParams; + /// Preferences for model selection and behavior in sampling requests. /// /// This allows servers to express their preferences for which model to use @@ -1244,7 +1393,10 @@ impl CompletionContext { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct CompleteRequestParam { +pub struct CompleteRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, pub r#ref: Reference, pub argument: ArgumentInfo, /// Optional context containing previously resolved argument values @@ -1252,7 +1404,20 @@ pub struct CompleteRequestParam { pub context: Option, } -pub type CompleteRequest = Request; +impl RequestParamsMeta for CompleteRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`CompleteRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use CompleteRequestParams instead")] +pub type CompleteRequestParam = CompleteRequestParams; + +pub type CompleteRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] @@ -1477,7 +1642,8 @@ pub enum ElicitationAction { /// ```rust /// use rmcp::model::*; /// -/// let params = CreateElicitationRequestParam { +/// let params = CreateElicitationRequestParams { +/// meta: None, /// message: "Please provide your email".to_string(), /// requested_schema: ElicitationSchema::builder() /// .required_email("email") @@ -1488,7 +1654,11 @@ pub enum ElicitationAction { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct CreateElicitationRequestParam { +pub struct CreateElicitationRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Human-readable message explaining what input is needed from the user. /// This should be clear and provide sufficient context for the user to understand /// what information they need to provide. @@ -1500,6 +1670,19 @@ pub struct CreateElicitationRequestParam { pub requested_schema: ElicitationSchema, } +impl RequestParamsMeta for CreateElicitationRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`CreateElicitationRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use CreateElicitationRequestParams instead")] +pub type CreateElicitationRequestParam = CreateElicitationRequestParams; + /// The result returned by a client in response to an elicitation request. /// /// Contains the user's decision (accept/decline/cancel) and optionally their input data @@ -1520,7 +1703,7 @@ pub struct CreateElicitationResult { /// Request type for creating an elicitation to gather user input pub type CreateElicitationRequest = - Request; + Request; // ============================================================================= // TOOL EXECUTION RESULTS @@ -1685,7 +1868,7 @@ impl<'de> Deserialize<'de> for CallToolResult { const_string!(ListToolsRequestMethod = "tools/list"); /// Request to list all available tools from a server -pub type ListToolsRequest = RequestOptionalParam; +pub type ListToolsRequest = RequestOptionalParam; paginated_result!( ListToolsResult { @@ -1698,21 +1881,50 @@ const_string!(CallToolRequestMethod = "tools/call"); /// /// Contains the tool name and optional arguments needed to execute /// the tool operation. +/// +/// This implements `TaskAugmentedRequestParamsMeta` as tool calls can be +/// long-running and may benefit from task-based execution. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct CallToolRequestParam { +pub struct CallToolRequestParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, /// The name of the tool to call pub name: Cow<'static, str>, /// Arguments to pass to the tool (must match the tool's input schema) #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, + /// Task metadata for async task management (SEP-1319) #[serde(skip_serializing_if = "Option::is_none")] pub task: Option, } +impl RequestParamsMeta for CallToolRequestParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +impl TaskAugmentedRequestParamsMeta for CallToolRequestParams { + fn task(&self) -> Option<&JsonObject> { + self.task.as_ref() + } + fn task_mut(&mut self) -> &mut Option { + &mut self.task + } +} + +/// Deprecated: Use [`CallToolRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use CallToolRequestParams instead")] +pub type CallToolRequestParam = CallToolRequestParams; + /// Request to call a specific tool -pub type CallToolRequest = Request; +pub type CallToolRequest = Request; /// The result of a sampling/createMessage request containing the generated response. /// @@ -1752,37 +1964,85 @@ pub struct GetPromptResult { // ============================================================================= const_string!(GetTaskInfoMethod = "tasks/get"); -pub type GetTaskInfoRequest = Request; +pub type GetTaskInfoRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct GetTaskInfoParam { +pub struct GetTaskInfoParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, pub task_id: String, } +impl RequestParamsMeta for GetTaskInfoParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`GetTaskInfoParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use GetTaskInfoParams instead")] +pub type GetTaskInfoParam = GetTaskInfoParams; + const_string!(ListTasksMethod = "tasks/list"); -pub type ListTasksRequest = RequestOptionalParam; +pub type ListTasksRequest = RequestOptionalParam; const_string!(GetTaskResultMethod = "tasks/result"); -pub type GetTaskResultRequest = Request; +pub type GetTaskResultRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct GetTaskResultParam { +pub struct GetTaskResultParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, pub task_id: String, } +impl RequestParamsMeta for GetTaskResultParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`GetTaskResultParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use GetTaskResultParams instead")] +pub type GetTaskResultParam = GetTaskResultParams; + const_string!(CancelTaskMethod = "tasks/cancel"); -pub type CancelTaskRequest = Request; +pub type CancelTaskRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct CancelTaskParam { +pub struct CancelTaskParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, pub task_id: String, } + +impl RequestParamsMeta for CancelTaskParams { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")] +pub type CancelTaskParam = CancelTaskParams; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -2201,11 +2461,13 @@ mod tests { serde_json::from_value(request.clone()).expect("invalid request"); let (request, id) = request.into_request().expect("should be a request"); assert_eq!(id, RequestId::Number(1)); + #[allow(deprecated)] match request { ClientRequest::InitializeRequest(Request { method: _, params: InitializeRequestParam { + meta: _, protocol_version: _, capabilities, client_info, diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index acda39003..c979318da 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -18,6 +18,51 @@ pub trait GetExtensions { fn extensions_mut(&mut self) -> &mut Extensions; } +/// Trait for request params that contain the `_meta` field. +/// +/// Per the MCP 2025-11-25 spec, all request params should have an optional `_meta` +/// field that can contain a `progressToken` for tracking long-running operations. +pub trait RequestParamsMeta { + /// Get a reference to the meta field + fn meta(&self) -> Option<&Meta>; + /// Get a mutable reference to the meta field + fn meta_mut(&mut self) -> &mut Option; + /// Set the meta field + fn set_meta(&mut self, meta: Meta) { + *self.meta_mut() = Some(meta); + } + /// Get the progress token from meta, if present + fn progress_token(&self) -> Option { + self.meta().and_then(|m| m.get_progress_token()) + } + /// Set a progress token in meta + fn set_progress_token(&mut self, token: ProgressToken) { + match self.meta_mut() { + Some(meta) => meta.set_progress_token(token), + none => { + let mut meta = Meta::new(); + meta.set_progress_token(token); + *none = Some(meta); + } + } + } +} + +/// Trait for task-augmented request params that contain both `_meta` and `task` fields. +/// +/// Per the MCP 2025-11-25 spec, certain requests (like `tools/call` and `sampling/createMessage`) +/// can include a `task` field to signal that the caller wants task-augmented execution. +pub trait TaskAugmentedRequestParamsMeta: RequestParamsMeta { + /// Get a reference to the task field + fn task(&self) -> Option<&JsonObject>; + /// Get a mutable reference to the task field + fn task_mut(&mut self) -> &mut Option; + /// Set the task field + fn set_task(&mut self, task: JsonObject) { + *self.task_mut() = Some(task); + } +} + impl GetExtensions for CustomNotification { fn extensions(&self) -> &Extensions { &self.extensions @@ -156,6 +201,13 @@ impl Meta { Self(JsonObject::new()) } + /// Create a new Meta with a progress token set + pub fn with_progress_token(token: ProgressToken) -> Self { + let mut meta = Self::new(); + meta.set_progress_token(token); + meta + } + pub(crate) fn static_empty() -> &'static Self { static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); EMPTY.get_or_init(Default::default) diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index e6991f38a..837fafeff 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -5,18 +5,18 @@ use thiserror::Error; use super::*; use crate::{ model::{ - ArgumentInfo, CallToolRequest, CallToolRequestParam, CallToolResult, CancelledNotification, - CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, - ClientRequest, ClientResult, CompleteRequest, CompleteRequestParam, CompleteResult, - CompletionContext, CompletionInfo, ErrorData, GetPromptRequest, GetPromptRequestParam, - GetPromptResult, InitializeRequest, InitializedNotification, JsonRpcResponse, - ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, + ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResult, + CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, + ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, + CompleteResult, CompletionContext, CompletionInfo, ErrorData, GetPromptRequest, + GetPromptRequestParams, GetPromptResult, InitializeRequest, InitializedNotification, + JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest, - ListToolsResult, PaginatedRequestParam, ProgressNotification, ProgressNotificationParam, - ReadResourceRequest, ReadResourceRequestParam, ReadResourceResult, Reference, RequestId, + ListToolsResult, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, + ReadResourceRequest, ReadResourceRequestParams, ReadResourceResult, Reference, RequestId, RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification, - ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParam, SubscribeRequest, - SubscribeRequestParam, UnsubscribeRequest, UnsubscribeRequestParam, + ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SubscribeRequest, + SubscribeRequestParams, UnsubscribeRequest, UnsubscribeRequestParams, }, transport::DynamicTransportError, }; @@ -350,17 +350,17 @@ macro_rules! method { } impl Peer { - method!(peer_req complete CompleteRequest(CompleteRequestParam) => CompleteResult); - method!(peer_req set_level SetLevelRequest(SetLevelRequestParam)); - method!(peer_req get_prompt GetPromptRequest(GetPromptRequestParam) => GetPromptResult); - method!(peer_req list_prompts ListPromptsRequest(PaginatedRequestParam)? => ListPromptsResult); - method!(peer_req list_resources ListResourcesRequest(PaginatedRequestParam)? => ListResourcesResult); - method!(peer_req list_resource_templates ListResourceTemplatesRequest(PaginatedRequestParam)? => ListResourceTemplatesResult); - method!(peer_req read_resource ReadResourceRequest(ReadResourceRequestParam) => ReadResourceResult); - method!(peer_req subscribe SubscribeRequest(SubscribeRequestParam) ); - method!(peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParam)); - method!(peer_req call_tool CallToolRequest(CallToolRequestParam) => CallToolResult); - method!(peer_req list_tools ListToolsRequest(PaginatedRequestParam)? => ListToolsResult); + method!(peer_req complete CompleteRequest(CompleteRequestParams) => CompleteResult); + method!(peer_req set_level SetLevelRequest(SetLevelRequestParams)); + method!(peer_req get_prompt GetPromptRequest(GetPromptRequestParams) => GetPromptResult); + method!(peer_req list_prompts ListPromptsRequest(PaginatedRequestParams)? => ListPromptsResult); + method!(peer_req list_resources ListResourcesRequest(PaginatedRequestParams)? => ListResourcesResult); + method!(peer_req list_resource_templates ListResourceTemplatesRequest(PaginatedRequestParams)? => ListResourceTemplatesResult); + method!(peer_req read_resource ReadResourceRequest(ReadResourceRequestParams) => ReadResourceResult); + method!(peer_req subscribe SubscribeRequest(SubscribeRequestParams) ); + method!(peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams)); + method!(peer_req call_tool CallToolRequest(CallToolRequestParams) => CallToolResult); + method!(peer_req list_tools ListToolsRequest(PaginatedRequestParams)? => ListToolsResult); method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); @@ -377,7 +377,7 @@ impl Peer { let mut cursor = None; loop { let result = self - .list_tools(Some(PaginatedRequestParam { cursor })) + .list_tools(Some(PaginatedRequestParams { meta: None, cursor })) .await?; tools.extend(result.tools); cursor = result.next_cursor; @@ -396,7 +396,7 @@ impl Peer { let mut cursor = None; loop { let result = self - .list_prompts(Some(PaginatedRequestParam { cursor })) + .list_prompts(Some(PaginatedRequestParams { meta: None, cursor })) .await?; prompts.extend(result.prompts); cursor = result.next_cursor; @@ -415,7 +415,7 @@ impl Peer { let mut cursor = None; loop { let result = self - .list_resources(Some(PaginatedRequestParam { cursor })) + .list_resources(Some(PaginatedRequestParams { meta: None, cursor })) .await?; resources.extend(result.resources); cursor = result.next_cursor; @@ -436,7 +436,7 @@ impl Peer { let mut cursor = None; loop { let result = self - .list_resource_templates(Some(PaginatedRequestParam { cursor })) + .list_resource_templates(Some(PaginatedRequestParams { meta: None, cursor })) .await?; resource_templates.extend(result.resource_templates); cursor = result.next_cursor; @@ -464,7 +464,8 @@ impl Peer { current_value: impl Into, context: Option, ) -> Result { - let request = CompleteRequestParam { + let request = CompleteRequestParams { + meta: None, r#ref: Reference::for_prompt(prompt_name), argument: ArgumentInfo { name: argument_name.into(), @@ -494,7 +495,8 @@ impl Peer { current_value: impl Into, context: Option, ) -> Result { - let request = CompleteRequestParam { + let request = CompleteRequestParams { + meta: None, r#ref: Reference::for_resource(uri_template), argument: ArgumentInfo { name: argument_name.into(), diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 82a7e7d82..1ba578b7b 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -5,13 +5,13 @@ use thiserror::Error; use super::*; #[cfg(feature = "elicitation")] use crate::model::{ - CreateElicitationRequest, CreateElicitationRequestParam, CreateElicitationResult, + CreateElicitationRequest, CreateElicitationRequestParams, CreateElicitationResult, }; use crate::{ model::{ CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CreateMessageRequest, - CreateMessageRequestParam, CreateMessageResult, ErrorData, ListRootsRequest, + CreateMessageRequestParams, CreateMessageResult, ErrorData, ListRootsRequest, ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam, ProgressNotification, ProgressNotificationParam, PromptListChangedNotification, ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification, @@ -386,7 +386,7 @@ macro_rules! method { impl Peer { pub async fn create_message( &self, - params: CreateMessageRequestParam, + params: CreateMessageRequestParams, ) -> Result { let result = self .send_request(ServerRequest::CreateMessageRequest(CreateMessageRequest { @@ -402,9 +402,9 @@ impl Peer { } method!(peer_req list_roots ListRootsRequest() => ListRootsResult); #[cfg(feature = "elicitation")] - method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParam) => CreateElicitationResult); + method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult); #[cfg(feature = "elicitation")] - method!(peer_req_with_timeout create_elicitation_with_timeout CreateElicitationRequest(CreateElicitationRequestParam) => CreateElicitationResult); + method!(peer_req_with_timeout create_elicitation_with_timeout CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult); method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); @@ -690,7 +690,8 @@ impl Peer { let response = self .create_elicitation_with_timeout( - CreateElicitationRequestParam { + CreateElicitationRequestParams { + meta: None, message: message.into(), requested_schema: schema, }, diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index db82d4715..373d278c0 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -49,7 +49,7 @@ impl TestClientHandler { impl ClientHandler for TestClientHandler { async fn create_message( &self, - params: CreateMessageRequestParam, + params: CreateMessageRequestParams, _context: RequestContext, ) -> Result { // First validate that there's at least one User message @@ -117,7 +117,7 @@ impl ServerHandler for TestServer { fn set_level( &self, - request: SetLevelRequestParam, + request: SetLevelRequestParams, context: RequestContext, ) -> impl Future> + Send + '_ { let peer = context.peer; diff --git a/crates/rmcp/tests/test_completion.rs b/crates/rmcp/tests/test_completion.rs index ea9f632fe..bd563cadf 100644 --- a/crates/rmcp/tests/test_completion.rs +++ b/crates/rmcp/tests/test_completion.rs @@ -52,7 +52,8 @@ fn test_complete_request_param_serialization() { let mut args = HashMap::new(); args.insert("previous_input".to_string(), "test".to_string()); - let request = CompleteRequestParam { + let request = CompleteRequestParams { + meta: None, r#ref: Reference::for_prompt("weather_prompt"), argument: ArgumentInfo { name: "location".to_string(), @@ -195,7 +196,8 @@ fn test_completion_context_empty() { #[test] fn test_mcp_schema_compliance() { // Test that our types serialize correctly according to MCP specification - let request = CompleteRequestParam { + let request = CompleteRequestParams { + meta: None, r#ref: Reference::for_resource("file://{path}"), argument: ArgumentInfo { name: "path".to_string(), diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 87c6706c3..65f21e235 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -36,7 +36,7 @@ async fn test_elicitation_serialization() { ); } -/// Test CreateElicitationRequestParam structure serialization/deserialization +/// Test CreateElicitationRequestParams structure serialization/deserialization #[tokio::test] async fn test_elicitation_request_param_serialization() { let schema = ElicitationSchema::builder() @@ -44,7 +44,8 @@ async fn test_elicitation_request_param_serialization() { .build() .unwrap(); - let request_param = CreateElicitationRequestParam { + let request_param = CreateElicitationRequestParams { + meta: None, message: "Please provide your email address".to_string(), requested_schema: schema, }; @@ -68,7 +69,7 @@ async fn test_elicitation_request_param_serialization() { assert_eq!(json, expected); // Test deserialization - let deserialized: CreateElicitationRequestParam = serde_json::from_value(expected).unwrap(); + let deserialized: CreateElicitationRequestParams = serde_json::from_value(expected).unwrap(); assert_eq!(deserialized.message, request_param.message); assert_eq!( deserialized.requested_schema, @@ -128,7 +129,8 @@ async fn test_elicitation_json_rpc_protocol() { id: RequestId::Number(1), request: CreateElicitationRequest { method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParam { + params: CreateElicitationRequestParams { + meta: None, message: "Do you want to continue?".to_string(), requested_schema: schema, }, @@ -212,7 +214,8 @@ async fn test_elicitation_spec_compliance() { #[tokio::test] async fn test_elicitation_error_handling() { // Test minimal schema handling (empty properties is technically valid) - let minimal_schema_request = CreateElicitationRequestParam { + let minimal_schema_request = CreateElicitationRequestParams { + meta: None, message: "Test message".to_string(), requested_schema: ElicitationSchema::builder().build().unwrap(), }; @@ -221,7 +224,8 @@ async fn test_elicitation_error_handling() { let _json = serde_json::to_value(&minimal_schema_request).unwrap(); // Test empty message - let empty_message_request = CreateElicitationRequestParam { + let empty_message_request = CreateElicitationRequestParams { + meta: None, message: "".to_string(), requested_schema: ElicitationSchema::builder() .property("value", PrimitiveSchema::String(StringSchema::new())) @@ -246,7 +250,8 @@ async fn test_elicitation_performance() { .build() .unwrap(); - let request = CreateElicitationRequestParam { + let request = CreateElicitationRequestParams { + meta: None, message: "Performance test message".to_string(), requested_schema: schema, }; @@ -256,7 +261,7 @@ async fn test_elicitation_performance() { // Serialize/deserialize 1000 times for _ in 0..1000 { let json = serde_json::to_value(&request).unwrap(); - let _deserialized: CreateElicitationRequestParam = serde_json::from_value(json).unwrap(); + let _deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); } let duration = start.elapsed(); @@ -369,8 +374,9 @@ async fn test_elicitation_convenience_methods() { .contains("Option A") ); - // Test that CreateElicitationRequestParam can be created with type-safe schemas - let confirmation_request = CreateElicitationRequestParam { + // Test that CreateElicitationRequestParams can be created with type-safe schemas + let confirmation_request = CreateElicitationRequestParams { + meta: None, message: "Test confirmation".to_string(), requested_schema: ElicitationSchema::builder() .property( @@ -412,14 +418,15 @@ async fn test_elicitation_structured_schemas() { .build() .unwrap(); - let request = CreateElicitationRequestParam { + let request = CreateElicitationRequestParams { + meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, }; // Test that complex schemas serialize/deserialize correctly let json = serde_json::to_value(&request).unwrap(); - let deserialized: CreateElicitationRequestParam = serde_json::from_value(json).unwrap(); + let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.message, "Please provide your user information"); assert_eq!(deserialized.requested_schema.properties.len(), 5); @@ -654,14 +661,15 @@ async fn test_elicitation_multi_select_enum() { .build() .unwrap(); - let request = CreateElicitationRequestParam { + let request = CreateElicitationRequestParams { + meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, }; // Test that complex schemas serialize/deserialize correctly let json = serde_json::to_value(&request).unwrap(); - let deserialized: CreateElicitationRequestParam = serde_json::from_value(json).unwrap(); + let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.message, "Please provide your user information"); assert_eq!(deserialized.requested_schema.properties.len(), 1); @@ -735,14 +743,15 @@ async fn test_elicitation_single_select_enum() { .build() .unwrap(); - let request = CreateElicitationRequestParam { + let request = CreateElicitationRequestParams { + meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, }; // Test that complex schemas serialize/deserialize correctly let json = serde_json::to_value(&request).unwrap(); - let deserialized: CreateElicitationRequestParam = serde_json::from_value(json).unwrap(); + let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.message, "Please provide your user information"); assert_eq!(deserialized.requested_schema.properties.len(), 1); assert!( @@ -816,7 +825,8 @@ async fn test_elicitation_direction_server_to_client() { .build() .unwrap(); - let elicitation_request = CreateElicitationRequestParam { + let elicitation_request = CreateElicitationRequestParams { + meta: None, message: "Please enter your name".to_string(), requested_schema: schema, }; @@ -868,7 +878,8 @@ async fn test_elicitation_json_rpc_direction() { let server_request = ServerJsonRpcMessage::request( ServerRequest::CreateElicitationRequest(CreateElicitationRequest { method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParam { + params: CreateElicitationRequestParams { + meta: None, message: "Do you want to continue?".to_string(), requested_schema: schema, }, @@ -1039,8 +1050,9 @@ async fn test_client_capabilities_with_elicitation() { /// Test InitializeRequestParam with elicitation capability #[tokio::test] async fn test_initialize_request_with_elicitation() { - // Test InitializeRequestParam with elicitation capability - let init_param = InitializeRequestParam { + // Test InitializeRequestParams with elicitation capability + let init_param = InitializeRequestParams { + meta: None, protocol_version: ProtocolVersion::LATEST, capabilities: ClientCapabilities { elicitation: Some(ElicitationCapability { @@ -1084,7 +1096,8 @@ async fn test_capability_checking_logic() { // Simulate the logic that would be used in supports_elicitation() // Case 1: Client with elicitation capability - let client_with_capability = InitializeRequestParam { + let client_with_capability = InitializeRequestParams { + meta: None, protocol_version: ProtocolVersion::LATEST, capabilities: ClientCapabilities { elicitation: Some(ElicitationCapability { @@ -1106,7 +1119,8 @@ async fn test_capability_checking_logic() { assert!(supports_elicitation); // Case 2: Client without elicitation capability - let client_without_capability = InitializeRequestParam { + let client_without_capability = InitializeRequestParams { + meta: None, protocol_version: ProtocolVersion::LATEST, capabilities: ClientCapabilities { elicitation: None, @@ -1308,7 +1322,8 @@ async fn test_create_elicitation_with_timeout_basic() { .build() .unwrap(); - let _params = CreateElicitationRequestParam { + let _params = CreateElicitationRequestParams { + meta: None, message: "Enter your details".to_string(), requested_schema: schema, }; diff --git a/crates/rmcp/tests/test_logging.rs b/crates/rmcp/tests/test_logging.rs index eb63773fc..be63b24fb 100644 --- a/crates/rmcp/tests/test_logging.rs +++ b/crates/rmcp/tests/test_logging.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use common::handlers::{TestClientHandler, TestServer}; use rmcp::{ ServiceExt, - model::{LoggingLevel, LoggingMessageNotificationParam, SetLevelRequestParam}, + model::{LoggingLevel, LoggingMessageNotificationParam, SetLevelRequestParams}, }; use serde_json::json; use tokio::sync::Notify; @@ -63,7 +63,7 @@ async fn test_logging_spec_compliance() -> anyhow::Result<()> { ] { client .peer() - .set_level(SetLevelRequestParam { level }) + .set_level(SetLevelRequestParams { meta: None, level }) .await?; // Wait for each message response @@ -121,7 +121,8 @@ async fn test_logging_user_scenarios() -> anyhow::Result<()> { // Test 1: Error reporting scenario client .peer() - .set_level(SetLevelRequestParam { + .set_level(SetLevelRequestParams { + meta: None, level: LoggingLevel::Error, }) .await?; @@ -147,7 +148,8 @@ async fn test_logging_user_scenarios() -> anyhow::Result<()> { // Test 2: Debug scenario client .peer() - .set_level(SetLevelRequestParam { + .set_level(SetLevelRequestParams { + meta: None, level: LoggingLevel::Debug, }) .await?; @@ -170,7 +172,8 @@ async fn test_logging_user_scenarios() -> anyhow::Result<()> { // Test 3: Production monitoring scenario client .peer() - .set_level(SetLevelRequestParam { + .set_level(SetLevelRequestParams { + meta: None, level: LoggingLevel::Info, }) .await?; @@ -256,7 +259,7 @@ async fn test_logging_edge_cases() -> anyhow::Result<()> { ] { client .peer() - .set_level(SetLevelRequestParam { level }) + .set_level(SetLevelRequestParams { meta: None, level }) .await?; receive_signal.notified().await; @@ -316,7 +319,7 @@ async fn test_logging_optional_fields() -> anyhow::Result<()> { for level in [LoggingLevel::Info, LoggingLevel::Debug] { client .peer() - .set_level(SetLevelRequestParam { level }) + .set_level(SetLevelRequestParams { meta: None, level }) .await?; // Wait for each message response diff --git a/crates/rmcp/tests/test_message_protocol.rs b/crates/rmcp/tests/test_message_protocol.rs index 602f93dab..b2851b5a1 100644 --- a/crates/rmcp/tests/test_message_protocol.rs +++ b/crates/rmcp/tests/test_message_protocol.rs @@ -47,7 +47,9 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { // Test ThisServer context inclusion let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("test message"), @@ -89,7 +91,9 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { // Test AllServers context inclusion let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("test message"), @@ -131,7 +135,9 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { // Test No context inclusion let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("test message"), @@ -193,7 +199,9 @@ async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> { // Test that context requests are ignored let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("test message"), @@ -254,7 +262,9 @@ async fn test_message_sequence_integration() -> anyhow::Result<()> { let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![ SamplingMessage { role: Role::User, @@ -325,7 +335,9 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { // Test valid sequence: User -> Assistant -> User let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![ SamplingMessage { role: Role::User, @@ -369,7 +381,9 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { // Test invalid: No user message let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::Assistant, content: Content::text("assistant message"), @@ -422,7 +436,9 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { // Test ThisServer is honored let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("test message"), @@ -462,7 +478,9 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { // Test AllServers is ignored let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("test message"), @@ -519,7 +537,9 @@ async fn test_context_inclusion() -> anyhow::Result<()> { // Test context handling let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("test"), diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 5ae242a38..85b2a5fb2 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -187,10 +187,18 @@ "format": "const", "const": "tools/call" }, - "CallToolRequestParam": { - "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", + "CallToolRequestParams": { + "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.\n\nThis implements `TaskAugmentedRequestParamsMeta` as tool calls can be\nlong-running and may benefit from task-based execution.", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "arguments": { "description": "Arguments to pass to the tool (must match the tool's input schema)", "type": [ @@ -204,6 +212,7 @@ "type": "string" }, "task": { + "description": "Task metadata for async task management (SEP-1319)", "type": [ "object", "null" @@ -220,9 +229,17 @@ "format": "const", "const": "tasks/cancel" }, - "CancelTaskParam": { + "CancelTaskParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "taskId": { "type": "string" } @@ -332,9 +349,17 @@ "format": "const", "const": "completion/complete" }, - "CompleteRequestParam": { + "CompleteRequestParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "argument": { "$ref": "#/definitions/ArgumentInfo" }, @@ -534,10 +559,18 @@ "format": "const", "const": "prompts/get" }, - "GetPromptRequestParam": { + "GetPromptRequestParams": { "description": "Parameters for retrieving a specific prompt", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "arguments": { "type": [ "object", @@ -558,9 +591,17 @@ "format": "const", "const": "tasks/get" }, - "GetTaskInfoParam": { + "GetTaskInfoParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "taskId": { "type": "string" } @@ -574,9 +615,17 @@ "format": "const", "const": "tasks/result" }, - "GetTaskResultParam": { + "GetTaskResultParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "taskId": { "type": "string" } @@ -651,10 +700,18 @@ "version" ] }, - "InitializeRequestParam": { + "InitializeRequestParams": { "description": "Parameters sent by a client when initializing a connection to an MCP server.\n\nThis contains the client's protocol version, capabilities, and implementation\ninformation, allowing the server to understand what the client supports.", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "capabilities": { "description": "The capabilities this client supports (sampling, roots, etc.)", "allOf": [ @@ -953,9 +1010,17 @@ } ] }, - "PaginatedRequestParam": { + "PaginatedRequestParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "cursor": { "type": [ "string", @@ -1183,10 +1248,18 @@ "format": "const", "const": "resources/read" }, - "ReadResourceRequestParam": { + "ReadResourceRequestParams": { "description": "Parameters for reading a specific resource", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "uri": { "description": "The URI of the resource to read", "type": "string" @@ -1242,7 +1315,7 @@ "$ref": "#/definitions/InitializeResultMethod" }, "params": { - "$ref": "#/definitions/InitializeRequestParam" + "$ref": "#/definitions/InitializeRequestParams" } }, "required": [ @@ -1258,7 +1331,7 @@ "$ref": "#/definitions/GetTaskResultMethod" }, "params": { - "$ref": "#/definitions/GetTaskResultParam" + "$ref": "#/definitions/GetTaskResultParams" } }, "required": [ @@ -1274,7 +1347,7 @@ "$ref": "#/definitions/CancelTaskMethod" }, "params": { - "$ref": "#/definitions/CancelTaskParam" + "$ref": "#/definitions/CancelTaskParams" } }, "required": [ @@ -1290,7 +1363,7 @@ "$ref": "#/definitions/CompleteRequestMethod" }, "params": { - "$ref": "#/definitions/CompleteRequestParam" + "$ref": "#/definitions/CompleteRequestParams" } }, "required": [ @@ -1306,7 +1379,7 @@ "$ref": "#/definitions/SetLevelRequestMethod" }, "params": { - "$ref": "#/definitions/SetLevelRequestParam" + "$ref": "#/definitions/SetLevelRequestParams" } }, "required": [ @@ -1322,7 +1395,7 @@ "$ref": "#/definitions/GetPromptRequestMethod" }, "params": { - "$ref": "#/definitions/GetPromptRequestParam" + "$ref": "#/definitions/GetPromptRequestParams" } }, "required": [ @@ -1338,7 +1411,7 @@ "$ref": "#/definitions/ReadResourceRequestMethod" }, "params": { - "$ref": "#/definitions/ReadResourceRequestParam" + "$ref": "#/definitions/ReadResourceRequestParams" } }, "required": [ @@ -1354,7 +1427,7 @@ "$ref": "#/definitions/SubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParam" + "$ref": "#/definitions/SubscribeRequestParams" } }, "required": [ @@ -1370,7 +1443,7 @@ "$ref": "#/definitions/UnsubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParam" + "$ref": "#/definitions/UnsubscribeRequestParams" } }, "required": [ @@ -1386,7 +1459,7 @@ "$ref": "#/definitions/CallToolRequestMethod" }, "params": { - "$ref": "#/definitions/CallToolRequestParam" + "$ref": "#/definitions/CallToolRequestParams" } }, "required": [ @@ -1402,7 +1475,7 @@ "$ref": "#/definitions/GetTaskInfoMethod" }, "params": { - "$ref": "#/definitions/GetTaskInfoParam" + "$ref": "#/definitions/GetTaskInfoParams" } }, "required": [ @@ -1430,7 +1503,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1451,7 +1524,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1472,7 +1545,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1493,7 +1566,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1514,7 +1587,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1650,10 +1723,18 @@ "format": "const", "const": "logging/setLevel" }, - "SetLevelRequestParam": { + "SetLevelRequestParams": { "description": "Parameters for setting the logging level", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "level": { "description": "The desired logging level", "allOf": [ @@ -1672,10 +1753,18 @@ "format": "const", "const": "resources/subscribe" }, - "SubscribeRequestParam": { + "SubscribeRequestParams": { "description": "Parameters for subscribing to resource updates", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "uri": { "description": "The URI of the resource to subscribe to", "type": "string" @@ -1720,10 +1809,18 @@ "format": "const", "const": "resources/unsubscribe" }, - "UnsubscribeRequestParam": { + "UnsubscribeRequestParams": { "description": "Parameters for unsubscribing from resource updates", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "uri": { "description": "The URI of the resource to unsubscribe from", "type": "string" diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 5ae242a38..85b2a5fb2 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -187,10 +187,18 @@ "format": "const", "const": "tools/call" }, - "CallToolRequestParam": { - "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", + "CallToolRequestParams": { + "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.\n\nThis implements `TaskAugmentedRequestParamsMeta` as tool calls can be\nlong-running and may benefit from task-based execution.", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "arguments": { "description": "Arguments to pass to the tool (must match the tool's input schema)", "type": [ @@ -204,6 +212,7 @@ "type": "string" }, "task": { + "description": "Task metadata for async task management (SEP-1319)", "type": [ "object", "null" @@ -220,9 +229,17 @@ "format": "const", "const": "tasks/cancel" }, - "CancelTaskParam": { + "CancelTaskParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "taskId": { "type": "string" } @@ -332,9 +349,17 @@ "format": "const", "const": "completion/complete" }, - "CompleteRequestParam": { + "CompleteRequestParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "argument": { "$ref": "#/definitions/ArgumentInfo" }, @@ -534,10 +559,18 @@ "format": "const", "const": "prompts/get" }, - "GetPromptRequestParam": { + "GetPromptRequestParams": { "description": "Parameters for retrieving a specific prompt", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "arguments": { "type": [ "object", @@ -558,9 +591,17 @@ "format": "const", "const": "tasks/get" }, - "GetTaskInfoParam": { + "GetTaskInfoParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "taskId": { "type": "string" } @@ -574,9 +615,17 @@ "format": "const", "const": "tasks/result" }, - "GetTaskResultParam": { + "GetTaskResultParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "taskId": { "type": "string" } @@ -651,10 +700,18 @@ "version" ] }, - "InitializeRequestParam": { + "InitializeRequestParams": { "description": "Parameters sent by a client when initializing a connection to an MCP server.\n\nThis contains the client's protocol version, capabilities, and implementation\ninformation, allowing the server to understand what the client supports.", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "capabilities": { "description": "The capabilities this client supports (sampling, roots, etc.)", "allOf": [ @@ -953,9 +1010,17 @@ } ] }, - "PaginatedRequestParam": { + "PaginatedRequestParams": { "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "cursor": { "type": [ "string", @@ -1183,10 +1248,18 @@ "format": "const", "const": "resources/read" }, - "ReadResourceRequestParam": { + "ReadResourceRequestParams": { "description": "Parameters for reading a specific resource", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "uri": { "description": "The URI of the resource to read", "type": "string" @@ -1242,7 +1315,7 @@ "$ref": "#/definitions/InitializeResultMethod" }, "params": { - "$ref": "#/definitions/InitializeRequestParam" + "$ref": "#/definitions/InitializeRequestParams" } }, "required": [ @@ -1258,7 +1331,7 @@ "$ref": "#/definitions/GetTaskResultMethod" }, "params": { - "$ref": "#/definitions/GetTaskResultParam" + "$ref": "#/definitions/GetTaskResultParams" } }, "required": [ @@ -1274,7 +1347,7 @@ "$ref": "#/definitions/CancelTaskMethod" }, "params": { - "$ref": "#/definitions/CancelTaskParam" + "$ref": "#/definitions/CancelTaskParams" } }, "required": [ @@ -1290,7 +1363,7 @@ "$ref": "#/definitions/CompleteRequestMethod" }, "params": { - "$ref": "#/definitions/CompleteRequestParam" + "$ref": "#/definitions/CompleteRequestParams" } }, "required": [ @@ -1306,7 +1379,7 @@ "$ref": "#/definitions/SetLevelRequestMethod" }, "params": { - "$ref": "#/definitions/SetLevelRequestParam" + "$ref": "#/definitions/SetLevelRequestParams" } }, "required": [ @@ -1322,7 +1395,7 @@ "$ref": "#/definitions/GetPromptRequestMethod" }, "params": { - "$ref": "#/definitions/GetPromptRequestParam" + "$ref": "#/definitions/GetPromptRequestParams" } }, "required": [ @@ -1338,7 +1411,7 @@ "$ref": "#/definitions/ReadResourceRequestMethod" }, "params": { - "$ref": "#/definitions/ReadResourceRequestParam" + "$ref": "#/definitions/ReadResourceRequestParams" } }, "required": [ @@ -1354,7 +1427,7 @@ "$ref": "#/definitions/SubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParam" + "$ref": "#/definitions/SubscribeRequestParams" } }, "required": [ @@ -1370,7 +1443,7 @@ "$ref": "#/definitions/UnsubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParam" + "$ref": "#/definitions/UnsubscribeRequestParams" } }, "required": [ @@ -1386,7 +1459,7 @@ "$ref": "#/definitions/CallToolRequestMethod" }, "params": { - "$ref": "#/definitions/CallToolRequestParam" + "$ref": "#/definitions/CallToolRequestParams" } }, "required": [ @@ -1402,7 +1475,7 @@ "$ref": "#/definitions/GetTaskInfoMethod" }, "params": { - "$ref": "#/definitions/GetTaskInfoParam" + "$ref": "#/definitions/GetTaskInfoParams" } }, "required": [ @@ -1430,7 +1503,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1451,7 +1524,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1472,7 +1545,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1493,7 +1566,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1514,7 +1587,7 @@ "params": { "anyOf": [ { - "$ref": "#/definitions/PaginatedRequestParam" + "$ref": "#/definitions/PaginatedRequestParams" }, { "type": "null" @@ -1650,10 +1723,18 @@ "format": "const", "const": "logging/setLevel" }, - "SetLevelRequestParam": { + "SetLevelRequestParams": { "description": "Parameters for setting the logging level", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "level": { "description": "The desired logging level", "allOf": [ @@ -1672,10 +1753,18 @@ "format": "const", "const": "resources/subscribe" }, - "SubscribeRequestParam": { + "SubscribeRequestParams": { "description": "Parameters for subscribing to resource updates", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "uri": { "description": "The URI of the resource to subscribe to", "type": "string" @@ -1720,10 +1809,18 @@ "format": "const", "const": "resources/unsubscribe" }, - "UnsubscribeRequestParam": { + "UnsubscribeRequestParams": { "description": "Parameters for unsubscribing from resource updates", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "uri": { "description": "The URI of the resource to unsubscribe from", "type": "string" diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 1816e1cb5..e23eae12d 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -504,10 +504,18 @@ } ] }, - "CreateElicitationRequestParam": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParam {\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", + "CreateElicitationRequestParams": { + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "message": { "description": "Human-readable message explaining what input is needed from the user.\nThis should be clear and provide sufficient context for the user to understand\nwhat information they need to provide.", "type": "string" @@ -551,10 +559,18 @@ "format": "const", "const": "sampling/createMessage" }, - "CreateMessageRequestParam": { - "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.", + "CreateMessageRequestParams": { + "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.\n\nThis implements `TaskAugmentedRequestParamsMeta` as sampling requests can be\nlong-running and may benefit from task-based execution.", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "includeContext": { "description": "How much context to include from MCP servers", "anyOf": [ @@ -610,6 +626,14 @@ "null" ] }, + "task": { + "description": "Task metadata for async task management (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "temperature": { "description": "Temperature for controlling randomness (0.0 to 1.0)", "type": [ @@ -2111,7 +2135,7 @@ "$ref": "#/definitions/CreateMessageRequestMethod" }, "params": { - "$ref": "#/definitions/CreateMessageRequestParam" + "$ref": "#/definitions/CreateMessageRequestParams" } }, "required": [ @@ -2127,7 +2151,7 @@ "$ref": "#/definitions/ElicitationCreateRequestMethod" }, "params": { - "$ref": "#/definitions/CreateElicitationRequestParam" + "$ref": "#/definitions/CreateElicitationRequestParams" } }, "required": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 1816e1cb5..e23eae12d 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -504,10 +504,18 @@ } ] }, - "CreateElicitationRequestParam": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParam {\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", + "CreateElicitationRequestParams": { + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "message": { "description": "Human-readable message explaining what input is needed from the user.\nThis should be clear and provide sufficient context for the user to understand\nwhat information they need to provide.", "type": "string" @@ -551,10 +559,18 @@ "format": "const", "const": "sampling/createMessage" }, - "CreateMessageRequestParam": { - "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.", + "CreateMessageRequestParams": { + "description": "Parameters for creating a message through LLM sampling.\n\nThis structure contains all the necessary information for a client to\ngenerate an LLM response, including conversation history, model preferences,\nand generation parameters.\n\nThis implements `TaskAugmentedRequestParamsMeta` as sampling requests can be\nlong-running and may benefit from task-based execution.", "type": "object", "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "includeContext": { "description": "How much context to include from MCP servers", "anyOf": [ @@ -610,6 +626,14 @@ "null" ] }, + "task": { + "description": "Task metadata for async task management (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "temperature": { "description": "Temperature for controlling randomness (0.0 to 1.0)", "type": [ @@ -2111,7 +2135,7 @@ "$ref": "#/definitions/CreateMessageRequestMethod" }, "params": { - "$ref": "#/definitions/CreateMessageRequestParam" + "$ref": "#/definitions/CreateMessageRequestParams" } }, "required": [ @@ -2127,7 +2151,7 @@ "$ref": "#/definitions/ElicitationCreateRequestMethod" }, "params": { - "$ref": "#/definitions/CreateElicitationRequestParam" + "$ref": "#/definitions/CreateElicitationRequestParams" } }, "required": [ diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index cce04364d..018374212 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -4,7 +4,7 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ ClientNotification, CustomNotification, ResourceUpdatedNotificationParam, - ServerCapabilities, ServerInfo, ServerNotification, SubscribeRequestParam, + ServerCapabilities, ServerInfo, ServerNotification, SubscribeRequestParams, }, }; use serde_json::json; @@ -27,7 +27,7 @@ impl ServerHandler for Server { async fn subscribe( &self, - request: rmcp::model::SubscribeRequestParam, + request: rmcp::model::SubscribeRequestParams, context: rmcp::service::RequestContext, ) -> Result<(), rmcp::ErrorData> { let uri = request.uri; @@ -87,7 +87,8 @@ async fn test_server_notification() -> anyhow::Result<()> { .serve(client_transport) .await?; client - .subscribe(SubscribeRequestParam { + .subscribe(SubscribeRequestParams { + meta: None, uri: "test://test-resource".to_owned(), }) .await?; diff --git a/crates/rmcp/tests/test_progress_subscriber.rs b/crates/rmcp/tests/test_progress_subscriber.rs index b5d185ab9..521219a3b 100644 --- a/crates/rmcp/tests/test_progress_subscriber.rs +++ b/crates/rmcp/tests/test_progress_subscriber.rs @@ -2,7 +2,7 @@ use futures::StreamExt; use rmcp::{ ClientHandler, Peer, RoleServer, ServerHandler, ServiceExt, handler::{client::progress::ProgressDispatcher, server::tool::ToolRouter}, - model::{CallToolRequestParam, ClientRequest, Meta, ProgressNotificationParam, Request}, + model::{CallToolRequestParams, ClientRequest, Meta, ProgressNotificationParam, Request}, service::PeerRequestOptions, tool, tool_handler, tool_router, }; @@ -107,7 +107,8 @@ async fn test_progress_subscriber() -> anyhow::Result<()> { let client_service = client.serve(transport_client).await?; let handle = client_service .send_cancellable_request( - ClientRequest::CallToolRequest(Request::new(CallToolRequestParam { + ClientRequest::CallToolRequest(Request::new(CallToolRequestParams { + meta: None, name: "some_progress".into(), arguments: None, task: None, diff --git a/crates/rmcp/tests/test_prompt_handler.rs b/crates/rmcp/tests/test_prompt_handler.rs index 86f204347..ca4418bcc 100644 --- a/crates/rmcp/tests/test_prompt_handler.rs +++ b/crates/rmcp/tests/test_prompt_handler.rs @@ -6,7 +6,7 @@ use rmcp::{ RoleServer, ServerHandler, handler::server::router::prompt::PromptRouter, - model::{GetPromptRequestParam, GetPromptResult, ListPromptsResult, PaginatedRequestParam}, + model::{GetPromptRequestParams, GetPromptResult, ListPromptsResult, PaginatedRequestParams}, prompt_handler, service::RequestContext, }; diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs index 5d5ece8cb..2407571d7 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -6,8 +6,8 @@ use rmcp::{ ClientHandler, RoleServer, ServerHandler, ServiceExt, handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, model::{ - ClientInfo, GetPromptRequestParam, GetPromptResult, ListPromptsResult, - PaginatedRequestParam, PromptMessage, PromptMessageRole, + ClientInfo, GetPromptRequestParams, GetPromptResult, ListPromptsResult, + PaginatedRequestParams, PromptMessage, PromptMessageRole, }, prompt, prompt_handler, prompt_router, service::RequestContext, @@ -327,7 +327,8 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { // Test null case let result = client - .get_prompt(GetPromptRequestParam { + .get_prompt(GetPromptRequestParams { + meta: None, name: "test_optional_i64".into(), arguments: Some( serde_json::json!({ @@ -353,7 +354,8 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { // Test Some case let some_result = client - .get_prompt(GetPromptRequestParam { + .get_prompt(GetPromptRequestParams { + meta: None, name: "test_optional_i64".into(), arguments: Some( serde_json::json!({ diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index b760796f7..83a4325c2 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -31,7 +31,9 @@ async fn test_basic_sampling_message_creation() -> Result<()> { #[tokio::test] async fn test_sampling_request_params() -> Result<()> { // Test sampling request parameters structure - let params = CreateMessageRequestParam { + let params = CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("Hello, world!"), @@ -54,7 +56,7 @@ async fn test_sampling_request_params() -> Result<()> { // Verify serialization/deserialization let json = serde_json::to_string(¶ms)?; - let deserialized: CreateMessageRequestParam = serde_json::from_str(&json)?; + let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; assert_eq!(params, deserialized); // Verify specific fields @@ -134,7 +136,9 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> { // Test sampling with context inclusion let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("What is the capital of France?"), @@ -214,7 +218,9 @@ async fn test_sampling_no_context_inclusion() -> Result<()> { // Test sampling without context inclusion let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text("Hello"), @@ -283,7 +289,9 @@ async fn test_sampling_error_invalid_message_sequence() -> Result<()> { // Test sampling with no user messages (should fail) let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), - params: CreateMessageRequestParam { + params: CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::Assistant, content: Content::text("I'm an assistant message without a user message"), diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index 763c4f430..a7609eecb 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use rmcp::{ ClientHandler, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParam, ClientInfo}, + model::{CallToolRequestParams, ClientInfo}, tool, tool_handler, tool_router, }; use schemars::JsonSchema; @@ -309,7 +309,8 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { // Test null case let result = client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "test_optional_i64".into(), arguments: Some( serde_json::json!({ @@ -338,7 +339,8 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { // Test Some case let some_result = client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "test_optional_i64".into(), arguments: Some( serde_json::json!({ diff --git a/examples/clients/src/collection.rs b/examples/clients/src/collection.rs index c714da544..a4c734824 100644 --- a/examples/clients/src/collection.rs +++ b/examples/clients/src/collection.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use anyhow::Result; use rmcp::{ - model::CallToolRequestParam, + model::CallToolRequestParams, service::ServiceExt, transport::{ConfigureCommandExt, TokioChildProcess}, }; @@ -46,7 +46,8 @@ async fn main() -> Result<()> { // Call tool 'git_status' with arguments = {"repo_path": "."} let _tool_result = client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "git_status".into(), arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(), task: None, diff --git a/examples/clients/src/everything_stdio.rs b/examples/clients/src/everything_stdio.rs index f1cbcae5b..763a880a6 100644 --- a/examples/clients/src/everything_stdio.rs +++ b/examples/clients/src/everything_stdio.rs @@ -1,7 +1,7 @@ use anyhow::Result; use rmcp::{ ServiceExt, - model::{CallToolRequestParam, GetPromptRequestParam, ReadResourceRequestParam}, + model::{CallToolRequestParams, GetPromptRequestParams, ReadResourceRequestParams}, object, transport::{ConfigureCommandExt, TokioChildProcess}, }; @@ -37,7 +37,8 @@ async fn main() -> Result<()> { // Call tool echo let tool_result = client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "echo".into(), arguments: Some(object!({ "message": "hi from rmcp" })), task: None, @@ -47,7 +48,8 @@ async fn main() -> Result<()> { // Call tool longRunningOperation let tool_result = client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "longRunningOperation".into(), arguments: Some(object!({ "duration": 3, "steps": 1 })), task: None, @@ -61,7 +63,8 @@ async fn main() -> Result<()> { // Read resource let resource = client - .read_resource(ReadResourceRequestParam { + .read_resource(ReadResourceRequestParams { + meta: None, uri: "test://static/resource/3".into(), }) .await?; @@ -73,7 +76,8 @@ async fn main() -> Result<()> { // Get simple prompt let prompt = client - .get_prompt(GetPromptRequestParam { + .get_prompt(GetPromptRequestParams { + meta: None, name: "simple_prompt".into(), arguments: None, }) @@ -82,7 +86,8 @@ async fn main() -> Result<()> { // Get complex prompt (returns text & image) let prompt = client - .get_prompt(GetPromptRequestParam { + .get_prompt(GetPromptRequestParams { + meta: None, name: "complex_prompt".into(), arguments: Some(object!({ "temperature": "0.5", "style": "formal" })), }) diff --git a/examples/clients/src/git_stdio.rs b/examples/clients/src/git_stdio.rs index 7b516f387..9960c16b9 100644 --- a/examples/clients/src/git_stdio.rs +++ b/examples/clients/src/git_stdio.rs @@ -1,6 +1,6 @@ use rmcp::{ RmcpError, - model::CallToolRequestParam, + model::CallToolRequestParams, service::ServiceExt, transport::{ConfigureCommandExt, TokioChildProcess}, }; @@ -39,7 +39,8 @@ async fn main() -> Result<(), RmcpError> { // Call tool 'git_status' with arguments = {"repo_path": "."} let tool_result = client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "git_status".into(), arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(), task: None, diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index 888738ba3..db66a8ed6 100644 --- a/examples/clients/src/progress_client.rs +++ b/examples/clients/src/progress_client.rs @@ -8,7 +8,7 @@ use clap::{Parser, ValueEnum}; use rmcp::{ ClientHandler, ServiceExt, model::{ - CallToolRequestParam, ClientCapabilities, ClientInfo, Implementation, + CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, ProgressNotificationParam, }, service::{NotificationContext, RoleClient}, @@ -123,6 +123,7 @@ impl ClientHandler for ProgressAwareClient { fn get_info(&self) -> ClientInfo { ClientInfo { + meta: None, protocol_version: Default::default(), capabilities: ClientCapabilities::default(), client_info: Implementation { @@ -181,7 +182,8 @@ async fn test_stdio_transport(records: u32) -> Result<()> { // Call stream processor tool tracing::info!("Starting to process {} records...", records); let tool_result = service - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "stream_processor".into(), arguments: None, task: None, @@ -236,7 +238,8 @@ async fn test_http_transport(http_url: &str, records: u32) -> Result<()> { // Call stream processor tool tracing::info!("Starting to process {} records...", records); let tool_result = client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "stream_processor".into(), arguments: None, task: None, diff --git a/examples/clients/src/sampling_stdio.rs b/examples/clients/src/sampling_stdio.rs index 642d315ac..cdefad589 100644 --- a/examples/clients/src/sampling_stdio.rs +++ b/examples/clients/src/sampling_stdio.rs @@ -31,7 +31,7 @@ impl SamplingDemoClient { impl ClientHandler for SamplingDemoClient { async fn create_message( &self, - params: CreateMessageRequestParam, + params: CreateMessageRequestParams, _context: RequestContext, ) -> Result { tracing::info!("Received sampling request with {:?}", params); @@ -101,7 +101,8 @@ async fn main() -> Result<()> { // Test the ask_llm tool tracing::info!("Testing ask_llm tool..."); match client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "ask_llm".into(), arguments: Some(object!({ "question": "Hello world" diff --git a/examples/clients/src/streamable_http.rs b/examples/clients/src/streamable_http.rs index cd4b73c44..7af9b5bcb 100644 --- a/examples/clients/src/streamable_http.rs +++ b/examples/clients/src/streamable_http.rs @@ -1,7 +1,7 @@ use anyhow::Result; use rmcp::{ ServiceExt, - model::{CallToolRequestParam, ClientCapabilities, ClientInfo, Implementation}, + model::{CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation}, transport::StreamableHttpClientTransport, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -18,6 +18,7 @@ async fn main() -> Result<()> { .init(); let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); let client_info = ClientInfo { + meta: None, protocol_version: Default::default(), capabilities: ClientCapabilities::default(), client_info: Implementation { @@ -41,7 +42,8 @@ async fn main() -> Result<()> { tracing::info!("Available tools: {tools:#?}"); let tool_result = client - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: "increment".into(), arguments: serde_json::json!({}).as_object().cloned(), task: None, diff --git a/examples/rig-integration/src/mcp_adaptor.rs b/examples/rig-integration/src/mcp_adaptor.rs index f5397c63b..af57935ee 100644 --- a/examples/rig-integration/src/mcp_adaptor.rs +++ b/examples/rig-integration/src/mcp_adaptor.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use rig::tool::{ToolDyn as RigTool, ToolEmbeddingDyn, ToolSet}; use rmcp::{ RoleClient, - model::{CallToolRequestParam, CallToolResult, Tool as McpTool}, + model::{CallToolRequestParams, CallToolResult, Tool as McpTool}, service::{RunningService, ServerSink}, }; @@ -41,7 +41,8 @@ impl RigTool for McpToolAdaptor { let server = self.server.clone(); Box::pin(async move { let call_mcp_tool_result = server - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: self.tool.name.clone(), arguments: serde_json::from_str(&args) .map_err(rig::tool::ToolError::JsonError)?, diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index ac271cba6..9ca043f88 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -214,7 +214,7 @@ impl ServerHandler for Counter { async fn list_resources( &self, - _request: Option, + _request: Option, _: RequestContext, ) -> Result { Ok(ListResourcesResult { @@ -229,7 +229,7 @@ impl ServerHandler for Counter { async fn read_resource( &self, - ReadResourceRequestParam { uri }: ReadResourceRequestParam, + ReadResourceRequestParams { meta: _, uri }: ReadResourceRequestParams, _: RequestContext, ) -> Result { match uri.as_str() { @@ -256,7 +256,7 @@ impl ServerHandler for Counter { async fn list_resource_templates( &self, - _request: Option, + _request: Option, _: RequestContext, ) -> Result { Ok(ListResourceTemplatesResult { @@ -268,7 +268,7 @@ impl ServerHandler for Counter { async fn initialize( &self, - _request: InitializeRequestParam, + _request: InitializeRequestParams, context: RequestContext, ) -> Result { if let Some(http_request_part) = context.extensions.get::() { @@ -347,7 +347,8 @@ mod tests { "source".into(), serde_json::Value::String("integration-test".into()), ); - let params = CallToolRequestParam { + let params = CallToolRequestParams { + meta: None, name: "long_task".into(), arguments: None, task: Some(task_meta), diff --git a/examples/servers/src/completion_stdio.rs b/examples/servers/src/completion_stdio.rs index 7beb1e1e3..e4365cadc 100644 --- a/examples/servers/src/completion_stdio.rs +++ b/examples/servers/src/completion_stdio.rs @@ -337,7 +337,7 @@ impl ServerHandler for SqlQueryServer { async fn complete( &self, - request: CompleteRequestParam, + request: CompleteRequestParams, _context: RequestContext, ) -> Result { let candidates = match &request.r#ref { diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 1ef6309b8..29198bb85 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -33,7 +33,7 @@ impl ServerHandler for SamplingDemoServer { async fn call_tool( &self, - request: CallToolRequestParam, + request: CallToolRequestParams, context: RequestContext, ) -> Result { match request.name.as_ref() { @@ -48,7 +48,9 @@ impl ServerHandler for SamplingDemoServer { let response = context .peer - .create_message(CreateMessageRequestParam { + .create_message(CreateMessageRequestParams { + meta: None, + task: None, messages: vec![SamplingMessage { role: Role::User, content: Content::text(question), @@ -99,7 +101,7 @@ impl ServerHandler for SamplingDemoServer { async fn list_tools( &self, - _request: Option, + _request: Option, _context: RequestContext, ) -> Result { Ok(ListToolsResult { diff --git a/examples/simple-chat-client/src/tool.rs b/examples/simple-chat-client/src/tool.rs index 174f4274b..14f073a24 100644 --- a/examples/simple-chat-client/src/tool.rs +++ b/examples/simple-chat-client/src/tool.rs @@ -4,7 +4,7 @@ use anyhow::Result; use async_trait::async_trait; use rmcp::{ RoleClient, - model::{CallToolRequestParam, CallToolResult, Tool as McpTool}, + model::{CallToolRequestParams, CallToolResult, Tool as McpTool}, service::{RunningService, ServerSink}, }; use serde_json::Value; @@ -59,7 +59,8 @@ impl Tool for McpToolAdapter { println!("arguments: {:?}", arguments); let call_result = self .server - .call_tool(CallToolRequestParam { + .call_tool(CallToolRequestParams { + meta: None, name: self.tool.name.clone(), arguments, task: None, diff --git a/examples/transport/src/named-pipe.rs b/examples/transport/src/named-pipe.rs index b070d02b3..1231059bc 100644 --- a/examples/transport/src/named-pipe.rs +++ b/examples/transport/src/named-pipe.rs @@ -48,7 +48,8 @@ async fn main() -> anyhow::Result<()> { println!("Calling sum tool: {}", sum_tool.name); let result = client .peer() - .call_tool(rmcp::model::CallToolRequestParam { + .call_tool(rmcp::model::CallToolRequestParams { + meta: None, name: sum_tool.name.clone(), arguments: Some(rmcp::object!({ "a": 10, diff --git a/examples/transport/src/unix_socket.rs b/examples/transport/src/unix_socket.rs index 0d91dfeef..666a61f3a 100644 --- a/examples/transport/src/unix_socket.rs +++ b/examples/transport/src/unix_socket.rs @@ -46,7 +46,8 @@ async fn main() -> anyhow::Result<()> { println!("Calling sum tool: {}", sum_tool.name); let result = client .peer() - .call_tool(rmcp::model::CallToolRequestParam { + .call_tool(rmcp::model::CallToolRequestParams { + meta: None, name: sum_tool.name.clone(), arguments: Some(rmcp::object!({ "a": 10, From 2e08001f322b409caebd24a245dfbbe07b8fd04a Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Sun, 18 Jan 2026 20:42:12 -0500 Subject: [PATCH 014/333] fix: don't treat non-success HTTP codes as transport errors (#618) --- .../rmcp/src/transport/common/reqwest/streamable_http_client.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index cd1942d56..0ecbad20d 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -121,7 +121,6 @@ impl StreamableHttpClient for reqwest::Client { } } let status = response.status(); - let response = response.error_for_status()?; if matches!( status, reqwest::StatusCode::ACCEPTED | reqwest::StatusCode::NO_CONTENT From acc1f8b525c9b9447f27b37b98c4ab5c4093b3c7 Mon Sep 17 00:00:00 2001 From: Joseph Wortmann Date: Wed, 21 Jan 2026 11:14:10 -0600 Subject: [PATCH 015/333] docs: added hyper-mcp to the list of built with rmcp (#621) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bbf652632..7fdb47c91 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. - [video-transcriber-mcp-rs](https://github.com/nhatvu148/video-transcriber-mcp-rs) - High-performance MCP server for transcribing videos from 1000+ platforms using whisper.cpp - [NexusCore MCP](https://github.com/sjkim1127/Nexuscore_MCP) - Advanced malware analysis & dynamic instrumentation MCP server with Frida integration and stealth unpacking capabilities - [spreadsheet-mcp](https://github.com/PSU3D0/spreadsheet-mcp) - Token-efficient MCP server for spreadsheet analysis with automatic region detection, recalculation, screenshot, and editing support for LLM agents +- [hyper-mcp](https://github.com/hyper-mcp-rs/hyper-mcp) - A fast, secure MCP server that extends its capabilities through WebAssembly (WASM) plugins ## Development From e623f2acabd53e51a978d160f955c315bc16c220 Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Wed, 21 Jan 2026 11:56:16 -0600 Subject: [PATCH 016/333] docs: show README content on docs.rs (#583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `#![doc = include_str!("../README.md")]` to display README as crate documentation on docs.rs for both `rmcp` and `rmcp-macros`. Changes to support this: - Fix code examples to compile as doc tests (`rust,no_run`) - Fix broken rustdoc links with explicit `crate::` paths - Add "Structured Output" section and examples link to rmcp README - Simplify rmcp-macros README to a summary table with doc links - Fix grammar throughout - Add CSS to hide GitHub badges when rendered as rustdoc 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- crates/rmcp-macros/README.md | 223 ++++++---------------------------- crates/rmcp-macros/src/lib.rs | 2 + crates/rmcp/README.md | 133 ++++++++++++++------ crates/rmcp/src/lib.rs | 142 +--------------------- 4 files changed, 138 insertions(+), 362 deletions(-) diff --git a/crates/rmcp-macros/README.md b/crates/rmcp-macros/README.md index 62ea2f5e9..ca137b13c 100644 --- a/crates/rmcp-macros/README.md +++ b/crates/rmcp-macros/README.md @@ -1,208 +1,65 @@ -# rmcp-macros - -`rmcp-macros` is a procedural macro library for the Rust Model Context Protocol (RMCP) SDK, providing macros that facilitate the development of RMCP applications. + -## Features +
-This library primarily provides the following macros: +# rmcp-macros -- `#[tool]`: Mark an async/sync function as an RMCP tool and generate metadata + schema glue -- `#[tool_router]`: Collect all `#[tool]` functions in an impl block into a router value -- `#[tool_handler]`: Implement the `call_tool` and `list_tools` entry points by delegating to a router expression -- `#[task_handler]`: Wire up the task lifecycle (list/enqueue/get/cancel) on top of an `OperationProcessor` +[![Crates.io](https://img.shields.io/crates/v/rmcp-macros.svg)](https://crates.io/crates/rmcp-macros) +[![Documentation](https://docs.rs/rmcp-macros/badge.svg)](https://docs.rs/rmcp-macros) -## Usage +
-### tool +`rmcp-macros` is a procedural macro library for the Rust Model Context Protocol (RMCP) SDK, providing macros that facilitate the development of RMCP applications. -This macro is used to mark a function as a tool handler. +## Available Macros -This will generate a function that return the attribute of this tool, with type `rmcp::model::Tool`. +| Macro | Description | +|-------|-------------| +| [`#[tool]`][tool] | Mark a function as an MCP tool handler | +| [`#[tool_router]`][tool_router] | Generate a tool router from an impl block | +| [`#[tool_handler]`][tool_handler] | Generate `call_tool` and `list_tools` handler methods | +| [`#[prompt]`][prompt] | Mark a function as an MCP prompt handler | +| [`#[prompt_router]`][prompt_router] | Generate a prompt router from an impl block | +| [`#[prompt_handler]`][prompt_handler] | Generate `get_prompt` and `list_prompts` handler methods | +| [`#[task_handler]`][task_handler] | Wire up the task lifecycle on top of an `OperationProcessor` | -#### Tool attributes +[tool]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool.html +[tool_router]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool_router.html +[tool_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool_handler.html +[prompt]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt.html +[prompt_router]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt_router.html +[prompt_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt_handler.html +[task_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.task_handler.html -| field | type | usage | -| :- | :- | :- | -| `name` | `String` | The name of the tool. If not provided, it defaults to the function name. | -| `description` | `String` | A description of the tool. The document of this function will be used. | -| `input_schema` | `Expr` | A JSON Schema object defining the expected parameters for the tool. If not provide, if will use the json schema of its argument with type `Parameters` | -| `annotations` | `ToolAnnotationsAttribute` | Additional tool information. Defaults to `None`. | +## Quick Example -#### Tool example +```rust,ignore +use rmcp::{tool, tool_router, tool_handler, ServerHandler, model::*}; -```rust -#[tool(name = "my_tool", description = "This is my tool", annotations(title = "我的工具", read_only_hint = true))] -pub async fn my_tool(param: Parameters) { - // handling tool request +#[derive(Clone)] +struct MyServer { + tool_router: rmcp::handler::server::tool::ToolRouter, } -``` - -### tool_router - -This macro is used to generate a tool router based on functions marked with `#[rmcp::tool]` in an implementation block. -It creates a function that returns a `ToolRouter` instance. - -In most case, you need to add a field for handler to store the router information and initialize it when creating handler, or store it with a static variable. - -#### Router attributes - -| field | type | usage | -| :- | :- | :- | -| `router` | `Ident` | The name of the router function to be generated. Defaults to `tool_router`. | -| `vis` | `Visibility` | The visibility of the generated router function. Defaults to empty. | - -#### Router example - -```rust #[tool_router] -impl MyToolHandler { - #[tool] - pub fn my_tool() { - - } - - pub fn new() -> Self { - Self { - // the default name of tool router will be `tool_router` - tool_router: Self::tool_router(), - } +impl MyServer { + #[tool(description = "Say hello")] + async fn hello(&self) -> String { + "Hello, world!".into() } } -``` - -Or specify the visibility and router name, which would be helpful when you want to combine multiple routers into one: - -```rust -mod a { - #[tool_router(router = tool_router_a, vis = "pub")] - impl MyToolHandler { - #[tool] - fn my_tool_a() { - - } - } -} - -mod b { - #[tool_router(router = tool_router_b, vis = "pub")] - impl MyToolHandler { - #[tool] - fn my_tool_b() { - - } - } -} - -impl MyToolHandler { - fn new() -> Self { - Self { - tool_router: self::tool_router_a() + self::tool_router_b(), - } - } -} -``` - -### tool_handler - -This macro will generate the handler for `tool_call` and `list_tools` methods in the implementation block, by using an existing `ToolRouter` instance. - -#### Handler attributes - -| field | type | usage | -| :- | :- | :- | -| `router` | `Expr` | The expression to access the `ToolRouter` instance. Defaults to `self.tool_router`. | - -#### Handler example -```rust #[tool_handler] -impl ServerHandler for MyToolHandler { - // ...implement other handler -} -``` - -or using a custom router expression: - -```rust -#[tool_handler(router = self.get_router().await)] -impl ServerHandler for MyToolHandler { - // ...implement other handler -} -``` - -#### Handler expansion - -This macro will be expended to something like this: - -```rust -impl ServerHandler for MyToolHandler { - async fn call_tool( - &self, - request: CallToolRequestParam, - context: RequestContext, - ) -> Result { - let tcc = ToolCallContext::new(self, request, context); - self.tool_router.call(tcc).await - } - - async fn list_tools( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - let items = self.tool_router.list_all(); - Ok(ListToolsResult::with_all_items(items)) - } -} -``` - -### task_handler - -This macro wires the task lifecycle endpoints (`list_tasks`, `enqueue_task`, `get_task`, `cancel_task`) to an implementation of `OperationProcessor`. It keeps the handler lean by delegating scheduling, status tracking, and cancellation semantics to the processor. - -#### Task handler attributes - -| field | type | usage | -| :- | :- | :- | -| `processor` | `Expr` | Expression that yields an `Arc` (or compatible trait object). Defaults to `self.processor.clone()`. | - -#### Task handler example - -```rust -#[derive(Clone)] -pub struct TaskHandler { - processor: Arc + Send + Sync>, -} - -#[task_handler(processor = self.processor.clone())] -impl ServerHandler for TaskHandler {} -``` - -#### Task handler expansion - -At expansion time the macro implements the task-specific handler methods by forwarding to the processor expression, roughly equivalent to: - -```rust -impl ServerHandler for TaskHandler { - async fn list_tasks(&self, request: TaskListRequest, ctx: RequestContext) -> Result { - self.processor.list_tasks(request, ctx).await - } - - async fn enqueue_task(&self, request: TaskEnqueueRequest, ctx: RequestContext) -> Result { - self.processor.enqueue_task(request, ctx).await +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() } - - // get_task and cancel_task are generated in the same manner. } ``` - -## Advanced Features - -- Support for custom tool names and descriptions -- Automatic generation of tool descriptions from documentation comments -- JSON Schema generation for tool parameters +See the [full documentation](https://docs.rs/rmcp-macros) for detailed usage of each macro. ## License diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index ea79f465f..ce9047e49 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -1,3 +1,5 @@ +#![doc = include_str!("../README.md")] + #[allow(unused_imports)] use proc_macro::TokenStream; diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index e28df0009..ed366a39d 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -1,9 +1,17 @@ -# RMCP: Rust Model Context Protocol + -`rmcp` is the official Rust implementation of the Model Context Protocol (MCP), a protocol designed for AI assistants to communicate with other services. This library can be used to build both servers that expose capabilities to AI assistants and clients that interact with such servers. +
+# RMCP: Rust Model Context Protocol +[![Crates.io](https://img.shields.io/crates/v/rmcp.svg)](https://crates.io/crates/rmcp) +[![Documentation](https://docs.rs/rmcp/badge.svg)](https://docs.rs/rmcp) +
+ +`rmcp` is the official Rust implementation of the Model Context Protocol (MCP), a protocol designed for AI assistants to communicate with other services. This library can be used to build both servers that expose capabilities to AI assistants and clients that interact with such servers. ## Quick Start @@ -11,12 +19,15 @@ Creating a server with tools is simple using the `#[tool]` macro: -```rust, ignore +```rust,no_run use rmcp::{ - handler::server::router::tool::ToolRouter, model::*, tool, tool_handler, tool_router, - transport::stdio, ErrorData as McpError, ServiceExt, + ServerHandler, ServiceExt, + handler::server::tool::ToolRouter, + model::*, + tool, tool_handler, tool_router, + transport::stdio, + ErrorData as McpError, }; -use std::future::Future; use std::sync::Arc; use tokio::sync::Mutex; @@ -55,7 +66,7 @@ impl Counter { // Implement the server handler #[tool_handler] -impl rmcp::ServerHandler for Counter { +impl ServerHandler for Counter { fn get_info(&self) -> ServerInfo { ServerInfo { instructions: Some("A simple counter that tallies the number of times the increment tool has been used".into()), @@ -73,11 +84,54 @@ async fn main() -> Result<(), Box> { println!("Error starting server: {}", e); })?; service.waiting().await?; - Ok(()) } ``` +### Structured Output + +Tools can return structured JSON data with schemas. Use the [`Json`] wrapper: + +```rust +# use rmcp::{tool, tool_router, handler::server::{tool::ToolRouter, wrapper::Parameters}, Json}; +# use schemars::JsonSchema; +# use serde::{Serialize, Deserialize}; +# +#[derive(Serialize, Deserialize, JsonSchema)] +struct CalculationRequest { + a: i32, + b: i32, + operation: String, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +struct CalculationResult { + result: i32, + operation: String, +} + +# #[derive(Clone)] +# struct Calculator { +# tool_router: ToolRouter, +# } +# +# #[tool_router] +# impl Calculator { +#[tool(name = "calculate", description = "Perform a calculation")] +async fn calculate(&self, params: Parameters) -> Result, String> { + let result = match params.0.operation.as_str() { + "add" => params.0.a + params.0.b, + "multiply" => params.0.a * params.0.b, + _ => return Err("Unknown operation".to_string()), + }; + + Ok(Json(CalculationResult { result, operation: params.0.operation })) +} +# } +``` + +The `#[tool]` macro automatically generates an output schema from the `CalculationResult` type. + ## Tasks RMCP implements the task lifecycle from SEP-1686 so long-running or asynchronous tool calls can be queued and polled safely. @@ -93,11 +147,11 @@ To expose task support, enable the `tasks` capability when building `ServerCapab Creating a client to interact with a server: -```rust, ignore +```rust,no_run use rmcp::{ - model::CallToolRequestParam, - service::ServiceExt, - transport::{TokioChildProcess, ConfigureCommandExt} + ServiceExt, + model::CallToolRequestParams, + transport::{ConfigureCommandExt, TokioChildProcess}, }; use tokio::process::Command; @@ -105,12 +159,12 @@ use tokio::process::Command; async fn main() -> Result<(), Box> { // Connect to a server running as a child process let service = () - .serve(TokioChildProcess::new(Command::new("uvx").configure( - |cmd| { - cmd.arg("mcp-server-git"); - }, - ))?) - .await?; + .serve(TokioChildProcess::new(Command::new("uvx").configure( + |cmd| { + cmd.arg("mcp-server-git"); + }, + ))?) + .await?; // Get server information let server_info = service.peer_info(); @@ -122,9 +176,10 @@ async fn main() -> Result<(), Box> { // Call a tool let result = service - .call_tool(CallToolRequestParam { - name: "increment".into(), - arguments: None, + .call_tool(CallToolRequestParams { + meta: None, + name: "git_status".into(), + arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(), task: None, }) .await?; @@ -132,11 +187,12 @@ async fn main() -> Result<(), Box> { // Gracefully close the connection service.cancel().await?; - Ok(()) } ``` +For more examples, see the [examples directory](https://github.com/anthropics/mcp-rust-sdk/tree/main/examples) in the repository. + ## Transport Options RMCP supports multiple transport mechanisms, each suited for different use cases: @@ -151,7 +207,7 @@ For working directly with I/O streams (`tokio::io::AsyncRead` and `tokio::io::As Run MCP servers as child processes and communicate via standard I/O. Example: -```rust +```rust,ignore use rmcp::transport::TokioChildProcess; use tokio::process::Command; @@ -159,8 +215,6 @@ let transport = TokioChildProcess::new(Command::new("mcp-server"))?; let service = client.serve(transport).await?; ``` - - ## Access with peer interface when handling message You can get the [`Peer`](crate::service::Peer) struct from [`NotificationContext`](crate::service::NotificationContext) and [`RequestContext`](crate::service::RequestContext). @@ -212,7 +266,7 @@ RMCP uses feature flags to control which components are included: - `transport-async-rw`: Async read/write support - `transport-io`: I/O stream support - `transport-child-process`: Child process support - - `transport-streamable-http-client` / `transport-streamable-http-server`: HTTP streaming (client agnostic, see [`StreamableHttpClientTransport`] for details) + - `transport-streamable-http-client` / `transport-streamable-http-server`: HTTP streaming (client agnostic, see [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) for details) - `transport-streamable-http-client-reqwest`: a default `reqwest` implementation of the streamable http client - `auth`: OAuth2 authentication support - `schemars`: JSON Schema generation (for tool definitions) @@ -227,25 +281,26 @@ RMCP uses feature flags to control which components are included:
Transport -The transport type must implemented [`Transport`] trait, which allow it send message concurrently and receive message sequentially. + +The transport type must implement the [`Transport`](crate::transport::Transport) trait, which allows it to send messages concurrently and receive messages sequentially. There are 2 pairs of standard transport types: -| transport | client | server | -|:-: |:-: |:-: | -| std IO | [`child_process::TokioChildProcess`] | [`io::stdio`] | -| streamable http | [`streamable_http_client::StreamableHttpClientTransport`] | [`streamable_http_server::session::create_session`] | +| transport | client | server | +|:---------------:|:-----------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------:| +| std IO | [`TokioChildProcess`](crate::transport::TokioChildProcess) | [`stdio`](crate::transport::stdio) | +| streamable http | [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) | [`StreamableHttpService`](crate::transport::StreamableHttpService) | -#### [IntoTransport](`IntoTransport`) trait -[`IntoTransport`] is a helper trait that implicitly convert a type into a transport type. +#### [`IntoTransport`](crate::transport::IntoTransport) trait +[`IntoTransport`](crate::transport::IntoTransport) is a helper trait that implicitly converts a type into a transport type. -These types is automatically implemented [`IntoTransport`] trait -1. A type that already implement both [`futures::Sink`] and [`futures::Stream`] trait, or a tuple `(Tx, Rx)` where `Tx` is [`futures::Sink`] and `Rx` is [`futures::Stream`]. -2. A type that implement both [`tokio::io::AsyncRead`] and [`tokio::io::AsyncWrite`] trait. or a tuple `(R, W)` where `R` is [`tokio::io::AsyncRead`] and `W` is [`tokio::io::AsyncWrite`]. -3. A type that implement [Worker](`worker::Worker`) trait. -4. A type that implement [`Transport`] trait. +These types automatically implement [`IntoTransport`](crate::transport::IntoTransport): +1. A type that implements both `futures::Sink` and `futures::Stream`, or a tuple `(Tx, Rx)` where `Tx` is `futures::Sink` and `Rx` is `futures::Stream`. +2. A type that implements both `tokio::io::AsyncRead` and `tokio::io::AsyncWrite`, or a tuple `(R, W)` where `R` is `tokio::io::AsyncRead` and `W` is `tokio::io::AsyncWrite`. +3. A type that implements the [`Worker`](crate::transport::worker::Worker) trait. +4. A type that implements the [`Transport`](crate::transport::Transport) trait.
## License -This project is licensed under the terms specified in the repository's LICENSE file. \ No newline at end of file +This project is licensed under the terms specified in the repository's LICENSE file. diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 5f543d278..29d21b2d8 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -1,145 +1,7 @@ #![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(docsrs, allow(unused_attributes))] -//! The official Rust SDK for the Model Context Protocol (MCP). -//! -//! The MCP is a protocol that allows AI assistants to communicate with other -//! services. `rmcp` is the official Rust implementation of this protocol. -//! -//! There are two ways in which the library can be used, namely to build a -//! server or to build a client. -//! -//! ## Server -//! -//! A server is a service that exposes capabilities. For example, a common -//! use-case is for the server to make multiple tools available to clients such -//! as Claude Desktop or the Cursor IDE. -//! -//! For example, to implement a server that has a tool that can count, you would -//! make an object for that tool and add an implementation with the `#[tool_router]` macro: -//! -//! ```rust -//! use std::sync::Arc; -//! use rmcp::{ErrorData as McpError, model::*, tool, tool_router, handler::server::tool::ToolRouter}; -//! use tokio::sync::Mutex; -//! -//! #[derive(Clone)] -//! pub struct Counter { -//! counter: Arc>, -//! tool_router: ToolRouter, -//! } -//! -//! #[tool_router] -//! impl Counter { -//! fn new() -> Self { -//! Self { -//! counter: Arc::new(Mutex::new(0)), -//! tool_router: Self::tool_router(), -//! } -//! } -//! -//! #[tool(description = "Increment the counter by 1")] -//! async fn increment(&self) -> Result { -//! let mut counter = self.counter.lock().await; -//! *counter += 1; -//! Ok(CallToolResult::success(vec![Content::text( -//! counter.to_string(), -//! )])) -//! } -//! } -//! ``` -//! -//! ### Structured Output -//! -//! Tools can also return structured JSON data with schemas. Use the [`Json`] wrapper: -//! -//! ```rust -//! # use rmcp::{tool, tool_router, handler::server::{tool::ToolRouter, wrapper::Parameters}, Json}; -//! # use schemars::JsonSchema; -//! # use serde::{Serialize, Deserialize}; -//! # -//! #[derive(Serialize, Deserialize, JsonSchema)] -//! struct CalculationRequest { -//! a: i32, -//! b: i32, -//! operation: String, -//! } -//! -//! #[derive(Serialize, Deserialize, JsonSchema)] -//! struct CalculationResult { -//! result: i32, -//! operation: String, -//! } -//! -//! # #[derive(Clone)] -//! # struct Calculator { -//! # tool_router: ToolRouter, -//! # } -//! # -//! # #[tool_router] -//! # impl Calculator { -//! #[tool(name = "calculate", description = "Perform a calculation")] -//! async fn calculate(&self, params: Parameters) -> Result, String> { -//! let result = match params.0.operation.as_str() { -//! "add" => params.0.a + params.0.b, -//! "multiply" => params.0.a * params.0.b, -//! _ => return Err("Unknown operation".to_string()), -//! }; -//! -//! Ok(Json(CalculationResult { result, operation: params.0.operation })) -//! } -//! # } -//! ``` -//! -//! The `#[tool]` macro automatically generates an output schema from the `CalculationResult` type. -//! -//! Next also implement [ServerHandler] for your server type and start the server inside -//! `main` by calling `.serve(...)`. See the examples directory in the repository for more information. -//! -//! ## Client -//! -//! A client can be used to interact with a server. Clients can be used to get a -//! list of the available tools and to call them. For example, we can `uv` to -//! start a MCP server in Python and then list the tools and call `git status` -//! as follows: -//! -//! ```rust -//! use anyhow::Result; -//! use rmcp::{model::CallToolRequestParams, service::ServiceExt}; -//! #[cfg(feature = "transport-child-process")] -//! #[cfg_attr(docsrs, doc(cfg(feature = "transport-child-process")))] -//! use rmcp::transport::{TokioChildProcess, ConfigureCommandExt}; -//! use tokio::process::Command; -//! -//! #[cfg(feature = "transport-child-process")] -//! #[cfg_attr(docsrs, doc(cfg(feature = "transport-child-process")))] -//! async fn client() -> Result<()> { -//! let service = ().serve(TokioChildProcess::new(Command::new("uvx").configure(|cmd| { -//! cmd.arg("mcp-server-git"); -//! }))?).await?; -//! -//! // Initialize -//! let server_info = service.peer_info(); -//! println!("Connected to server: {server_info:#?}"); -//! -//! // List tools -//! let tools = service.list_tools(Default::default()).await?; -//! println!("Available tools: {tools:#?}"); -//! -//! // Call tool 'git_status' with arguments = {"repo_path": "."} -//! let tool_result = service -//! .call_tool(CallToolRequestParams { -//! meta: None, -//! name: "git_status".into(), -//! arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(), -//! task: None, -//! }) -//! .await?; -//! println!("Tool result: {tool_result:#?}"); -//! -//! service.cancel().await?; -//! Ok(()) -//! } -//! ``` +#![doc = include_str!("../README.md")] + mod error; #[allow(deprecated)] pub use error::{Error, ErrorData, RmcpError}; From 613eafbda872a918873439274af2ca01fa295815 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 23 Jan 2026 13:03:31 -0500 Subject: [PATCH 017/333] fix(tasks): #626 model task capabilities correctly (#627) --- crates/rmcp/src/model.rs | 1 + crates/rmcp/src/model/capabilities.rs | 178 +++++++++++++++++- .../client_json_rpc_message_schema.json | 94 +++++++-- ...lient_json_rpc_message_schema_current.json | 94 +++++++-- .../server_json_rpc_message_schema.json | 102 ++++++++-- ...erver_json_rpc_message_schema_current.json | 102 ++++++++-- 6 files changed, 510 insertions(+), 61 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index ae6bdb069..e0f0bb242 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2089,6 +2089,7 @@ macro_rules! ts_union { (@declare_end $U:ident { $($declared:tt)* }) => { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] + #[allow(clippy::large_enum_variant)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub enum $U { $($declared)* diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 1740b3eef..803532161 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -40,24 +40,121 @@ pub struct RootsCapabilities { pub list_changed: Option, } -/// Task capability negotiation for SEP-1686. +/// Task capabilities shared by client and server. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct TasksCapability { - /// Map of request category (e.g. "tools.call") to a boolean indicating support. #[serde(skip_serializing_if = "Option::is_none")] - pub requests: Option, - /// Whether the receiver supports `tasks/list`. + pub requests: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub list: Option, - /// Whether the receiver supports `tasks/cancel`. + pub list: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub cancel: Option, + pub cancel: Option, } -/// A convenience alias for describing per-request task support. -pub type TaskRequestMap = BTreeMap; +/// Request types that support task-augmented execution. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct TaskRequestsCapability { + #[serde(skip_serializing_if = "Option::is_none")] + pub sampling: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub elicitation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct SamplingTaskCapability { + #[serde(skip_serializing_if = "Option::is_none")] + pub create_message: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ElicitationTaskCapability { + #[serde(skip_serializing_if = "Option::is_none")] + pub create: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ToolsTaskCapability { + #[serde(skip_serializing_if = "Option::is_none")] + pub call: Option, +} + +impl TasksCapability { + /// Default client tasks capability with sampling and elicitation support. + pub fn client_default() -> Self { + Self { + list: Some(JsonObject::new()), + cancel: Some(JsonObject::new()), + requests: Some(TaskRequestsCapability { + sampling: Some(SamplingTaskCapability { + create_message: Some(JsonObject::new()), + }), + elicitation: Some(ElicitationTaskCapability { + create: Some(JsonObject::new()), + }), + tools: None, + }), + } + } + + /// Default server tasks capability with tools/call support. + pub fn server_default() -> Self { + Self { + list: Some(JsonObject::new()), + cancel: Some(JsonObject::new()), + requests: Some(TaskRequestsCapability { + sampling: None, + elicitation: None, + tools: Some(ToolsTaskCapability { + call: Some(JsonObject::new()), + }), + }), + } + } + + pub fn supports_list(&self) -> bool { + self.list.is_some() + } + + pub fn supports_cancel(&self) -> bool { + self.cancel.is_some() + } + + pub fn supports_tools_call(&self) -> bool { + self.requests + .as_ref() + .and_then(|r| r.tools.as_ref()) + .and_then(|t| t.call.as_ref()) + .is_some() + } + + pub fn supports_sampling_create_message(&self) -> bool { + self.requests + .as_ref() + .and_then(|r| r.sampling.as_ref()) + .and_then(|s| s.create_message.as_ref()) + .is_some() + } + + pub fn supports_elicitation_create(&self) -> bool { + self.requests + .as_ref() + .and_then(|r| r.elicitation.as_ref()) + .and_then(|e| e.create.as_ref()) + .is_some() + } +} /// Capability for handling elicitation requests from servers. /// @@ -368,4 +465,67 @@ mod test { }) ); } + + #[test] + fn test_task_capabilities_deserialization() { + // Test deserializing from the MCP spec format + let json = serde_json::json!({ + "list": {}, + "cancel": {}, + "requests": { + "tools": { "call": {} } + } + }); + + let tasks: TasksCapability = serde_json::from_value(json).unwrap(); + assert!(tasks.list.is_some()); + assert!(tasks.cancel.is_some()); + assert!(tasks.requests.is_some()); + let requests = tasks.requests.unwrap(); + assert!(requests.tools.is_some()); + assert!(requests.tools.unwrap().call.is_some()); + } + + #[test] + fn test_tasks_capability_client_default() { + let tasks = TasksCapability::client_default(); + + // Verify structure + assert!(tasks.supports_list()); + assert!(tasks.supports_cancel()); + assert!(tasks.supports_sampling_create_message()); + assert!(tasks.supports_elicitation_create()); + assert!(!tasks.supports_tools_call()); + + // Verify serialization matches expected format + let json = serde_json::to_value(&tasks).unwrap(); + assert_eq!(json["list"], serde_json::json!({})); + assert_eq!(json["cancel"], serde_json::json!({})); + assert_eq!( + json["requests"]["sampling"]["createMessage"], + serde_json::json!({}) + ); + assert_eq!( + json["requests"]["elicitation"]["create"], + serde_json::json!({}) + ); + } + + #[test] + fn test_tasks_capability_server_default() { + let tasks = TasksCapability::server_default(); + + // Verify structure + assert!(tasks.supports_list()); + assert!(tasks.supports_cancel()); + assert!(tasks.supports_tools_call()); + assert!(!tasks.supports_sampling_create_message()); + assert!(!tasks.supports_elicitation_create()); + + // Verify serialization matches expected format + let json = serde_json::to_value(&tasks).unwrap(); + assert_eq!(json["list"], serde_json::json!({})); + assert_eq!(json["cancel"], serde_json::json!({})); + assert_eq!(json["requests"]["tools"]["call"], serde_json::json!({})); + } } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 85b2a5fb2..e0d90fa8a 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -519,6 +519,18 @@ } } }, + "ElicitationTaskCapability": { + "type": "object", + "properties": { + "create": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object", @@ -1718,6 +1730,18 @@ "format": "const", "const": "notifications/roots/list_changed" }, + "SamplingTaskCapability": { + "type": "object", + "properties": { + "createMessage": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "SetLevelRequestMethod": { "type": "string", "format": "const", @@ -1774,33 +1798,81 @@ "uri" ] }, + "TaskRequestsCapability": { + "description": "Request types that support task-augmented execution.", + "type": "object", + "properties": { + "elicitation": { + "anyOf": [ + { + "$ref": "#/definitions/ElicitationTaskCapability" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "anyOf": [ + { + "$ref": "#/definitions/SamplingTaskCapability" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsTaskCapability" + }, + { + "type": "null" + } + ] + } + } + }, "TasksCapability": { - "description": "Task capability negotiation for SEP-1686.", + "description": "Task capabilities shared by client and server.", "type": "object", "properties": { "cancel": { - "description": "Whether the receiver supports `tasks/cancel`.", "type": [ - "boolean", + "object", "null" - ] + ], + "additionalProperties": true }, "list": { - "description": "Whether the receiver supports `tasks/list`.", "type": [ - "boolean", + "object", "null" - ] + ], + "additionalProperties": true }, "requests": { - "description": "Map of request category (e.g. \"tools.call\") to a boolean indicating support.", + "anyOf": [ + { + "$ref": "#/definitions/TaskRequestsCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ToolsTaskCapability": { + "type": "object", + "properties": { + "call": { "type": [ "object", "null" ], - "additionalProperties": { - "type": "boolean" - } + "additionalProperties": true } } }, diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 85b2a5fb2..e0d90fa8a 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -519,6 +519,18 @@ } } }, + "ElicitationTaskCapability": { + "type": "object", + "properties": { + "create": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object", @@ -1718,6 +1730,18 @@ "format": "const", "const": "notifications/roots/list_changed" }, + "SamplingTaskCapability": { + "type": "object", + "properties": { + "createMessage": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "SetLevelRequestMethod": { "type": "string", "format": "const", @@ -1774,33 +1798,81 @@ "uri" ] }, + "TaskRequestsCapability": { + "description": "Request types that support task-augmented execution.", + "type": "object", + "properties": { + "elicitation": { + "anyOf": [ + { + "$ref": "#/definitions/ElicitationTaskCapability" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "anyOf": [ + { + "$ref": "#/definitions/SamplingTaskCapability" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsTaskCapability" + }, + { + "type": "null" + } + ] + } + } + }, "TasksCapability": { - "description": "Task capability negotiation for SEP-1686.", + "description": "Task capabilities shared by client and server.", "type": "object", "properties": { "cancel": { - "description": "Whether the receiver supports `tasks/cancel`.", "type": [ - "boolean", + "object", "null" - ] + ], + "additionalProperties": true }, "list": { - "description": "Whether the receiver supports `tasks/list`.", "type": [ - "boolean", + "object", "null" - ] + ], + "additionalProperties": true }, "requests": { - "description": "Map of request category (e.g. \"tools.call\") to a boolean indicating support.", + "anyOf": [ + { + "$ref": "#/definitions/TaskRequestsCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "ToolsTaskCapability": { + "type": "object", + "properties": { + "call": { "type": [ "object", "null" ], - "additionalProperties": { - "type": "boolean" - } + "additionalProperties": true } } }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index e23eae12d..b848d4ee5 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -763,6 +763,18 @@ "properties" ] }, + "ElicitationTaskCapability": { + "type": "object", + "properties": { + "create": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object", @@ -2322,6 +2334,18 @@ "content" ] }, + "SamplingTaskCapability": { + "type": "object", + "properties": { + "createMessage": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "ServerCapabilities": { "title": "Builder", "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_logging()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", @@ -2605,6 +2629,42 @@ "createdAt" ] }, + "TaskRequestsCapability": { + "description": "Request types that support task-augmented execution.", + "type": "object", + "properties": { + "elicitation": { + "anyOf": [ + { + "$ref": "#/definitions/ElicitationTaskCapability" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "anyOf": [ + { + "$ref": "#/definitions/SamplingTaskCapability" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsTaskCapability" + }, + { + "type": "null" + } + ] + } + } + }, "TaskResult": { "description": "Final result for a succeeded task (returned from `tasks/result`).", "type": "object", @@ -2660,32 +2720,32 @@ ] }, "TasksCapability": { - "description": "Task capability negotiation for SEP-1686.", + "description": "Task capabilities shared by client and server.", "type": "object", "properties": { "cancel": { - "description": "Whether the receiver supports `tasks/cancel`.", "type": [ - "boolean", + "object", "null" - ] + ], + "additionalProperties": true }, "list": { - "description": "Whether the receiver supports `tasks/list`.", - "type": [ - "boolean", - "null" - ] - }, - "requests": { - "description": "Map of request category (e.g. \"tools.call\") to a boolean indicating support.", "type": [ "object", "null" ], - "additionalProperties": { - "type": "boolean" - } + "additionalProperties": true + }, + "requests": { + "anyOf": [ + { + "$ref": "#/definitions/TaskRequestsCapability" + }, + { + "type": "null" + } + ] } } }, @@ -2921,6 +2981,18 @@ } } }, + "ToolsTaskCapability": { + "type": "object", + "properties": { + "call": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "UntitledItems": { "description": "Items for untitled multi-select options", "type": "object", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index e23eae12d..b848d4ee5 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -763,6 +763,18 @@ "properties" ] }, + "ElicitationTaskCapability": { + "type": "object", + "properties": { + "create": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object", @@ -2322,6 +2334,18 @@ "content" ] }, + "SamplingTaskCapability": { + "type": "object", + "properties": { + "createMessage": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "ServerCapabilities": { "title": "Builder", "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_logging()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", @@ -2605,6 +2629,42 @@ "createdAt" ] }, + "TaskRequestsCapability": { + "description": "Request types that support task-augmented execution.", + "type": "object", + "properties": { + "elicitation": { + "anyOf": [ + { + "$ref": "#/definitions/ElicitationTaskCapability" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "anyOf": [ + { + "$ref": "#/definitions/SamplingTaskCapability" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsTaskCapability" + }, + { + "type": "null" + } + ] + } + } + }, "TaskResult": { "description": "Final result for a succeeded task (returned from `tasks/result`).", "type": "object", @@ -2660,32 +2720,32 @@ ] }, "TasksCapability": { - "description": "Task capability negotiation for SEP-1686.", + "description": "Task capabilities shared by client and server.", "type": "object", "properties": { "cancel": { - "description": "Whether the receiver supports `tasks/cancel`.", "type": [ - "boolean", + "object", "null" - ] + ], + "additionalProperties": true }, "list": { - "description": "Whether the receiver supports `tasks/list`.", - "type": [ - "boolean", - "null" - ] - }, - "requests": { - "description": "Map of request category (e.g. \"tools.call\") to a boolean indicating support.", "type": [ "object", "null" ], - "additionalProperties": { - "type": "boolean" - } + "additionalProperties": true + }, + "requests": { + "anyOf": [ + { + "$ref": "#/definitions/TaskRequestsCapability" + }, + { + "type": "null" + } + ] } } }, @@ -2921,6 +2981,18 @@ } } }, + "ToolsTaskCapability": { + "type": "object", + "properties": { + "call": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "UntitledItems": { "description": "Items for untitled multi-select options", "type": "object", From 8d09f8813d2f6f7386b50f0d0fce7a69280af501 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:09:02 -0500 Subject: [PATCH 018/333] chore: release v0.14.0 (#623) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 8 ++++++++ crates/rmcp/CHANGELOG.md | 13 +++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 188aa4d52..e91a99d36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "0.13.0", path = "./crates/rmcp" } -rmcp-macros = { version = "0.13.0", path = "./crates/rmcp-macros" } +rmcp = { version = "0.14.0", path = "./crates/rmcp" } +rmcp-macros = { version = "0.14.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "0.13.0" +version = "0.14.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 7cd42c54c..ef895f2f5 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.13.0...rmcp-macros-v0.14.0) - 2026-01-23 + +### Other + +- show README content on docs.rs ([#583](https://github.com/modelcontextprotocol/rust-sdk/pull/583)) +- added hyper-mcp to the list of built with rmcp ([#621](https://github.com/modelcontextprotocol/rust-sdk/pull/621)) +- Implement SEP-1319: Decouple Request Payload from RPC Methods ([#617](https://github.com/modelcontextprotocol/rust-sdk/pull/617)) + ## [0.13.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.12.0...rmcp-macros-v0.13.0) - 2026-01-15 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 7feff31cf..4cdead0a6 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.13.0...rmcp-v0.14.0) - 2026-01-23 + +### Fixed + +- *(tasks)* #626 model task capabilities correctly ([#627](https://github.com/modelcontextprotocol/rust-sdk/pull/627)) +- don't treat non-success HTTP codes as transport errors ([#618](https://github.com/modelcontextprotocol/rust-sdk/pull/618)) + +### Other + +- show README content on docs.rs ([#583](https://github.com/modelcontextprotocol/rust-sdk/pull/583)) +- added hyper-mcp to the list of built with rmcp ([#621](https://github.com/modelcontextprotocol/rust-sdk/pull/621)) +- Implement SEP-1319: Decouple Request Payload from RPC Methods ([#617](https://github.com/modelcontextprotocol/rust-sdk/pull/617)) + ## [0.13.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.12.0...rmcp-v0.13.0) - 2026-01-15 ### Added From d84573a6170b77d80ffc1dd05dc6db9904aa5d1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:27:36 +0800 Subject: [PATCH 019/333] chore(deps): update rig-core requirement from 0.28.0 to 0.29.0 (#630) Updates the requirements on [rig-core](https://github.com/0xPlaygrounds/rig) to permit the latest version. - [Release notes](https://github.com/0xPlaygrounds/rig/releases) - [Commits](https://github.com/0xPlaygrounds/rig/compare/rig-core-v0.28.0...rig-core-v0.29.0) --- updated-dependencies: - dependency-name: rig-core dependency-version: 0.29.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: jokemanfire --- examples/rig-integration/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/rig-integration/Cargo.toml b/examples/rig-integration/Cargo.toml index 4cc0ee926..afb920b93 100644 --- a/examples/rig-integration/Cargo.toml +++ b/examples/rig-integration/Cargo.toml @@ -13,7 +13,7 @@ readme = { workspace = true } publish = false [dependencies] -rig-core = "0.28.0" +rig-core = "0.29.0" tokio = { version = "1", features = ["full"] } rmcp = { workspace = true, features = [ "client", From 32a68aa2396aba6a9990d2b38164b2e74906e092 Mon Sep 17 00:00:00 2001 From: apexlnc <43242113+apexlnc@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:30:53 -0500 Subject: [PATCH 020/333] fix(tasks): correct enum variant ordering for deserialization (#634) Move CustomRequest and CustomResult to end of their respective untagged enums to ensure specific task variants match before catch-all custom types. Add deny_unknown_fields to GetTaskInfoResult to prevent matching arbitrary JSON objects. Fixes issue where tasks/get, tasks/list, tasks/result, and tasks/cancel incorrectly deserialized as CustomRequest instead of their typed variants. --- crates/rmcp/src/model.rs | 9 +++++---- .../client_json_rpc_message_schema.json | 6 +++--- .../client_json_rpc_message_schema_current.json | 6 +++--- .../server_json_rpc_message_schema.json | 9 +++++---- .../server_json_rpc_message_schema_current.json | 9 +++++---- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index e0f0bb242..3c86fbbdc 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2045,6 +2045,7 @@ impl RequestParamsMeta for CancelTaskParams { pub type CancelTaskParam = CancelTaskParams; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct GetTaskInfoResult { #[serde(skip_serializing_if = "Option::is_none")] @@ -2130,11 +2131,11 @@ ts_union!( | UnsubscribeRequest | CallToolRequest | ListToolsRequest - | CustomRequest | GetTaskInfoRequest | ListTasksRequest | GetTaskResultRequest - | CancelTaskRequest; + | CancelTaskRequest + | CustomRequest; ); impl ClientRequest { @@ -2153,11 +2154,11 @@ impl ClientRequest { ClientRequest::UnsubscribeRequest(r) => r.method.as_str(), ClientRequest::CallToolRequest(r) => r.method.as_str(), ClientRequest::ListToolsRequest(r) => r.method.as_str(), - ClientRequest::CustomRequest(r) => r.method.as_str(), ClientRequest::GetTaskInfoRequest(r) => r.method.as_str(), ClientRequest::ListTasksRequest(r) => r.method.as_str(), ClientRequest::GetTaskResultRequest(r) => r.method.as_str(), ClientRequest::CancelTaskRequest(r) => r.method.as_str(), + ClientRequest::CustomRequest(r) => r.method.as_str(), } } } @@ -2222,11 +2223,11 @@ ts_union!( | ListToolsResult | CreateElicitationResult | EmptyResult - | CustomResult | CreateTaskResult | ListTasksResult | GetTaskInfoResult | TaskResult + | CustomResult ; ); diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index e0d90fa8a..6e45c5f15 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -862,9 +862,6 @@ { "$ref": "#/definitions/RequestOptionalParam4" }, - { - "$ref": "#/definitions/CustomRequest" - }, { "$ref": "#/definitions/Request9" }, @@ -876,6 +873,9 @@ }, { "$ref": "#/definitions/Request11" + }, + { + "$ref": "#/definitions/CustomRequest" } ], "required": [ diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index e0d90fa8a..6e45c5f15 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -862,9 +862,6 @@ { "$ref": "#/definitions/RequestOptionalParam4" }, - { - "$ref": "#/definitions/CustomRequest" - }, { "$ref": "#/definitions/Request9" }, @@ -876,6 +873,9 @@ }, { "$ref": "#/definitions/Request11" + }, + { + "$ref": "#/definitions/CustomRequest" } ], "required": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index b848d4ee5..f15968578 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -857,7 +857,8 @@ } ] } - } + }, + "additionalProperties": false }, "Icon": { "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", @@ -2452,9 +2453,6 @@ { "$ref": "#/definitions/EmptyObject" }, - { - "$ref": "#/definitions/CustomResult" - }, { "$ref": "#/definitions/CreateTaskResult" }, @@ -2466,6 +2464,9 @@ }, { "$ref": "#/definitions/TaskResult" + }, + { + "$ref": "#/definitions/CustomResult" } ] }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index b848d4ee5..f15968578 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -857,7 +857,8 @@ } ] } - } + }, + "additionalProperties": false }, "Icon": { "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", @@ -2452,9 +2453,6 @@ { "$ref": "#/definitions/EmptyObject" }, - { - "$ref": "#/definitions/CustomResult" - }, { "$ref": "#/definitions/CreateTaskResult" }, @@ -2466,6 +2464,9 @@ }, { "$ref": "#/definitions/TaskResult" + }, + { + "$ref": "#/definitions/CustomResult" } ] }, From 1794fe15485b012346298cf8e4e7e03936f60ccd Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 3 Feb 2026 19:14:22 -0500 Subject: [PATCH 021/333] feat(capabilities): add extensions field for SEP-1724 (#643) Add support for MCP extension capabilities in both ClientCapabilities and ServerCapabilities structs, as specified in SEP-1724. Changes: - Add ExtensionCapabilities type alias (BTreeMap) - Add 'extensions' field to ClientCapabilities struct - Add 'extensions' field to ServerCapabilities struct - Update builder macros and impl blocks for both structs - Add comprehensive tests for extension capabilities - Update JSON schema test fixtures This enables clients to advertise extension support during initialize, such as: { "capabilities": { "extensions": { "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] } } } } Closes #530 --- crates/rmcp/src/model/capabilities.rs | 163 ++++++++++++++++-- .../client_json_rpc_message_schema.json | 11 ++ ...lient_json_rpc_message_schema_current.json | 11 ++ .../server_json_rpc_message_schema.json | 11 ++ ...erver_json_rpc_message_schema_current.json | 11 ++ 5 files changed, 197 insertions(+), 10 deletions(-) diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 803532161..c82557511 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -6,6 +6,28 @@ use serde::{Deserialize, Serialize}; use super::JsonObject; pub type ExperimentalCapabilities = BTreeMap; +/// MCP extension capabilities map. +/// +/// Keys are extension identifiers in the format `{vendor-prefix}/{extension-name}` +/// (e.g., `io.modelcontextprotocol/ui`, `io.modelcontextprotocol/oauth-client-credentials`). +/// Values are per-extension settings objects. An empty object indicates support with no settings. +/// +/// # Example +/// +/// ```rust +/// use rmcp::model::ExtensionCapabilities; +/// use serde_json::json; +/// +/// let mut extensions = ExtensionCapabilities::new(); +/// extensions.insert( +/// "io.modelcontextprotocol/ui".to_string(), +/// serde_json::from_value(json!({ +/// "mimeTypes": ["text/html;profile=mcp-app"] +/// })).unwrap() +/// ); +/// ``` +pub type ExtensionCapabilities = BTreeMap; + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -187,6 +209,12 @@ pub struct ElicitationCapability { pub struct ClientCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub experimental: Option, + /// Optional MCP extensions that the client supports (SEP-1724). + /// Keys are extension identifiers (e.g., `"io.modelcontextprotocol/ui"`), + /// values are per-extension settings objects. An empty object indicates + /// support with no settings. + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub roots: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -217,6 +245,12 @@ pub struct ClientCapabilities { pub struct ServerCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub experimental: Option, + /// Optional MCP extensions that the server supports (SEP-1724). + /// Keys are extension identifiers (e.g., `"io.modelcontextprotocol/apps"`), + /// values are per-extension settings objects. An empty object indicates + /// support with no settings. + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub logging: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -339,6 +373,7 @@ macro_rules! builder { builder! { ServerCapabilities { experimental: ExperimentalCapabilities, + extensions: ExtensionCapabilities, logging: JsonObject, completions: JsonObject, prompts: PromptsCapability, @@ -348,8 +383,15 @@ builder! { } } -impl - ServerCapabilitiesBuilder> +impl< + const E: bool, + const EXT: bool, + const L: bool, + const C: bool, + const P: bool, + const R: bool, + const TASKS: bool, +> ServerCapabilitiesBuilder> { pub fn enable_tool_list_changed(mut self) -> Self { if let Some(c) = self.tools.as_mut() { @@ -359,8 +401,15 @@ impl - ServerCapabilitiesBuilder> +impl< + const E: bool, + const EXT: bool, + const L: bool, + const C: bool, + const R: bool, + const T: bool, + const TASKS: bool, +> ServerCapabilitiesBuilder> { pub fn enable_prompts_list_changed(mut self) -> Self { if let Some(c) = self.prompts.as_mut() { @@ -370,8 +419,15 @@ impl - ServerCapabilitiesBuilder> +impl< + const E: bool, + const EXT: bool, + const L: bool, + const C: bool, + const P: bool, + const T: bool, + const TASKS: bool, +> ServerCapabilitiesBuilder> { pub fn enable_resources_list_changed(mut self) -> Self { if let Some(c) = self.resources.as_mut() { @@ -391,6 +447,7 @@ impl - ClientCapabilitiesBuilder> +impl + ClientCapabilitiesBuilder> { pub fn enable_roots_list_changed(mut self) -> Self { if let Some(c) = self.roots.as_mut() { @@ -410,8 +467,8 @@ impl } #[cfg(feature = "elicitation")] -impl - ClientCapabilitiesBuilder> +impl + ClientCapabilitiesBuilder> { /// Enable JSON Schema validation for elicitation responses. /// When enabled, the client will validate user input against the requested_schema @@ -528,4 +585,90 @@ mod test { assert_eq!(json["cancel"], serde_json::json!({})); assert_eq!(json["requests"]["tools"]["call"], serde_json::json!({})); } + + #[test] + fn test_client_extensions_capability() { + // Test building ClientCapabilities with extensions (MCP Apps support) + let mut extensions = ExtensionCapabilities::new(); + extensions.insert( + "io.modelcontextprotocol/ui".to_string(), + serde_json::from_value(serde_json::json!({ + "mimeTypes": ["text/html;profile=mcp-app"] + })) + .unwrap(), + ); + + let capabilities = ClientCapabilities::builder() + .enable_extensions_with(extensions) + .enable_sampling() + .build(); + + // Verify serialization matches MCP Apps spec format + let json = serde_json::to_value(&capabilities).unwrap(); + assert_eq!( + json["extensions"]["io.modelcontextprotocol/ui"]["mimeTypes"], + serde_json::json!(["text/html;profile=mcp-app"]) + ); + assert!(json["sampling"].is_object()); + } + + #[test] + fn test_server_extensions_capability() { + // Test building ServerCapabilities with extensions + let mut extensions = ExtensionCapabilities::new(); + extensions.insert( + "io.modelcontextprotocol/apps".to_string(), + serde_json::from_value(serde_json::json!({})).unwrap(), + ); + + let capabilities = ServerCapabilities::builder() + .enable_extensions_with(extensions) + .enable_tools() + .build(); + + // Verify serialization + let json = serde_json::to_value(&capabilities).unwrap(); + assert!(json["extensions"]["io.modelcontextprotocol/apps"].is_object()); + assert!(json["tools"].is_object()); + } + + #[test] + fn test_extensions_deserialization() { + // Test deserializing capabilities with extensions from JSON + let json = serde_json::json!({ + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + }, + "sampling": {} + }); + + let capabilities: ClientCapabilities = serde_json::from_value(json).unwrap(); + assert!(capabilities.extensions.is_some()); + let extensions = capabilities.extensions.unwrap(); + assert!(extensions.contains_key("io.modelcontextprotocol/ui")); + let ui_ext = extensions.get("io.modelcontextprotocol/ui").unwrap(); + assert!(ui_ext.contains_key("mimeTypes")); + } + + #[test] + fn test_extensions_empty_settings() { + // Test that empty extension settings work (indicates support with no settings) + let mut extensions = ExtensionCapabilities::new(); + extensions.insert( + "io.modelcontextprotocol/oauth-client-credentials".to_string(), + JsonObject::new(), + ); + + let capabilities = ClientCapabilities::builder() + .enable_extensions_with(extensions) + .build(); + + let json = serde_json::to_value(&capabilities).unwrap(); + assert_eq!( + json["extensions"]["io.modelcontextprotocol/oauth-client-credentials"], + serde_json::json!({}) + ); + } } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 6e45c5f15..4b6c24aa4 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -296,6 +296,17 @@ "additionalProperties": true } }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, "roots": { "anyOf": [ { diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 6e45c5f15..4b6c24aa4 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -296,6 +296,17 @@ "additionalProperties": true } }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, "roots": { "anyOf": [ { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index f15968578..e23aa330a 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -2369,6 +2369,17 @@ "additionalProperties": true } }, + "extensions": { + "description": "Optional MCP extensions that the server supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/apps\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, "logging": { "type": [ "object", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index f15968578..e23aa330a 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -2369,6 +2369,17 @@ "additionalProperties": true } }, + "extensions": { + "description": "Optional MCP extensions that the server supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/apps\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, "logging": { "type": [ "object", From df6c3f0665246bdd9a3b21ba9357bf9f7dd04319 Mon Sep 17 00:00:00 2001 From: Luca Chang <131398524+LucaButBoring@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:17:36 -0800 Subject: [PATCH 022/333] fix(tasks): expose `execution.taskSupport` on tools (#635) * fix(tasks): expose execution.taskSupport on tools * feat: implement taskSupport validation on server --- crates/rmcp-macros/src/tool.rs | 45 +++ crates/rmcp-macros/src/tool_handler.rs | 9 + crates/rmcp/src/handler/server.rs | 40 ++- crates/rmcp/src/handler/server/router/tool.rs | 7 + crates/rmcp/src/model/tool.rs | 71 +++++ .../tool_list_result.json | 3 + .../server_json_rpc_message_schema.json | 48 ++++ ...erver_json_rpc_message_schema_current.json | 48 ++++ .../tests/test_task_support_validation.rs | 269 ++++++++++++++++++ examples/servers/src/sampling_stdio.rs | 1 + 10 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 crates/rmcp/tests/test_task_support_validation.rs diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index abfb3c362..bec3ddd11 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -89,12 +89,21 @@ pub struct ToolAttribute { pub output_schema: Option, /// Optional additional tool information. pub annotations: Option, + /// Execution-related configuration including task support. + pub execution: Option, /// Optional icons for the tool pub icons: Option, /// Optional metadata for the tool pub meta: Option, } +#[derive(FromMeta, Debug, Default)] +#[darling(default)] +pub struct ToolExecutionAttribute { + /// Task support mode: "forbidden", "optional", or "required" + pub task_support: Option, +} + pub struct ResolvedToolAttribute { pub name: String, pub title: Option, @@ -102,6 +111,7 @@ pub struct ResolvedToolAttribute { pub input_schema: Expr, pub output_schema: Option, pub annotations: Expr, + pub execution: Expr, pub icons: Option, pub meta: Option, } @@ -115,6 +125,7 @@ impl ResolvedToolAttribute { input_schema, output_schema, annotations, + execution, icons, meta, } = self; @@ -155,6 +166,7 @@ impl ResolvedToolAttribute { input_schema: #input_schema, output_schema: #output_schema, annotations: #annotations, + execution: #execution, icons: #icons, meta: #meta, } @@ -263,6 +275,38 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { } else { none_expr()? }; + let execution_expr = if let Some(execution) = attribute.execution { + let ToolExecutionAttribute { task_support } = execution; + + let task_support_expr = if let Some(ts) = task_support { + let ts_ident = match ts.as_str() { + "forbidden" => quote! { rmcp::model::TaskSupport::Forbidden }, + "optional" => quote! { rmcp::model::TaskSupport::Optional }, + "required" => quote! { rmcp::model::TaskSupport::Required }, + _ => { + return Err(syn::Error::new( + Span::call_site(), + format!( + "Invalid task_support value '{}'. Expected 'forbidden', 'optional', or 'required'", + ts + ), + )); + } + }; + quote! { Some(#ts_ident) } + } else { + quote! { None } + }; + + let token_stream = quote! { + Some(rmcp::model::ToolExecution { + task_support: #task_support_expr, + }) + }; + syn::parse2::(token_stream)? + } else { + none_expr()? + }; // Handle output_schema - either explicit or generated from return type let output_schema_expr = attribute.output_schema.or_else(|| { // Try to generate schema from return type @@ -286,6 +330,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { input_schema: input_schema_expr, output_schema: output_schema_expr, annotations: annotations_expr, + execution: execution_expr, title: attribute.title, icons: attribute.icons, meta: attribute.meta, diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index e37fa88e7..e28c0ca47 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -56,9 +56,18 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result Option { + #router.get(name).cloned() + } + }; + let tool_call_fn = syn::parse2::(tool_call_fn)?; let tool_list_fn = syn::parse2::(tool_list_fn)?; + let get_tool_fn = syn::parse2::(get_tool_fn)?; item_impl.items.push(tool_call_fn); item_impl.items.push(tool_list_fn); + item_impl.items.push(get_tool_fn); Ok(item_impl.into_token_stream()) } diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index ee744b776..86773d878 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::{ error::ErrorData as McpError, - model::*, + model::{TaskSupport, *}, service::{NotificationContext, RequestContext, RoleServer, Service, ServiceRole}, }; @@ -65,7 +65,32 @@ impl Service for H { .await .map(ServerResult::empty), ClientRequest::CallToolRequest(request) => { - if request.params.task.is_some() { + let is_task = request.params.task.is_some(); + + // Validate task support mode per MCP specification + if let Some(tool) = self.get_tool(&request.params.name) { + match (tool.task_support(), is_task) { + // If taskSupport is "required", clients MUST invoke the tool as a task. + // Servers MUST return a -32601 (Method not found) error if they don't. + (TaskSupport::Required, false) => { + return Err(McpError::new( + ErrorCode::METHOD_NOT_FOUND, + "Tool requires task-based invocation", + None, + )); + } + // If taskSupport is "forbidden" (default), clients MUST NOT invoke as a task. + (TaskSupport::Forbidden, true) => { + return Err(McpError::invalid_params( + "Tool does not support task-based invocation", + None, + )); + } + _ => {} + } + } + + if is_task { tracing::info!("Enqueueing task for tool call: {}", request.params.name); self.enqueue_task(request.params, context.clone()) .await @@ -241,6 +266,13 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { ) -> impl Future> + Send + '_ { std::future::ready(Ok(ListToolsResult::default())) } + /// Get a tool definition by name. + /// + /// The default implementation returns `None`, which bypasses validation. + /// When using `#[tool_handler]`, this method is automatically implemented. + fn get_tool(&self, _name: &str) -> Option { + None + } fn on_custom_request( &self, request: CustomRequest, @@ -445,6 +477,10 @@ macro_rules! impl_server_handler_for_wrapper { (**self).list_tools(request, context) } + fn get_tool(&self, name: &str) -> Option { + (**self).get_tool(name) + } + fn on_custom_request( &self, request: CustomRequest, diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 036531245..72b7f2e26 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -254,6 +254,13 @@ where pub fn list_all(&self) -> Vec { self.map.values().map(|item| item.attr.clone()).collect() } + + /// Get a tool definition by name. + /// + /// Returns the tool if found, or `None` if no tool with the given name exists. + pub fn get(&self, name: &str) -> Option<&crate::model::Tool> { + self.map.get(name).map(|r| &r.attr) + } } impl std::ops::Add> for ToolRouter diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index 814d10aef..cb2cfda37 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -29,6 +29,9 @@ pub struct Tool { #[serde(skip_serializing_if = "Option::is_none")] /// Optional additional tool information. pub annotations: Option, + /// Execution-related configuration including task support mode. + #[serde(skip_serializing_if = "Option::is_none")] + pub execution: Option, /// Optional list of icons for the tool #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -37,6 +40,55 @@ pub struct Tool { pub meta: Option, } +/// Per-tool task support mode as defined in the MCP specification. +/// +/// This enum indicates whether a tool supports task-based invocation, +/// allowing clients to know how to properly call the tool. +/// +/// See [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum TaskSupport { + /// Clients MUST NOT invoke this tool as a task (default behavior). + #[default] + Forbidden, + /// Clients MAY invoke this tool as either a task or a normal call. + Optional, + /// Clients MUST invoke this tool as a task. + Required, +} + +/// Execution-related configuration for a tool. +/// +/// This struct contains settings that control how a tool should be executed, +/// including task support configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ToolExecution { + /// Indicates whether this tool supports task-based invocation. + /// + /// When not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task. + /// When set to `Optional`, clients MAY invoke this tool as a task or normal call. + /// When set to `Required`, clients MUST invoke this tool as a task. + #[serde(skip_serializing_if = "Option::is_none")] + pub task_support: Option, +} + +impl ToolExecution { + /// Create a new empty ToolExecution configuration. + pub fn new() -> Self { + Self::default() + } + + /// Set the task support mode. + pub fn with_task_support(mut self, task_support: TaskSupport) -> Self { + self.task_support = Some(task_support); + self + } +} + /// Additional properties describing a Tool to clients. /// /// NOTE: all properties in ToolAnnotations are **hints**. @@ -152,6 +204,7 @@ impl Tool { input_schema: input_schema.into(), output_schema: None, annotations: None, + execution: None, icons: None, meta: None, } @@ -164,6 +217,24 @@ impl Tool { } } + /// Set the execution configuration for this tool. + pub fn with_execution(self, execution: ToolExecution) -> Self { + Tool { + execution: Some(execution), + ..self + } + } + + /// Returns the task support mode for this tool. + /// + /// Returns `TaskSupport::Forbidden` if not explicitly set. + pub fn task_support(&self) -> TaskSupport { + self.execution + .as_ref() + .and_then(|e| e.task_support) + .unwrap_or_default() + } + /// Set the output schema using a type that implements JsonSchema /// /// # Panics diff --git a/crates/rmcp/tests/test_deserialization/tool_list_result.json b/crates/rmcp/tests/test_deserialization/tool_list_result.json index 674fdc058..38f7b34f5 100644 --- a/crates/rmcp/tests/test_deserialization/tool_list_result.json +++ b/crates/rmcp/tests/test_deserialization/tool_list_result.json @@ -19,6 +19,9 @@ ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" + }, + "execution": { + "taskSupport": "optional" } } ] diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index e23aa330a..f92177701 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -2731,6 +2731,26 @@ } ] }, + "TaskSupport": { + "description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).", + "oneOf": [ + { + "description": "Clients MUST NOT invoke this tool as a task (default behavior).", + "type": "string", + "const": "forbidden" + }, + { + "description": "Clients MAY invoke this tool as either a task or a normal call.", + "type": "string", + "const": "optional" + }, + { + "description": "Clients MUST invoke this tool as a task.", + "type": "string", + "const": "required" + } + ] + }, "TasksCapability": { "description": "Task capabilities shared by client and server.", "type": "object", @@ -2896,6 +2916,17 @@ "null" ] }, + "execution": { + "description": "Execution-related configuration including task support mode.", + "anyOf": [ + { + "$ref": "#/definitions/ToolExecution" + }, + { + "type": "null" + } + ] + }, "icons": { "description": "Optional list of icons for the tool", "type": [ @@ -2977,6 +3008,23 @@ } } }, + "ToolExecution": { + "description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.", + "type": "object", + "properties": { + "taskSupport": { + "description": "Indicates whether this tool supports task-based invocation.\n\nWhen not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.\nWhen set to `Optional`, clients MAY invoke this tool as a task or normal call.\nWhen set to `Required`, clients MUST invoke this tool as a task.", + "anyOf": [ + { + "$ref": "#/definitions/TaskSupport" + }, + { + "type": "null" + } + ] + } + } + }, "ToolListChangedNotificationMethod": { "type": "string", "format": "const", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index e23aa330a..f92177701 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -2731,6 +2731,26 @@ } ] }, + "TaskSupport": { + "description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).", + "oneOf": [ + { + "description": "Clients MUST NOT invoke this tool as a task (default behavior).", + "type": "string", + "const": "forbidden" + }, + { + "description": "Clients MAY invoke this tool as either a task or a normal call.", + "type": "string", + "const": "optional" + }, + { + "description": "Clients MUST invoke this tool as a task.", + "type": "string", + "const": "required" + } + ] + }, "TasksCapability": { "description": "Task capabilities shared by client and server.", "type": "object", @@ -2896,6 +2916,17 @@ "null" ] }, + "execution": { + "description": "Execution-related configuration including task support mode.", + "anyOf": [ + { + "$ref": "#/definitions/ToolExecution" + }, + { + "type": "null" + } + ] + }, "icons": { "description": "Optional list of icons for the tool", "type": [ @@ -2977,6 +3008,23 @@ } } }, + "ToolExecution": { + "description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.", + "type": "object", + "properties": { + "taskSupport": { + "description": "Indicates whether this tool supports task-based invocation.\n\nWhen not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.\nWhen set to `Optional`, clients MAY invoke this tool as a task or normal call.\nWhen set to `Required`, clients MUST invoke this tool as a task.", + "anyOf": [ + { + "$ref": "#/definitions/TaskSupport" + }, + { + "type": "null" + } + ] + } + } + }, "ToolListChangedNotificationMethod": { "type": "string", "format": "const", diff --git a/crates/rmcp/tests/test_task_support_validation.rs b/crates/rmcp/tests/test_task_support_validation.rs new file mode 100644 index 000000000..016ed2403 --- /dev/null +++ b/crates/rmcp/tests/test_task_support_validation.rs @@ -0,0 +1,269 @@ +//! Tests for task support validation in tool calls. +//! +//! Verifies that the server correctly validates `execution.taskSupport` settings +//! per the MCP specification: +//! - `Required`: MUST be invoked as a task, returns -32601 otherwise +//! - `Forbidden`: MUST NOT be invoked as a task, returns error otherwise +//! - `Optional`: MAY be invoked either way + +use rmcp::{ + ClientHandler, ServerHandler, ServiceError, ServiceExt, + handler::server::router::tool::ToolRouter, + model::{CallToolRequestParams, ClientInfo, ErrorCode, JsonObject}, + tool, tool_handler, tool_router, +}; +use serde_json::json; + +/// Server with tools having different task support modes. +#[derive(Debug, Clone)] +pub struct TaskSupportTestServer { + tool_router: ToolRouter, +} + +impl Default for TaskSupportTestServer { + fn default() -> Self { + Self::new() + } +} + +impl TaskSupportTestServer { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[tool_router] +impl TaskSupportTestServer { + #[tool( + description = "Tool that requires task-based invocation", + execution(task_support = "required") + )] + async fn required_task_tool(&self) -> String { + "required task executed".to_string() + } + + #[tool( + description = "Tool that forbids task-based invocation", + execution(task_support = "forbidden") + )] + async fn forbidden_task_tool(&self) -> String { + "forbidden task executed".to_string() + } + + #[tool( + description = "Tool that optionally supports task-based invocation", + execution(task_support = "optional") + )] + async fn optional_task_tool(&self) -> String { + "optional task executed".to_string() + } +} + +#[tool_handler] +impl ServerHandler for TaskSupportTestServer {} + +#[derive(Debug, Clone, Default)] +struct DummyClientHandler {} + +impl ClientHandler for DummyClientHandler { + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +/// Helper to create a task object for tool calls +fn make_task() -> Option { + Some(json!({}).as_object().unwrap().clone()) +} + +#[tokio::test] +async fn test_required_task_tool_without_task_returns_method_not_found() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server = TaskSupportTestServer::new(); + let server_handle = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + let client_handler = DummyClientHandler::default(); + let client = client_handler.serve(client_transport).await?; + + // Call the task-required tool without a task - should fail with -32601 + let result = client + .call_tool(CallToolRequestParams { + meta: None, + name: "required_task_tool".into(), + arguments: None, + task: None, // No task provided! + }) + .await; + + // Should be an error with code -32601 (METHOD_NOT_FOUND) + assert!( + result.is_err(), + "Expected error for required task tool without task" + ); + let error = result.unwrap_err(); + + // Check the error data contains the expected code + match error { + ServiceError::McpError(error_data) => { + assert_eq!( + error_data.code, + ErrorCode::METHOD_NOT_FOUND, + "Expected METHOD_NOT_FOUND error code (-32601)" + ); + assert!( + error_data + .message + .contains("requires task-based invocation"), + "Error message should indicate task-based invocation is required, got: {}", + error_data.message + ); + } + _ => panic!("Expected McpError variant, got: {:?}", error), + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_forbidden_task_tool_with_task_returns_error() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server = TaskSupportTestServer::new(); + let server_handle = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + let client_handler = DummyClientHandler::default(); + let client = client_handler.serve(client_transport).await?; + + // Call the forbidden task tool WITH a task - should fail + let result = client + .call_tool(CallToolRequestParams { + meta: None, + name: "forbidden_task_tool".into(), + arguments: None, + task: make_task(), // Task provided but forbidden! + }) + .await; + + // Should be an error with code INVALID_PARAMS + assert!( + result.is_err(), + "Expected error for forbidden task tool with task" + ); + let error = result.unwrap_err(); + + // Check the error data contains the expected code + match error { + ServiceError::McpError(error_data) => { + assert_eq!( + error_data.code, + ErrorCode::INVALID_PARAMS, + "Expected INVALID_PARAMS error code" + ); + assert!( + error_data + .message + .contains("does not support task-based invocation"), + "Error message should indicate task-based invocation is not supported, got: {}", + error_data.message + ); + } + _ => panic!("Expected McpError variant, got: {:?}", error), + } + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_forbidden_task_tool_without_task_succeeds() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server = TaskSupportTestServer::new(); + let server_handle = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + let client_handler = DummyClientHandler::default(); + let client = client_handler.serve(client_transport).await?; + + // Call the forbidden task tool WITHOUT a task - should succeed + let result = client + .call_tool(CallToolRequestParams { + meta: None, + name: "forbidden_task_tool".into(), + arguments: None, + task: None, // No task - allowed for forbidden + }) + .await; + + assert!( + result.is_ok(), + "Forbidden task tool without task should succeed" + ); + let result = result.unwrap(); + let text = result + .content + .first() + .and_then(|c| c.raw.as_text()) + .map(|t| t.text.as_str()) + .unwrap_or(""); + assert_eq!(text, "forbidden task executed"); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +#[tokio::test] +async fn test_optional_task_tool_without_task_succeeds() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server = TaskSupportTestServer::new(); + let server_handle = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + let client_handler = DummyClientHandler::default(); + let client = client_handler.serve(client_transport).await?; + + // Call the optional task tool WITHOUT a task - should succeed + let result = client + .call_tool(CallToolRequestParams { + meta: None, + name: "optional_task_tool".into(), + arguments: None, + task: None, // No task - allowed for optional + }) + .await; + + assert!( + result.is_ok(), + "Optional task tool without task should succeed" + ); + let result = result.unwrap(); + let text = result + .content + .first() + .and_then(|c| c.raw.as_text()) + .map(|t| t.text.as_str()) + .unwrap_or(""); + assert_eq!(text, "optional task executed"); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 29198bb85..67aac2490 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -124,6 +124,7 @@ impl ServerHandler for SamplingDemoServer { ), output_schema: None, annotations: None, + execution: None, icons: None, meta: None, }], From 53b64a9f87c43251804476fe369a07a76fc4aa55 Mon Sep 17 00:00:00 2001 From: Evgenii Date: Wed, 4 Feb 2026 01:21:06 +0100 Subject: [PATCH 023/333] fix: compilation with --no-default-features (#593) --- crates/rmcp/Cargo.toml | 2 +- crates/rmcp/src/error.rs | 4 ++-- crates/rmcp/src/lib.rs | 4 ++-- crates/rmcp/src/model/prompt.rs | 9 +++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index b9c0baa24..9829f4f4f 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -77,7 +77,7 @@ chrono = { version = "0.4.38", default-features = false, features = [ [features] default = ["base64", "macros", "server"] client = ["dep:tokio-stream"] -server = ["transport-async-rw", "dep:schemars"] +server = ["transport-async-rw", "dep:schemars", "dep:pastey"] macros = ["dep:rmcp-macros", "dep:pastey"] elicitation = [] diff --git a/crates/rmcp/src/error.rs b/crates/rmcp/src/error.rs index 51f60acb6..c7901f4b5 100644 --- a/crates/rmcp/src/error.rs +++ b/crates/rmcp/src/error.rs @@ -1,6 +1,5 @@ use std::{borrow::Cow, fmt::Display}; -use crate::ServiceError; pub use crate::model::ErrorData; #[deprecated( note = "Use `rmcp::ErrorData` instead, `rmcp::ErrorData` could become `RmcpError` in the future." @@ -22,8 +21,9 @@ impl std::error::Error for ErrorData {} #[derive(Debug, thiserror::Error)] #[allow(clippy::large_enum_variant)] pub enum RmcpError { + #[cfg(any(feature = "client", feature = "server"))] #[error("Service error: {0}")] - Service(#[from] ServiceError), + Service(#[from] crate::ServiceError), #[cfg(feature = "client")] #[error("Client initialization error: {0}")] ClientInitialize(#[from] crate::service::ClientInitializeError), diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 29d21b2d8..7a2ea49f3 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -41,8 +41,8 @@ pub use pastey::paste; #[cfg(all(feature = "macros", feature = "server"))] #[cfg_attr(docsrs, doc(cfg(all(feature = "macros", feature = "server"))))] pub use rmcp_macros::*; -#[cfg(all(feature = "macros", feature = "server"))] -#[cfg_attr(docsrs, doc(cfg(all(feature = "macros", feature = "server"))))] +#[cfg(any(feature = "macros", feature = "server"))] +#[cfg_attr(docsrs, doc(cfg(any(feature = "macros", feature = "server"))))] pub use schemars; #[cfg(feature = "macros")] #[cfg_attr(docsrs, doc(cfg(feature = "macros")))] diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index bb63938f8..f90aff199 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -1,8 +1,7 @@ -use base64::engine::{Engine, general_purpose::STANDARD as BASE64_STANDARD}; use serde::{Deserialize, Serialize}; use super::{ - AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource, RawImageContent, + AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource, content::{EmbeddedResource, ImageContent}, resource::ResourceContents, }; @@ -138,11 +137,13 @@ impl PromptMessage { meta: Option, annotations: Option, ) -> Self { + use base64::{Engine, prelude::BASE64_STANDARD}; + let base64 = BASE64_STANDARD.encode(data); Self { role, content: PromptMessageContent::Image { - image: RawImageContent { + image: crate::model::RawImageContent { data: base64, mime_type: mime_type.into(), meta, @@ -215,7 +216,7 @@ mod tests { #[test] fn test_prompt_message_image_serialization() { - let image_content = RawImageContent { + let image_content = crate::model::RawImageContent { data: "base64data".to_string(), mime_type: "image/png".to_string(), meta: None, From bfd9cc08d8b616ed3750b33f91d8efb4e3f1ec33 Mon Sep 17 00:00:00 2001 From: Rodolfo Olivieri Date: Tue, 3 Feb 2026 21:27:37 -0300 Subject: [PATCH 024/333] feat: add native-tls as an optional TLS backend (#631) Add reqwest-native-tls feature flag to allow users to choose between rustls (default) and native-tls for HTTP transports. native-tls uses platform-native TLS implementations: - OpenSSL on Linux - Secure Transport on macOS - SChannel on Windows This is particularly useful for Linux distribution packagers who need to link against system TLS libraries (e.g., OpenSSL) rather than bundling a separate TLS implementation. Linking against system libs ensures security updates are applied system-wide and satisfies distribution packaging policies. Updated documentation to explain the available TLS backend options. --- crates/rmcp/Cargo.toml | 2 ++ crates/rmcp/README.md | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 9829f4f4f..6f07f7d3c 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -88,6 +88,8 @@ reqwest = ["__reqwest", "reqwest?/rustls-tls"] reqwest-tls-no-provider = ["__reqwest", "reqwest?/rustls-tls-no-provider"] +reqwest-native-tls = ["__reqwest", "reqwest?/native-tls"] + server-side-http = [ "uuid", "dep:rand", diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index ed366a39d..54b27b910 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -270,6 +270,10 @@ RMCP uses feature flags to control which components are included: - `transport-streamable-http-client-reqwest`: a default `reqwest` implementation of the streamable http client - `auth`: OAuth2 authentication support - `schemars`: JSON Schema generation (for tool definitions) +- TLS backend options (for HTTP transports): + - `reqwest`: Uses rustls (pure Rust TLS, recommended default) + - `reqwest-native-tls`: Uses platform native TLS (OpenSSL on Linux, Secure Transport on macOS, SChannel on Windows) + - `reqwest-tls-no-provider`: Uses rustls without a default crypto provider (bring your own) ## Transports From f6ebc7af13efb74cb52dbf1c70fe1371f53631c5 Mon Sep 17 00:00:00 2001 From: Guy Lichtman <1395797+glicht@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:57:52 +0200 Subject: [PATCH 025/333] fix(auth): oauth metadata discovery (#641) * fix(auth): oauth metadata discovery * fix: format auth.rs --- crates/rmcp/src/transport/auth.rs | 39 ++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 6a5567f48..de2cf5e9d 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -807,6 +807,8 @@ impl AuthorizationManager { push_candidate(format!("/.well-known/openid-configuration/{trimmed}")); // 3. OpenID Connect with path appending push_candidate(format!("/{trimmed}/.well-known/openid-configuration")); + // 4. Canonical OAuth fallback (without path suffix) + push_candidate("/.well-known/oauth-authorization-server".to_string()); } candidates @@ -1605,7 +1607,7 @@ mod tests { // Test URL with single path segment: follow spec priority order let base_url = Url::parse("https://auth.example.com/tenant1").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 3); + assert_eq!(urls.len(), 4); assert_eq!( urls[0].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server/tenant1" @@ -1618,11 +1620,15 @@ mod tests { urls[2].as_str(), "https://auth.example.com/tenant1/.well-known/openid-configuration" ); + assert_eq!( + urls[3].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server" + ); // Test URL with path and trailing slash let base_url = Url::parse("https://auth.example.com/v1/mcp/").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 3); + assert_eq!(urls.len(), 4); assert_eq!( urls[0].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server/v1/mcp" @@ -1635,11 +1641,15 @@ mod tests { urls[2].as_str(), "https://auth.example.com/v1/mcp/.well-known/openid-configuration" ); + assert_eq!( + urls[3].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server" + ); // Test URL with multiple path segments let base_url = Url::parse("https://auth.example.com/tenant1/subtenant").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 3); + assert_eq!(urls.len(), 4); assert_eq!( urls[0].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server/tenant1/subtenant" @@ -1652,6 +1662,10 @@ mod tests { urls[2].as_str(), "https://auth.example.com/tenant1/subtenant/.well-known/openid-configuration" ); + assert_eq!( + urls[3].as_str(), + "https://auth.example.com/.well-known/oauth-authorization-server" + ); } // StateStore and StoredAuthorizationState tests @@ -1786,6 +1800,25 @@ mod tests { } } + #[test] + fn test_discovery_urls_with_path_suffix() { + // When the base URL has a path suffix (e.g., /mcp), the discovery should + // eventually fall back to checking /.well-known/oauth-authorization-server + // at the root, not just /.well-known/oauth-authorization-server/{path}. + let base_url = Url::parse("https://mcp.example.com/mcp").unwrap(); + let urls = AuthorizationManager::generate_discovery_urls(&base_url); + + let canonical_oauth_fallback = + "https://mcp.example.com/.well-known/oauth-authorization-server"; + + assert!( + urls.iter().any(|u| u.as_str() == canonical_oauth_fallback), + "Expected discovery URLs to include canonical OAuth fallback '{}', but got: {:?}", + canonical_oauth_fallback, + urls.iter().map(|u| u.as_str()).collect::>() + ); + } + #[tokio::test] async fn test_custom_state_store_with_authorization_manager() { use std::sync::atomic::{AtomicUsize, Ordering}; From be23334f9d8a7550fb73dde70efc324372903849 Mon Sep 17 00:00:00 2001 From: Jiho Park <73594851+coinmoles@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:00:09 +0900 Subject: [PATCH 026/333] fix(tasks): avoid dropping completed task results during collection (#639) * fix(tasks): avoid dropping completed task results during collection * chore(tasks): make `task_result_receiver` required * refactor(tasks): make `collect_completed_results` private --- crates/rmcp-macros/src/task_handler.rs | 3 --- crates/rmcp/src/task_manager.rs | 31 +++++++++++++++----------- crates/rmcp/tests/test_task.rs | 2 +- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index f94cf1303..09d43f96e 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -132,7 +132,6 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result syn::Result Result<(), McpError> { let task_id = request.task_id; let mut processor = (#processor).lock().await; - processor.collect_completed_results(); if processor.cancel_task(&task_id) { return Ok(()); diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index d87689021..774c542f8 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -80,7 +80,7 @@ pub struct OperationProcessor { running_tasks: HashMap, /// Completed results waiting to be collected completed_results: Vec, - task_result_receiver: Option>, + task_result_receiver: mpsc::UnboundedReceiver, task_result_sender: mpsc::UnboundedSender, } @@ -138,7 +138,7 @@ impl OperationProcessor { Self { running_tasks: HashMap::new(), completed_results: Vec::new(), - task_result_receiver: Some(task_result_receiver), + task_result_receiver, task_result_sender, } } @@ -195,18 +195,16 @@ impl OperationProcessor { } /// Collect completed results from running tasks and remove them from the running tasks map. - pub fn collect_completed_results(&mut self) -> Vec { - if let Some(receiver) = &mut self.task_result_receiver { - while let Ok(result) = receiver.try_recv() { - self.running_tasks.remove(&result.descriptor.operation_id); - self.completed_results.push(result); - } + fn collect_completed_results(&mut self) { + while let Ok(result) = self.task_result_receiver.try_recv() { + self.running_tasks.remove(&result.descriptor.operation_id); + self.completed_results.push(result); } - std::mem::take(&mut self.completed_results) } /// Check for tasks that have exceeded their timeout and handle them appropriately. pub fn check_timeouts(&mut self) { + self.collect_completed_results(); let now = std::time::Instant::now(); let mut timed_out_tasks = Vec::new(); @@ -231,7 +229,8 @@ impl OperationProcessor { } /// Get the number of running tasks. - pub fn running_task_count(&self) -> usize { + pub fn running_task_count(&mut self) -> usize { + self.collect_completed_results(); self.running_tasks.len() } @@ -240,15 +239,19 @@ impl OperationProcessor { for (_, task) in self.running_tasks.drain() { task.task_handle.abort(); } + while self.task_result_receiver.try_recv().is_ok() {} self.completed_results.clear(); } + /// List running task ids. - pub fn list_running(&self) -> Vec { + pub fn list_running(&mut self) -> Vec { + self.collect_completed_results(); self.running_tasks.keys().cloned().collect() } - /// Note: collectors should call collect_completed_results; this provides a snapshot of queued results. - pub fn peek_completed(&self) -> &[TaskResult] { + /// Returns a snapshot of completed task results. + pub fn peek_completed(&mut self) -> &[TaskResult] { + self.collect_completed_results(); &self.completed_results } @@ -266,6 +269,7 @@ impl OperationProcessor { /// Attempt to cancel a running task. pub fn cancel_task(&mut self, task_id: &str) -> bool { + self.collect_completed_results(); if let Some(task) = self.running_tasks.remove(task_id) { task.task_handle.abort(); // Insert a cancelled result so callers can observe the terminal state. @@ -281,6 +285,7 @@ impl OperationProcessor { /// Retrieve a completed task result if available. pub fn take_completed_result(&mut self, task_id: &str) -> Option { + self.collect_completed_results(); if let Some(position) = self .completed_results .iter() diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 31fc9a9b4..9ad0b2006 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -36,7 +36,7 @@ async fn executes_enqueued_future() { .expect("submit operation"); tokio::time::sleep(Duration::from_millis(30)).await; - let results = processor.collect_completed_results(); + let results = processor.peek_completed(); assert_eq!(results.len(), 1); let payload = results[0] .result From 8bd3fcb890f87e4b8fb78d029a1c46f58ab16b07 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 6 Feb 2026 06:51:26 -0500 Subject: [PATCH 027/333] Implement SEP-1577: Sampling With Tools (#628) * feat: implement SEP-1577 sampling with tools support * feat: add TryFrom for backward-compatible migration --- crates/rmcp/src/model.rs | 289 ++++++++++++- crates/rmcp/src/model/capabilities.rs | 38 +- crates/rmcp/src/model/content.rs | 131 ++++++ crates/rmcp/tests/common/handlers.rs | 5 +- crates/rmcp/tests/test_message_protocol.rs | 175 ++++---- .../client_json_rpc_message_schema.json | 230 ++++++++++- ...lient_json_rpc_message_schema_current.json | 230 ++++++++++- .../server_json_rpc_message_schema.json | 249 +++++++++++- ...erver_json_rpc_message_schema_current.json | 249 +++++++++++- crates/rmcp/tests/test_sampling.rs | 380 ++++++++++++++++-- examples/clients/src/sampling_stdio.rs | 5 +- examples/servers/src/sampling_stdio.rs | 10 +- 12 files changed, 1850 insertions(+), 141 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 3c86fbbdc..db6e927eb 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1209,6 +1209,152 @@ pub enum Role { Assistant, } +/// Tool selection mode (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum ToolChoiceMode { + /// Model decides whether to use tools + Auto, + /// Model must use at least one tool + Required, + /// Model must not use tools + None, +} + +impl Default for ToolChoiceMode { + fn default() -> Self { + Self::Auto + } +} + +/// Tool choice configuration (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ToolChoice { + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, +} + +impl ToolChoice { + pub fn auto() -> Self { + Self { + mode: Some(ToolChoiceMode::Auto), + } + } + + pub fn required() -> Self { + Self { + mode: Some(ToolChoiceMode::Required), + } + } + + pub fn none() -> Self { + Self { + mode: Some(ToolChoiceMode::None), + } + } +} + +/// Single or array content wrapper (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum SamplingContent { + Single(T), + Multiple(Vec), +} + +impl SamplingContent { + /// Convert to a Vec regardless of whether it's single or multiple + pub fn into_vec(self) -> Vec { + match self { + SamplingContent::Single(item) => vec![item], + SamplingContent::Multiple(items) => items, + } + } + + /// Check if the content is empty + pub fn is_empty(&self) -> bool { + match self { + SamplingContent::Single(_) => false, + SamplingContent::Multiple(items) => items.is_empty(), + } + } + + /// Get the number of content items + pub fn len(&self) -> usize { + match self { + SamplingContent::Single(_) => 1, + SamplingContent::Multiple(items) => items.len(), + } + } +} + +impl Default for SamplingContent { + fn default() -> Self { + SamplingContent::Multiple(Vec::new()) + } +} + +impl SamplingContent { + /// Get the first item if present + pub fn first(&self) -> Option<&T> { + match self { + SamplingContent::Single(item) => Some(item), + SamplingContent::Multiple(items) => items.first(), + } + } + + /// Iterate over all content items + pub fn iter(&self) -> impl Iterator { + let items: Vec<&T> = match self { + SamplingContent::Single(item) => vec![item], + SamplingContent::Multiple(items) => items.iter().collect(), + }; + items.into_iter() + } +} + +impl SamplingMessageContent { + /// Get the text content if this is a Text variant + pub fn as_text(&self) -> Option<&RawTextContent> { + match self { + SamplingMessageContent::Text(text) => Some(text), + _ => None, + } + } + + /// Get the tool use content if this is a ToolUse variant + pub fn as_tool_use(&self) -> Option<&ToolUseContent> { + match self { + SamplingMessageContent::ToolUse(tool_use) => Some(tool_use), + _ => None, + } + } + + /// Get the tool result content if this is a ToolResult variant + pub fn as_tool_result(&self) -> Option<&ToolResultContent> { + match self { + SamplingMessageContent::ToolResult(tool_result) => Some(tool_result), + _ => None, + } + } +} + +impl From for SamplingContent { + fn from(item: T) -> Self { + SamplingContent::Single(item) + } +} + +impl From> for SamplingContent { + fn from(items: Vec) -> Self { + SamplingContent::Multiple(items) + } +} + /// A message in a sampling conversation, containing a role and content. /// /// This represents a single message in a conversation flow, used primarily @@ -1219,8 +1365,135 @@ pub enum Role { pub struct SamplingMessage { /// The role of the message sender (User or Assistant) pub role: Role, - /// The actual content of the message (text, image, etc.) - pub content: Content, + /// The actual content of the message (text, image, audio, tool use, or tool result) + pub content: SamplingContent, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Content types for sampling messages (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum SamplingMessageContent { + Text(RawTextContent), + Image(RawImageContent), + Audio(RawAudioContent), + /// Assistant only + ToolUse(ToolUseContent), + /// User only + ToolResult(ToolResultContent), +} + +impl SamplingMessageContent { + /// Create a text content + pub fn text(text: impl Into) -> Self { + Self::Text(RawTextContent { + text: text.into(), + meta: None, + }) + } + + pub fn tool_use(id: impl Into, name: impl Into, input: JsonObject) -> Self { + Self::ToolUse(ToolUseContent::new(id, name, input)) + } + + pub fn tool_result(tool_use_id: impl Into, content: Vec) -> Self { + Self::ToolResult(ToolResultContent::new(tool_use_id, content)) + } +} + +impl SamplingMessage { + pub fn new(role: Role, content: impl Into) -> Self { + Self { + role, + content: SamplingContent::Single(content.into()), + meta: None, + } + } + + pub fn new_multiple(role: Role, contents: Vec) -> Self { + Self { + role, + content: SamplingContent::Multiple(contents), + meta: None, + } + } + + pub fn user_text(text: impl Into) -> Self { + Self::new(Role::User, SamplingMessageContent::text(text)) + } + + pub fn assistant_text(text: impl Into) -> Self { + Self::new(Role::Assistant, SamplingMessageContent::text(text)) + } + + pub fn user_tool_result(tool_use_id: impl Into, content: Vec) -> Self { + Self::new( + Role::User, + SamplingMessageContent::tool_result(tool_use_id, content), + ) + } + + pub fn assistant_tool_use( + id: impl Into, + name: impl Into, + input: JsonObject, + ) -> Self { + Self::new( + Role::Assistant, + SamplingMessageContent::tool_use(id, name, input), + ) + } +} + +// Conversion from RawTextContent to SamplingMessageContent +impl From for SamplingMessageContent { + fn from(text: RawTextContent) -> Self { + SamplingMessageContent::Text(text) + } +} + +// Conversion from String to SamplingMessageContent (as text) +impl From for SamplingMessageContent { + fn from(text: String) -> Self { + SamplingMessageContent::text(text) + } +} + +impl From<&str> for SamplingMessageContent { + fn from(text: &str) -> Self { + SamplingMessageContent::text(text) + } +} + +// Backward compatibility: Convert Content to SamplingMessageContent +// Note: Resource and ResourceLink variants are not supported in sampling messages +impl TryFrom for SamplingMessageContent { + type Error = &'static str; + + fn try_from(content: Content) -> Result { + match content.raw { + RawContent::Text(text) => Ok(SamplingMessageContent::Text(text)), + RawContent::Image(image) => Ok(SamplingMessageContent::Image(image)), + RawContent::Audio(audio) => Ok(SamplingMessageContent::Audio(audio)), + RawContent::Resource(_) => { + Err("Resource content is not supported in sampling messages") + } + RawContent::ResourceLink(_) => { + Err("ResourceLink content is not supported in sampling messages") + } + } + } +} + +// Backward compatibility: Convert Content to SamplingContent +impl TryFrom for SamplingContent { + type Error = &'static str; + + fn try_from(content: Content) -> Result { + Ok(SamplingContent::Single(content.try_into()?)) + } } /// Specifies how much context should be included in sampling requests. @@ -1281,6 +1554,12 @@ pub struct CreateMessageRequestParams { /// Additional metadata for the request #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, + /// Tools available for the model to call (SEP-1577) + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + /// Tool selection behavior (SEP-1577) + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, } impl RequestParamsMeta for CreateMessageRequestParams { @@ -1926,6 +2205,7 @@ pub type CallToolRequestParam = CallToolRequestParams; /// Request to call a specific tool pub type CallToolRequest = Request; +/// Result of sampling/createMessage (SEP-1577). /// The result of a sampling/createMessage request containing the generated response. /// /// This structure contains the generated message along with metadata about @@ -1948,6 +2228,7 @@ impl CreateMessageResult { pub const STOP_REASON_END_TURN: &str = "endTurn"; pub const STOP_REASON_END_SEQUENCE: &str = "stopSequence"; pub const STOP_REASON_END_MAX_TOKEN: &str = "maxTokens"; + pub const STOP_REASON_TOOL_USE: &str = "toolUse"; } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] @@ -2477,7 +2758,9 @@ mod tests { .. }) => { assert_eq!(capabilities.roots.unwrap().list_changed, Some(true)); - assert_eq!(capabilities.sampling.unwrap().len(), 0); + let sampling = capabilities.sampling.unwrap(); + assert_eq!(sampling.tools, None); + assert_eq!(sampling.context, None); assert_eq!(client_info.name, "ExampleClient"); assert_eq!(client_info.version, "1.0.0"); } diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index c82557511..6f4e0648a 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -194,6 +194,19 @@ pub struct ElicitationCapability { pub schema_validation: Option, } +/// Sampling capability with optional sub-capabilities (SEP-1577). +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct SamplingCapability { + /// Support for `tools` and `toolChoice` parameters + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + /// Support for `includeContext` (soft-deprecated) + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, +} + /// /// # Builder /// ```rust @@ -217,8 +230,9 @@ pub struct ClientCapabilities { pub extensions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub roots: Option, + /// Capability for LLM sampling requests (SEP-1577) #[serde(skip_serializing_if = "Option::is_none")] - pub sampling: Option, + pub sampling: Option, /// Capability to handle elicitation requests from servers for interactive user input #[serde(skip_serializing_if = "Option::is_none")] pub elicitation: Option, @@ -449,7 +463,7 @@ builder! { experimental: ExperimentalCapabilities, extensions: ExtensionCapabilities, roots: RootsCapabilities, - sampling: JsonObject, + sampling: SamplingCapability, elicitation: ElicitationCapability, tasks: TasksCapability, } @@ -466,6 +480,26 @@ impl + ClientCapabilitiesBuilder> +{ + /// Enable tool calling in sampling requests + pub fn enable_sampling_tools(mut self) -> Self { + if let Some(c) = self.sampling.as_mut() { + c.tools = Some(JsonObject::default()); + } + self + } + + /// Enable context inclusion in sampling (soft-deprecated) + pub fn enable_sampling_context(mut self) -> Self { + if let Some(c) = self.sampling.as_mut() { + c.context = Some(JsonObject::default()); + } + self + } +} + #[cfg(feature = "elicitation")] impl ClientCapabilitiesBuilder> diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index fb82053d5..297bc751e 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -59,6 +59,137 @@ pub struct RawAudioContent { pub type AudioContent = Annotated; +/// Tool call request from assistant (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ToolUseContent { + /// Unique identifier for this tool call + pub id: String, + /// Name of the tool to call + pub name: String, + /// Input arguments for the tool + pub input: super::JsonObject, + /// Optional metadata (preserved for caching) + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Tool execution result in user message (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ToolResultContent { + /// Optional metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// ID of the corresponding tool use + pub tool_use_id: String, + /// Content blocks returned by the tool + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, + /// Optional structured result + #[serde(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, + /// Whether tool execution failed + #[serde(skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +impl ToolUseContent { + pub fn new(id: impl Into, name: impl Into, input: super::JsonObject) -> Self { + Self { + id: id.into(), + name: name.into(), + input, + meta: None, + } + } +} + +impl ToolResultContent { + pub fn new(tool_use_id: impl Into, content: Vec) -> Self { + Self { + meta: None, + tool_use_id: tool_use_id.into(), + content, + structured_content: None, + is_error: None, + } + } + + pub fn error(tool_use_id: impl Into, content: Vec) -> Self { + Self { + meta: None, + tool_use_id: tool_use_id.into(), + content, + structured_content: None, + is_error: Some(true), + } + } +} + +/// Assistant message content types (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum AssistantMessageContent { + Text(RawTextContent), + Image(RawImageContent), + Audio(RawAudioContent), + ToolUse(ToolUseContent), +} + +/// User message content types (SEP-1577). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum UserMessageContent { + Text(RawTextContent), + Image(RawImageContent), + Audio(RawAudioContent), + ToolResult(ToolResultContent), +} + +impl AssistantMessageContent { + /// Create a text content + pub fn text(text: impl Into) -> Self { + Self::Text(RawTextContent { + text: text.into(), + meta: None, + }) + } + + /// Create a tool use content + pub fn tool_use( + id: impl Into, + name: impl Into, + input: super::JsonObject, + ) -> Self { + Self::ToolUse(ToolUseContent::new(id, name, input)) + } +} + +impl UserMessageContent { + /// Create a text content + pub fn text(text: impl Into) -> Self { + Self::Text(RawTextContent { + text: text.into(), + meta: None, + }) + } + + /// Create a tool result content + pub fn tool_result(tool_use_id: impl Into, content: Vec) -> Self { + Self::ToolResult(ToolResultContent::new(tool_use_id, content)) + } + + /// Create an error tool result content + pub fn tool_result_error(tool_use_id: impl Into, content: Vec) -> Self { + Self::ToolResult(ToolResultContent::error(tool_use_id, content)) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index 373d278c0..654413fa8 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -72,10 +72,7 @@ impl ClientHandler for TestClientHandler { }; Ok(CreateMessageResult { - message: SamplingMessage { - role: Role::Assistant, - content: Content::text(response.to_string()), - }, + message: SamplingMessage::assistant_text(response.to_string()), model: "test-model".to_string(), stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), }) diff --git a/crates/rmcp/tests/test_message_protocol.rs b/crates/rmcp/tests/test_message_protocol.rs index b2851b5a1..7ec3258c0 100644 --- a/crates/rmcp/tests/test_message_protocol.rs +++ b/crates/rmcp/tests/test_message_protocol.rs @@ -13,14 +13,8 @@ use tokio_util::sync::CancellationToken; #[tokio::test] async fn test_message_roles() { let messages = vec![ - SamplingMessage { - role: Role::User, - content: Content::text("user message"), - }, - SamplingMessage { - role: Role::Assistant, - content: Content::text("assistant message"), - }, + SamplingMessage::user_text("user message"), + SamplingMessage::assistant_text("assistant message"), ]; // Verify all roles can be serialized/deserialized correctly @@ -50,10 +44,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("test message"), - }], + messages: vec![SamplingMessage::user_text("test message")], include_context: Some(ContextInclusion::ThisServer), model_preferences: None, system_prompt: None, @@ -61,6 +52,8 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -79,7 +72,15 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { .await?; if let ClientResult::CreateMessageResult(result) = result { - let text = result.message.content.as_text().unwrap().text.as_str(); + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( text.contains("test context"), "Response should include context for ThisServer" @@ -94,10 +95,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("test message"), - }], + messages: vec![SamplingMessage::user_text("test message")], include_context: Some(ContextInclusion::AllServers), model_preferences: None, system_prompt: None, @@ -105,6 +103,8 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -123,7 +123,15 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { .await?; if let ClientResult::CreateMessageResult(result) = result { - let text = result.message.content.as_text().unwrap().text.as_str(); + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( text.contains("test context"), "Response should include context for AllServers" @@ -138,10 +146,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("test message"), - }], + messages: vec![SamplingMessage::user_text("test message")], include_context: Some(ContextInclusion::None), model_preferences: None, system_prompt: None, @@ -149,6 +154,8 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -167,7 +174,15 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { .await?; if let ClientResult::CreateMessageResult(result) = result { - let text = result.message.content.as_text().unwrap().text.as_str(); + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( !text.contains("test context"), "Response should not include context for None" @@ -202,10 +217,7 @@ async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("test message"), - }], + messages: vec![SamplingMessage::user_text("test message")], include_context: Some(ContextInclusion::ThisServer), model_preferences: None, system_prompt: None, @@ -213,6 +225,8 @@ async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -231,7 +245,15 @@ async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> { .await?; if let ClientResult::CreateMessageResult(result) = result { - let text = result.message.content.as_text().unwrap().text.as_str(); + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( !text.contains("test context"), "Context should be ignored when client chooses not to honor requests" @@ -266,14 +288,8 @@ async fn test_message_sequence_integration() -> anyhow::Result<()> { meta: None, task: None, messages: vec![ - SamplingMessage { - role: Role::User, - content: Content::text("first message"), - }, - SamplingMessage { - role: Role::Assistant, - content: Content::text("second message"), - }, + SamplingMessage::user_text("first message"), + SamplingMessage::assistant_text("second message"), ], include_context: Some(ContextInclusion::ThisServer), model_preferences: None, @@ -282,6 +298,8 @@ async fn test_message_sequence_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -300,7 +318,15 @@ async fn test_message_sequence_integration() -> anyhow::Result<()> { .await?; if let ClientResult::CreateMessageResult(result) = result { - let text = result.message.content.as_text().unwrap().text.as_str(); + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( text.contains("test context"), "Response should include context when ThisServer is specified" @@ -339,18 +365,9 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { meta: None, task: None, messages: vec![ - SamplingMessage { - role: Role::User, - content: Content::text("first user message"), - }, - SamplingMessage { - role: Role::Assistant, - content: Content::text("first assistant response"), - }, - SamplingMessage { - role: Role::User, - content: Content::text("second user message"), - }, + SamplingMessage::user_text("first user message"), + SamplingMessage::assistant_text("first assistant response"), + SamplingMessage::user_text("second user message"), ], include_context: None, model_preferences: None, @@ -359,6 +376,8 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -384,10 +403,7 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::Assistant, - content: Content::text("assistant message"), - }], + messages: vec![SamplingMessage::assistant_text("assistant message")], include_context: None, model_preferences: None, system_prompt: None, @@ -395,6 +411,8 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -439,10 +457,7 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("test message"), - }], + messages: vec![SamplingMessage::user_text("test message")], include_context: Some(ContextInclusion::ThisServer), model_preferences: None, system_prompt: None, @@ -450,6 +465,8 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -468,7 +485,15 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { .await?; if let ClientResult::CreateMessageResult(result) = result { - let text = result.message.content.as_text().unwrap().text.as_str(); + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( text.contains("test context"), "ThisServer context request should be honored" @@ -481,10 +506,7 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("test message"), - }], + messages: vec![SamplingMessage::user_text("test message")], include_context: Some(ContextInclusion::AllServers), model_preferences: None, system_prompt: None, @@ -492,6 +514,8 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -510,7 +534,15 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { .await?; if let ClientResult::CreateMessageResult(result) = result { - let text = result.message.content.as_text().unwrap().text.as_str(); + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( !text.contains("test context"), "AllServers context request should be ignored" @@ -540,10 +572,7 @@ async fn test_context_inclusion() -> anyhow::Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("test"), - }], + messages: vec![SamplingMessage::user_text("test")], include_context: Some(ContextInclusion::ThisServer), model_preferences: None, system_prompt: None, @@ -551,6 +580,8 @@ async fn test_context_inclusion() -> anyhow::Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -569,7 +600,15 @@ async fn test_context_inclusion() -> anyhow::Result<()> { .await?; if let ClientResult::CreateMessageResult(result) = result { - let text = result.message.content.as_text().unwrap().text.as_str(); + let text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!(text.contains("test context")); } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 4b6c24aa4..c9d7dab00 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -318,11 +318,15 @@ ] }, "sampling": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "description": "Capability for LLM sampling requests (SEP-1577)", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] }, "tasks": { "anyOf": [ @@ -431,14 +435,21 @@ ] }, "CreateMessageResult": { - "description": "The result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.", + "description": "Result of sampling/createMessage (SEP-1577).\nThe result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "content": { - "description": "The actual content of the message (text, image, etc.)", + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", "allOf": [ { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/SamplingContent" } ] }, @@ -1741,6 +1752,134 @@ "format": "const", "const": "notifications/roots/list_changed" }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContent" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContent" + } + } + ] + }, + "SamplingMessageContent": { + "description": "Content types for sampling messages (SEP-1577).", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawTextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawAudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, "SamplingTaskCapability": { "type": "object", "properties": { @@ -1875,6 +2014,81 @@ } } }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "content": { + "description": "Content blocks returned by the tool", + "type": "array", + "items": { + "$ref": "#/definitions/Annotated" + } + }, + "isError": { + "description": "Whether tool execution failed", + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": { + "description": "Optional structured result", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "toolUseId": { + "description": "ID of the corresponding tool use", + "type": "string" + } + }, + "required": [ + "toolUseId" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional metadata (preserved for caching)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "id": { + "description": "Unique identifier for this tool call", + "type": "string" + }, + "input": { + "description": "Input arguments for the tool", + "type": "object", + "additionalProperties": true + }, + "name": { + "description": "Name of the tool to call", + "type": "string" + } + }, + "required": [ + "id", + "name", + "input" + ] + }, "ToolsTaskCapability": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 4b6c24aa4..c9d7dab00 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -318,11 +318,15 @@ ] }, "sampling": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "description": "Capability for LLM sampling requests (SEP-1577)", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] }, "tasks": { "anyOf": [ @@ -431,14 +435,21 @@ ] }, "CreateMessageResult": { - "description": "The result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.", + "description": "Result of sampling/createMessage (SEP-1577).\nThe result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "content": { - "description": "The actual content of the message (text, image, etc.)", + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", "allOf": [ { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/SamplingContent" } ] }, @@ -1741,6 +1752,134 @@ "format": "const", "const": "notifications/roots/list_changed" }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContent" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContent" + } + } + ] + }, + "SamplingMessageContent": { + "description": "Content types for sampling messages (SEP-1577).", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawTextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawAudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, "SamplingTaskCapability": { "type": "object", "properties": { @@ -1875,6 +2014,81 @@ } } }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "content": { + "description": "Content blocks returned by the tool", + "type": "array", + "items": { + "$ref": "#/definitions/Annotated" + } + }, + "isError": { + "description": "Whether tool execution failed", + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": { + "description": "Optional structured result", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "toolUseId": { + "description": "ID of the corresponding tool use", + "type": "string" + } + }, + "required": [ + "toolUseId" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional metadata (preserved for caching)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "id": { + "description": "Unique identifier for this tool call", + "type": "string" + }, + "input": { + "description": "Input arguments for the tool", + "type": "object", + "additionalProperties": true + }, + "name": { + "description": "Name of the tool to call", + "type": "string" + } + }, + "required": [ + "id", + "name", + "input" + ] + }, "ToolsTaskCapability": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index f92177701..7bd25060e 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -641,6 +641,27 @@ "null" ], "format": "float" + }, + "toolChoice": { + "description": "Tool selection behavior (SEP-1577)", + "anyOf": [ + { + "$ref": "#/definitions/ToolChoice" + }, + { + "type": "null" + } + ] + }, + "tools": { + "description": "Tools available for the model to call (SEP-1577)", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Tool" + } } }, "required": [ @@ -2309,15 +2330,36 @@ } ] }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContent" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContent" + } + } + ] + }, "SamplingMessage": { "description": "A message in a sampling conversation, containing a role and content.\n\nThis represents a single message in a conversation flow, used primarily\nin LLM sampling requests where the conversation history is important\nfor generating appropriate responses.", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "content": { - "description": "The actual content of the message (text, image, etc.)", + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", "allOf": [ { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/SamplingContent" } ] }, @@ -2335,6 +2377,98 @@ "content" ] }, + "SamplingMessageContent": { + "description": "Content types for sampling messages (SEP-1577).", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawTextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawAudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, "SamplingTaskCapability": { "type": "object", "properties": { @@ -3008,6 +3142,42 @@ } } }, + "ToolChoice": { + "description": "Tool choice configuration (SEP-1577).", + "type": "object", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ToolChoiceMode" + }, + { + "type": "null" + } + ] + } + } + }, + "ToolChoiceMode": { + "description": "Tool selection mode (SEP-1577).", + "oneOf": [ + { + "description": "Model decides whether to use tools", + "type": "string", + "const": "auto" + }, + { + "description": "Model must use at least one tool", + "type": "string", + "const": "required" + }, + { + "description": "Model must not use tools", + "type": "string", + "const": "none" + } + ] + }, "ToolExecution": { "description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.", "type": "object", @@ -3030,6 +3200,81 @@ "format": "const", "const": "notifications/tools/list_changed" }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "content": { + "description": "Content blocks returned by the tool", + "type": "array", + "items": { + "$ref": "#/definitions/Annotated" + } + }, + "isError": { + "description": "Whether tool execution failed", + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": { + "description": "Optional structured result", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "toolUseId": { + "description": "ID of the corresponding tool use", + "type": "string" + } + }, + "required": [ + "toolUseId" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional metadata (preserved for caching)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "id": { + "description": "Unique identifier for this tool call", + "type": "string" + }, + "input": { + "description": "Input arguments for the tool", + "type": "object", + "additionalProperties": true + }, + "name": { + "description": "Name of the tool to call", + "type": "string" + } + }, + "required": [ + "id", + "name", + "input" + ] + }, "ToolsCapability": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index f92177701..7bd25060e 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -641,6 +641,27 @@ "null" ], "format": "float" + }, + "toolChoice": { + "description": "Tool selection behavior (SEP-1577)", + "anyOf": [ + { + "$ref": "#/definitions/ToolChoice" + }, + { + "type": "null" + } + ] + }, + "tools": { + "description": "Tools available for the model to call (SEP-1577)", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Tool" + } } }, "required": [ @@ -2309,15 +2330,36 @@ } ] }, + "SamplingContent": { + "description": "Single or array content wrapper (SEP-1577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingMessageContent" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SamplingMessageContent" + } + } + ] + }, "SamplingMessage": { "description": "A message in a sampling conversation, containing a role and content.\n\nThis represents a single message in a conversation flow, used primarily\nin LLM sampling requests where the conversation history is important\nfor generating appropriate responses.", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "content": { - "description": "The actual content of the message (text, image, etc.)", + "description": "The actual content of the message (text, image, audio, tool use, or tool result)", "allOf": [ { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/SamplingContent" } ] }, @@ -2335,6 +2377,98 @@ "content" ] }, + "SamplingMessageContent": { + "description": "Content types for sampling messages (SEP-1577).", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawTextContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawImageContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/RawAudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "Assistant only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_use" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolUseContent" + } + ], + "required": [ + "type" + ] + }, + { + "description": "User only", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_result" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ToolResultContent" + } + ], + "required": [ + "type" + ] + } + ] + }, "SamplingTaskCapability": { "type": "object", "properties": { @@ -3008,6 +3142,42 @@ } } }, + "ToolChoice": { + "description": "Tool choice configuration (SEP-1577).", + "type": "object", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ToolChoiceMode" + }, + { + "type": "null" + } + ] + } + } + }, + "ToolChoiceMode": { + "description": "Tool selection mode (SEP-1577).", + "oneOf": [ + { + "description": "Model decides whether to use tools", + "type": "string", + "const": "auto" + }, + { + "description": "Model must use at least one tool", + "type": "string", + "const": "required" + }, + { + "description": "Model must not use tools", + "type": "string", + "const": "none" + } + ] + }, "ToolExecution": { "description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.", "type": "object", @@ -3030,6 +3200,81 @@ "format": "const", "const": "notifications/tools/list_changed" }, + "ToolResultContent": { + "description": "Tool execution result in user message (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "content": { + "description": "Content blocks returned by the tool", + "type": "array", + "items": { + "$ref": "#/definitions/Annotated" + } + }, + "isError": { + "description": "Whether tool execution failed", + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": { + "description": "Optional structured result", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "toolUseId": { + "description": "ID of the corresponding tool use", + "type": "string" + } + }, + "required": [ + "toolUseId" + ] + }, + "ToolUseContent": { + "description": "Tool call request from assistant (SEP-1577).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional metadata (preserved for caching)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "id": { + "description": "Unique identifier for this tool call", + "type": "string" + }, + "input": { + "description": "Input arguments for the tool", + "type": "object", + "additionalProperties": true + }, + "name": { + "description": "Name of the tool to call", + "type": "string" + } + }, + "required": [ + "id", + "name", + "input" + ] + }, "ToolsCapability": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 83a4325c2..9bedfb047 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -13,13 +13,8 @@ use tokio_util::sync::CancellationToken; #[tokio::test] async fn test_basic_sampling_message_creation() -> Result<()> { - // Test basic sampling message structure - let message = SamplingMessage { - role: Role::User, - content: Content::text("What is the capital of France?"), - }; + let message = SamplingMessage::user_text("What is the capital of France?"); - // Verify serialization/deserialization let json = serde_json::to_string(&message)?; let deserialized: SamplingMessage = serde_json::from_str(&json)?; assert_eq!(message, deserialized); @@ -30,14 +25,10 @@ async fn test_basic_sampling_message_creation() -> Result<()> { #[tokio::test] async fn test_sampling_request_params() -> Result<()> { - // Test sampling request parameters structure let params = CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("Hello, world!"), - }], + messages: vec![SamplingMessage::user_text("Hello, world!")], model_preferences: Some(ModelPreferences { hints: Some(vec![ModelHint { name: Some("claude".to_string()), @@ -52,14 +43,14 @@ async fn test_sampling_request_params() -> Result<()> { stop_sequences: Some(vec!["STOP".to_string()]), include_context: Some(ContextInclusion::None), metadata: Some(serde_json::json!({"test": "value"})), + tools: None, + tool_choice: None, }; - // Verify serialization/deserialization let json = serde_json::to_string(¶ms)?; let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; assert_eq!(params, deserialized); - // Verify specific fields assert_eq!(params.messages.len(), 1); assert_eq!(params.max_tokens, 100); assert_eq!(params.temperature, Some(0.7)); @@ -69,22 +60,16 @@ async fn test_sampling_request_params() -> Result<()> { #[tokio::test] async fn test_sampling_result_structure() -> Result<()> { - // Test sampling result structure let result = CreateMessageResult { - message: SamplingMessage { - role: Role::Assistant, - content: Content::text("The capital of France is Paris."), - }, + message: SamplingMessage::assistant_text("The capital of France is Paris."), model: "test-model".to_string(), stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), }; - // Verify serialization/deserialization let json = serde_json::to_string(&result)?; let deserialized: CreateMessageResult = serde_json::from_str(&json)?; assert_eq!(result, deserialized); - // Verify specific fields assert_eq!(result.message.role, Role::Assistant); assert_eq!(result.model, "test-model"); assert_eq!( @@ -97,7 +82,6 @@ async fn test_sampling_result_structure() -> Result<()> { #[tokio::test] async fn test_sampling_context_inclusion_enum() -> Result<()> { - // Test context inclusion enum values let test_cases = vec![ (ContextInclusion::None, "none"), (ContextInclusion::ThisServer, "thisServer"), @@ -139,10 +123,7 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("What is the capital of France?"), - }], + messages: vec![SamplingMessage::user_text("What is the capital of France?")], include_context: Some(ContextInclusion::ThisServer), model_preferences: Some(ModelPreferences { hints: Some(vec![ModelHint { @@ -157,6 +138,8 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> { max_tokens: 100, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -183,7 +166,15 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> { Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()) ); - let response_text = result.message.content.as_text().unwrap().text.as_str(); + let response_text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( response_text.contains("test context"), "Response should include context for ThisServer inclusion" @@ -221,10 +212,7 @@ async fn test_sampling_no_context_inclusion() -> Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text("Hello"), - }], + messages: vec![SamplingMessage::user_text("Hello")], include_context: Some(ContextInclusion::None), model_preferences: None, system_prompt: None, @@ -232,6 +220,8 @@ async fn test_sampling_no_context_inclusion() -> Result<()> { max_tokens: 50, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -254,7 +244,15 @@ async fn test_sampling_no_context_inclusion() -> Result<()> { assert_eq!(result.message.role, Role::Assistant); assert_eq!(result.model, "test-model"); - let response_text = result.message.content.as_text().unwrap().text.as_str(); + let response_text = result + .message + .content + .first() + .unwrap() + .as_text() + .unwrap() + .text + .as_str(); assert!( !response_text.contains("test context"), "Response should not include context for None inclusion" @@ -292,10 +290,9 @@ async fn test_sampling_error_invalid_message_sequence() -> Result<()> { params: CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::Assistant, - content: Content::text("I'm an assistant message without a user message"), - }], + messages: vec![SamplingMessage::assistant_text( + "I'm an assistant message without a user message", + )], include_context: Some(ContextInclusion::None), model_preferences: None, system_prompt: None, @@ -303,6 +300,8 @@ async fn test_sampling_error_invalid_message_sequence() -> Result<()> { max_tokens: 50, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }, extensions: Default::default(), }); @@ -327,3 +326,314 @@ async fn test_sampling_error_invalid_message_sequence() -> Result<()> { server_handle.await??; Ok(()) } + +#[tokio::test] +async fn test_tool_choice_serialization() -> Result<()> { + let auto = ToolChoice::auto(); + let json = serde_json::to_string(&auto)?; + assert!(json.contains("auto")); + let deserialized: ToolChoice = serde_json::from_str(&json)?; + assert_eq!(auto, deserialized); + + let required = ToolChoice::required(); + let json = serde_json::to_string(&required)?; + assert!(json.contains("required")); + let deserialized: ToolChoice = serde_json::from_str(&json)?; + assert_eq!(required, deserialized); + + let none = ToolChoice::none(); + let json = serde_json::to_string(&none)?; + assert!(json.contains("none")); + let deserialized: ToolChoice = serde_json::from_str(&json)?; + assert_eq!(none, deserialized); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_with_tools() -> Result<()> { + use std::sync::Arc; + + let tool = Tool::new( + "get_weather", + "Get the current weather for a location", + Arc::new( + serde_json::json!({ + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + } + }, + "required": ["location"] + }) + .as_object() + .unwrap() + .clone(), + ), + ); + + let params = CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::user_text( + "What's the weather in San Francisco?", + )], + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens: 100, + stop_sequences: None, + metadata: None, + tools: Some(vec![tool]), + tool_choice: Some(ToolChoice::auto()), + }; + + let json = serde_json::to_string(¶ms)?; + let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; + + assert!(deserialized.tools.is_some()); + assert_eq!(deserialized.tools.as_ref().unwrap().len(), 1); + assert_eq!(deserialized.tools.as_ref().unwrap()[0].name, "get_weather"); + assert!(deserialized.tool_choice.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_tool_use_content_serialization() -> Result<()> { + let tool_use = ToolUseContent::new( + "call_123", + "get_weather", + serde_json::json!({ + "location": "San Francisco, CA" + }) + .as_object() + .unwrap() + .clone(), + ); + + let json = serde_json::to_string(&tool_use)?; + let deserialized: ToolUseContent = serde_json::from_str(&json)?; + assert_eq!(tool_use, deserialized); + assert_eq!(deserialized.id, "call_123"); + assert_eq!(deserialized.name, "get_weather"); + + Ok(()) +} + +#[tokio::test] +async fn test_tool_result_content_serialization() -> Result<()> { + let tool_result = ToolResultContent::new( + "call_123", + vec![Content::text( + "The weather in San Francisco is 72°F and sunny.", + )], + ); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(tool_result, deserialized); + assert_eq!(deserialized.tool_use_id, "call_123"); + assert!(!deserialized.content.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_message_with_tool_use() -> Result<()> { + let message = SamplingMessage::assistant_tool_use( + "call_123", + "get_weather", + serde_json::json!({ + "location": "San Francisco, CA" + }) + .as_object() + .unwrap() + .clone(), + ); + + let json = serde_json::to_string(&message)?; + let deserialized: SamplingMessage = serde_json::from_str(&json)?; + assert_eq!(message, deserialized); + assert_eq!(deserialized.role, Role::Assistant); + + let tool_use = deserialized.content.first().unwrap().as_tool_use().unwrap(); + assert_eq!(tool_use.name, "get_weather"); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_message_with_tool_result() -> Result<()> { + let message = + SamplingMessage::user_tool_result("call_123", vec![Content::text("72°F and sunny")]); + + let json = serde_json::to_string(&message)?; + let deserialized: SamplingMessage = serde_json::from_str(&json)?; + assert_eq!(message, deserialized); + assert_eq!(deserialized.role, Role::User); + + let tool_result = deserialized + .content + .first() + .unwrap() + .as_tool_result() + .unwrap(); + assert_eq!(tool_result.tool_use_id, "call_123"); + + Ok(()) +} + +#[tokio::test] +async fn test_create_message_result_tool_use_stop_reason() -> Result<()> { + let result = CreateMessageResult { + message: SamplingMessage::assistant_tool_use( + "call_123", + "get_weather", + serde_json::json!({ + "location": "San Francisco" + }) + .as_object() + .unwrap() + .clone(), + ), + model: "test-model".to_string(), + stop_reason: Some(CreateMessageResult::STOP_REASON_TOOL_USE.to_string()), + }; + + let json = serde_json::to_string(&result)?; + let deserialized: CreateMessageResult = serde_json::from_str(&json)?; + assert_eq!(result, deserialized); + assert_eq!(deserialized.stop_reason, Some("toolUse".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn test_sampling_capability() -> Result<()> { + let cap = SamplingCapability { + tools: Some(JsonObject::default()), + context: None, + }; + + let json = serde_json::to_string(&cap)?; + let deserialized: SamplingCapability = serde_json::from_str(&json)?; + assert_eq!(cap, deserialized); + assert!(deserialized.tools.is_some()); + assert!(deserialized.context.is_none()); + + let client_cap = ClientCapabilities::builder() + .enable_sampling() + .enable_sampling_tools() + .build(); + + assert!(client_cap.sampling.is_some()); + assert!(client_cap.sampling.as_ref().unwrap().tools.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_backward_compat_sampling_message_deserialization() -> Result<()> { + let old_format_json = r#"{ + "role": "user", + "content": { + "type": "text", + "text": "Hello, world!" + } + }"#; + + let message: SamplingMessage = serde_json::from_str(old_format_json)?; + assert_eq!(message.role, Role::User); + let text = message.content.first().unwrap().as_text().unwrap(); + assert_eq!(text.text, "Hello, world!"); + + Ok(()) +} + +#[tokio::test] +async fn test_backward_compat_sampling_message_with_image() -> Result<()> { + let old_format_json = r#"{ + "role": "user", + "content": { + "type": "image", + "data": "base64data", + "mimeType": "image/png" + } + }"#; + + let message: SamplingMessage = serde_json::from_str(old_format_json)?; + assert_eq!(message.role, Role::User); + assert_eq!(message.content.len(), 1); + + Ok(()) +} + +#[tokio::test] +async fn test_backward_compat_sampling_capability_empty_object() -> Result<()> { + let empty_json = "{}"; + let cap: SamplingCapability = serde_json::from_str(empty_json)?; + assert!(cap.tools.is_none()); + assert!(cap.context.is_none()); + + let client_cap_json = r#"{"sampling": {}}"#; + let client_cap: ClientCapabilities = serde_json::from_str(client_cap_json)?; + assert!(client_cap.sampling.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_content_to_sampling_message_content_conversion() -> Result<()> { + use std::convert::TryInto; + + let content = Content::text("Hello"); + let sampling_content: SamplingMessageContent = + content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; + assert!(sampling_content.as_text().is_some()); + assert_eq!(sampling_content.as_text().unwrap().text, "Hello"); + + let content = Content::image("base64data", "image/png"); + let sampling_content: SamplingMessageContent = + content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; + assert!(matches!(sampling_content, SamplingMessageContent::Image(_))); + + Ok(()) +} + +#[tokio::test] +async fn test_content_to_sampling_content_conversion() -> Result<()> { + use std::convert::TryInto; + + let content = Content::text("Hello"); + let sampling_content: SamplingContent = + content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; + assert_eq!(sampling_content.len(), 1); + assert!(sampling_content.first().unwrap().as_text().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_content_conversion_unsupported_variants() { + use std::convert::TryInto; + + use rmcp::model::ResourceContents; + + let resource_content = Content::resource(ResourceContents::TextResourceContents { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: "test".to_string(), + meta: None, + }); + + let result: Result = resource_content.try_into(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Resource content is not supported in sampling messages" + ); +} diff --git a/examples/clients/src/sampling_stdio.rs b/examples/clients/src/sampling_stdio.rs index cdefad589..e2a7a6d51 100644 --- a/examples/clients/src/sampling_stdio.rs +++ b/examples/clients/src/sampling_stdio.rs @@ -41,10 +41,7 @@ impl ClientHandler for SamplingDemoClient { self.mock_llm_response(¶ms.messages, params.system_prompt.as_deref()); Ok(CreateMessageResult { - message: SamplingMessage { - role: Role::Assistant, - content: Content::text(response_text), - }, + message: SamplingMessage::assistant_text(response_text), model: "mock_llm".to_string(), stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), }) diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 67aac2490..297af9d03 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -51,10 +51,7 @@ impl ServerHandler for SamplingDemoServer { .create_message(CreateMessageRequestParams { meta: None, task: None, - messages: vec![SamplingMessage { - role: Role::User, - content: Content::text(question), - }], + messages: vec![SamplingMessage::user_text(question)], model_preferences: Some(ModelPreferences { hints: Some(vec![ModelHint { name: Some("claude".to_string()), @@ -69,6 +66,8 @@ impl ServerHandler for SamplingDemoServer { max_tokens: 150, stop_sequences: None, metadata: None, + tools: None, + tool_choice: None, }) .await .map_err(|e| { @@ -85,7 +84,8 @@ impl ServerHandler for SamplingDemoServer { response .message .content - .as_text() + .first() + .and_then(|c| c.as_text()) .map(|t| &t.text) .unwrap_or(&"No text response".to_string()) ))])) From edd5b1d7e90df408b249ce2ae2a90deb268c19f0 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:57:00 -0500 Subject: [PATCH 028/333] feat: enforce SEP-1577 MUST requirements for sampling with tools (#646) --- crates/rmcp/src/model.rs | 87 ++++++++++++ crates/rmcp/src/model/content.rs | 61 --------- crates/rmcp/src/service/server.rs | 27 ++++ crates/rmcp/tests/test_sampling.rs | 213 ++++++++++++++++++++++++++--- 4 files changed, 310 insertions(+), 78 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index db6e927eb..b15ccab3e 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1580,6 +1580,85 @@ impl TaskAugmentedRequestParamsMeta for CreateMessageRequestParams { } } +impl CreateMessageRequestParams { + /// Validate the sampling request parameters per SEP-1577 spec requirements. + /// + /// Checks: + /// - ToolUse content is only allowed in assistant messages + /// - ToolResult content is only allowed in user messages + /// - Messages with tool result content MUST NOT contain other content types + /// - Every assistant ToolUse must be balanced with a corresponding user ToolResult + pub fn validate(&self) -> Result<(), String> { + for msg in &self.messages { + for content in msg.content.iter() { + // ToolUse only in assistant messages, ToolResult only in user messages + match content { + SamplingMessageContent::ToolUse(_) if msg.role != Role::Assistant => { + return Err("ToolUse content is only allowed in assistant messages".into()); + } + SamplingMessageContent::ToolResult(_) if msg.role != Role::User => { + return Err("ToolResult content is only allowed in user messages".into()); + } + _ => {} + } + } + + // Tool result messages MUST NOT contain other content types + let contents: Vec<_> = msg.content.iter().collect(); + let has_tool_result = contents + .iter() + .any(|c| matches!(c, SamplingMessageContent::ToolResult(_))); + if has_tool_result + && contents + .iter() + .any(|c| !matches!(c, SamplingMessageContent::ToolResult(_))) + { + return Err( + "SamplingMessage with tool result content MUST NOT contain other content types" + .into(), + ); + } + } + + // Every assistant ToolUse must be balanced with a user ToolResult + self.validate_tool_use_result_balance()?; + + Ok(()) + } + + fn validate_tool_use_result_balance(&self) -> Result<(), String> { + let mut pending_tool_use_ids: Vec = Vec::new(); + for msg in &self.messages { + if msg.role == Role::Assistant { + for content in msg.content.iter() { + if let SamplingMessageContent::ToolUse(tu) = content { + pending_tool_use_ids.push(tu.id.clone()); + } + } + } else if msg.role == Role::User { + for content in msg.content.iter() { + if let SamplingMessageContent::ToolResult(tr) = content { + if !pending_tool_use_ids.contains(&tr.tool_use_id) { + return Err(format!( + "ToolResult with toolUseId '{}' has no matching ToolUse", + tr.tool_use_id + )); + } + pending_tool_use_ids.retain(|id| id != &tr.tool_use_id); + } + } + } + } + if !pending_tool_use_ids.is_empty() { + return Err(format!( + "ToolUse with id(s) {:?} not balanced with ToolResult", + pending_tool_use_ids + )); + } + Ok(()) + } +} + /// Deprecated: Use [`CreateMessageRequestParams`] instead (SEP-1319 compliance). #[deprecated(since = "0.13.0", note = "Use CreateMessageRequestParams instead")] pub type CreateMessageRequestParam = CreateMessageRequestParams; @@ -2229,6 +2308,14 @@ impl CreateMessageResult { pub const STOP_REASON_END_SEQUENCE: &str = "stopSequence"; pub const STOP_REASON_END_MAX_TOKEN: &str = "maxTokens"; pub const STOP_REASON_TOOL_USE: &str = "toolUse"; + + /// Validate the result per SEP-1577: role must be "assistant". + pub fn validate(&self) -> Result<(), String> { + if self.message.role != Role::Assistant { + return Err("CreateMessageResult role must be 'assistant'".into()); + } + Ok(()) + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index 297bc751e..beb4d9f5d 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -129,67 +129,6 @@ impl ToolResultContent { } } -/// Assistant message content types (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub enum AssistantMessageContent { - Text(RawTextContent), - Image(RawImageContent), - Audio(RawAudioContent), - ToolUse(ToolUseContent), -} - -/// User message content types (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub enum UserMessageContent { - Text(RawTextContent), - Image(RawImageContent), - Audio(RawAudioContent), - ToolResult(ToolResultContent), -} - -impl AssistantMessageContent { - /// Create a text content - pub fn text(text: impl Into) -> Self { - Self::Text(RawTextContent { - text: text.into(), - meta: None, - }) - } - - /// Create a tool use content - pub fn tool_use( - id: impl Into, - name: impl Into, - input: super::JsonObject, - ) -> Self { - Self::ToolUse(ToolUseContent::new(id, name, input)) - } -} - -impl UserMessageContent { - /// Create a text content - pub fn text(text: impl Into) -> Self { - Self::Text(RawTextContent { - text: text.into(), - meta: None, - }) - } - - /// Create a tool result content - pub fn tool_result(tool_use_id: impl Into, content: Vec) -> Self { - Self::ToolResult(ToolResultContent::new(tool_use_id, content)) - } - - /// Create an error tool result content - pub fn tool_result_error(tool_use_id: impl Into, content: Vec) -> Self { - Self::ToolResult(ToolResultContent::error(tool_use_id, content)) - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 1ba578b7b..eeb880c00 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -384,10 +384,37 @@ macro_rules! method { } impl Peer { + /// Check if the client supports sampling tools capability. + pub fn supports_sampling_tools(&self) -> bool { + if let Some(client_info) = self.peer_info() { + client_info + .capabilities + .sampling + .as_ref() + .and_then(|s| s.tools.as_ref()) + .is_some() + } else { + false + } + } + pub async fn create_message( &self, params: CreateMessageRequestParams, ) -> Result { + // MUST throw error when tools/toolChoice provided without capability + if (params.tools.is_some() || params.tool_choice.is_some()) + && !self.supports_sampling_tools() + { + return Err(ServiceError::McpError(ErrorData::invalid_params( + "tools or toolChoice provided but client does not support sampling tools capability", + None, + ))); + } + // Validate message structure + params + .validate() + .map_err(|e| ServiceError::McpError(ErrorData::invalid_params(e, None)))?; let result = self .send_request(ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 9bedfb047..e5191d3c1 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -1,5 +1,3 @@ -//cargo test --test test_sampling --features "client server" - mod common; use anyhow::Result; @@ -103,21 +101,17 @@ async fn test_sampling_context_inclusion_enum() -> Result<()> { async fn test_sampling_integration_with_test_handlers() -> Result<()> { let (server_transport, client_transport) = tokio::io::duplex(4096); - // Start server let server_handle = tokio::spawn(async move { let server = TestServer::new().serve(server_transport).await?; server.waiting().await?; anyhow::Ok(()) }); - // Start client that honors sampling requests let handler = TestClientHandler::new(true, true); let client = handler.clone().serve(client_transport).await?; - // Wait for initialization tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - // Test sampling with context inclusion let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), params: CreateMessageRequestParams { @@ -157,7 +151,6 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> { ) .await?; - // Verify the response if let ClientResult::CreateMessageResult(result) = result { assert_eq!(result.message.role, Role::Assistant); assert_eq!(result.model, "test-model"); @@ -192,21 +185,17 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> { async fn test_sampling_no_context_inclusion() -> Result<()> { let (server_transport, client_transport) = tokio::io::duplex(4096); - // Start server let server_handle = tokio::spawn(async move { let server = TestServer::new().serve(server_transport).await?; server.waiting().await?; anyhow::Ok(()) }); - // Start client that honors sampling requests let handler = TestClientHandler::new(true, true); let client = handler.clone().serve(client_transport).await?; - // Wait for initialization tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - // Test sampling without context inclusion let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), params: CreateMessageRequestParams { @@ -239,7 +228,6 @@ async fn test_sampling_no_context_inclusion() -> Result<()> { ) .await?; - // Verify the response if let ClientResult::CreateMessageResult(result) = result { assert_eq!(result.message.role, Role::Assistant); assert_eq!(result.model, "test-model"); @@ -270,21 +258,17 @@ async fn test_sampling_no_context_inclusion() -> Result<()> { async fn test_sampling_error_invalid_message_sequence() -> Result<()> { let (server_transport, client_transport) = tokio::io::duplex(4096); - // Start server let server_handle = tokio::spawn(async move { let server = TestServer::new().serve(server_transport).await?; server.waiting().await?; anyhow::Ok(()) }); - // Start client let handler = TestClientHandler::new(true, true); let client = handler.clone().serve(client_transport).await?; - // Wait for initialization tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - // Test sampling with no user messages (should fail) let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { method: Default::default(), params: CreateMessageRequestParams { @@ -319,7 +303,6 @@ async fn test_sampling_error_invalid_message_sequence() -> Result<()> { ) .await; - // This should result in an error assert!(result.is_err()); client.cancel().await?; @@ -637,3 +620,199 @@ async fn test_content_conversion_unsupported_variants() { "Resource content is not supported in sampling messages" ); } + +#[tokio::test] +async fn test_validate_rejects_tool_use_in_user_message() { + let params = CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::new( + Role::User, + SamplingMessageContent::tool_use("call_1", "some_tool", Default::default()), + )], + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens: 100, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + }; + + let err = params.validate().unwrap_err(); + assert!( + err.contains("ToolUse content is only allowed in assistant messages"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_rejects_tool_result_in_assistant_message() { + let params = CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::new( + Role::Assistant, + SamplingMessageContent::tool_result("call_1", vec![Content::text("result")]), + )], + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens: 100, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + }; + + let err = params.validate().unwrap_err(); + assert!( + err.contains("ToolResult content is only allowed in user messages"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_rejects_mixed_content_with_tool_result() { + let params = CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::new_multiple( + Role::User, + vec![ + SamplingMessageContent::tool_result("call_1", vec![Content::text("result")]), + SamplingMessageContent::text("some extra text"), + ], + )], + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens: 100, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + }; + + let err = params.validate().unwrap_err(); + assert!( + err.contains("MUST NOT contain other content types"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_rejects_unbalanced_tool_use_result() { + let params = CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![ + SamplingMessage::user_text("Hello"), + SamplingMessage::assistant_tool_use("call_1", "some_tool", Default::default()), + ], + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens: 100, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + }; + + let err = params.validate().unwrap_err(); + assert!( + err.contains("not balanced with ToolResult"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_rejects_tool_result_without_matching_use() { + let params = CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![ + SamplingMessage::user_text("Hello"), + SamplingMessage::user_tool_result("nonexistent_call", vec![Content::text("result")]), + ], + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens: 100, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + }; + + let err = params.validate().unwrap_err(); + assert!( + err.contains("has no matching ToolUse"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_validate_accepts_valid_tool_conversation() { + let params = CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![ + SamplingMessage::user_text("What's the weather?"), + SamplingMessage::assistant_tool_use( + "call_1", + "get_weather", + serde_json::json!({"location": "SF"}) + .as_object() + .unwrap() + .clone(), + ), + SamplingMessage::user_tool_result("call_1", vec![Content::text("72°F and sunny")]), + SamplingMessage::assistant_text("It's 72°F and sunny in SF."), + ], + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens: 100, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + }; + + assert!(params.validate().is_ok()); +} + +#[tokio::test] +async fn test_create_message_result_validate_rejects_user_role() { + let result = CreateMessageResult { + message: SamplingMessage::user_text("This should not be a user message"), + model: "test-model".to_string(), + stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), + }; + + let err = result.validate().unwrap_err(); + assert!( + err.contains("role must be 'assistant'"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_create_message_result_validate_accepts_assistant_role() { + let result = CreateMessageResult { + message: SamplingMessage::assistant_text("Hello!"), + model: "test-model".to_string(), + stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), + }; + + assert!(result.validate().is_ok()); +} From 187597bf7e95499b1741f44e904e013ffb309a43 Mon Sep 17 00:00:00 2001 From: Pavel Bezglasny Date: Sun, 8 Feb 2026 03:39:30 +0100 Subject: [PATCH 029/333] feat(elicitation): add support URL elicitation. SEP-1036 (#605) --- crates/rmcp/Cargo.toml | 2 +- crates/rmcp/src/handler/client.rs | 50 + crates/rmcp/src/model.rs | 304 ++++++- crates/rmcp/src/model/capabilities.rs | 31 +- crates/rmcp/src/model/meta.rs | 1 + crates/rmcp/src/service/server.rs | 163 +++- crates/rmcp/tests/test_elicitation.rs | 858 ++++++++++++++---- .../client_json_rpc_message_schema.json | 44 +- ...lient_json_rpc_message_schema_current.json | 44 +- .../server_json_rpc_message_schema.json | 132 ++- ...erver_json_rpc_message_schema_current.json | 132 ++- examples/servers/src/elicitation_stdio.rs | 43 + 12 files changed, 1533 insertions(+), 271 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 6f07f7d3c..cdcdddbf7 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -79,7 +79,7 @@ default = ["base64", "macros", "server"] client = ["dep:tokio-stream"] server = ["transport-async-rw", "dep:schemars", "dep:pastey"] macros = ["dep:rmcp-macros", "dep:pastey"] -elicitation = [] +elicitation = ["dep:url"] # reqwest http client __reqwest = ["dep:reqwest"] diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 86539b87b..eeb79309e 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -62,6 +62,10 @@ impl Service for H { ServerNotification::PromptListChangedNotification(_notification_no_param) => { self.on_prompt_list_changed(context).await } + ServerNotification::ElicitationCompletionNotification(notification) => { + self.on_url_elicitation_notification_complete(notification.params, context) + .await + } ServerNotification::CustomNotification(notification) => { self.on_custom_notification(notification, context).await } @@ -116,6 +120,44 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { /// # Default Behavior /// The default implementation automatically declines all elicitation requests. /// Real clients should override this to provide user interaction. + /// + /// # Example + /// ```rust,ignore + /// use rmcp::model::CreateElicitationRequestParam; + /// use rmcp::{ + /// model::ErrorData as McpError, + /// model::*, + /// service::{NotificationContext, RequestContext, RoleClient, Service, ServiceRole}, + /// }; + /// use rmcp::ClientHandler; + /// + /// impl ClientHandler for MyClient { + /// async fn create_elicitation( + /// &self, + /// request: CreateElicitationRequestParam, + /// context: RequestContext, + /// ) -> Result { + /// match request { + /// CreateElicitationRequestParam::FormElicitationParam {meta, message, requested_schema,} => { + /// // Display message to user and collect input according to requested_schema + /// let user_input = get_user_input(message, requested_schema).await?; + /// Ok(CreateElicitationResult { + /// action: ElicitationAction::Accept, + /// content: Some(user_input), + /// }) + /// } + /// CreateElicitationRequestParam::UrlElicitationParam {meta, message, url, elicitation_id,} => { + /// // Open URL in browser for user to complete elicitation + /// open_url_in_browser(url).await?; + /// Ok(CreateElicitationResult { + /// action: ElicitationAction::Accept, + /// content: None, + /// }) + /// } + /// } + /// } + /// } + /// ``` fn create_elicitation( &self, request: CreateElicitationRequestParams, @@ -189,6 +231,14 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { ) -> impl Future + Send + '_ { std::future::ready(()) } + + fn on_url_elicitation_notification_complete( + &self, + params: ElicitationResponseNotificationParam, + context: NotificationContext, + ) -> impl Future + Send + '_ { + std::future::ready(()) + } fn on_custom_notification( &self, notification: CustomNotification, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index b15ccab3e..16ccc0a69 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -453,6 +453,7 @@ impl ErrorCode { pub const INVALID_PARAMS: Self = Self(-32602); pub const INTERNAL_ERROR: Self = Self(-32603); pub const PARSE_ERROR: Self = Self(-32700); + pub const URL_ELICITATION_REQUIRED: Self = Self(-32042); } /// Error information for JSON-RPC error responses. @@ -504,6 +505,12 @@ impl ErrorData { pub fn internal_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::INTERNAL_ERROR, message, data) } + pub fn url_elicitation_required( + message: impl Into>, + data: Option, + ) -> Self { + Self::new(ErrorCode::URL_ELICITATION_REQUIRED, message, data) + } } /// Represents any JSON-RPC message that can be sent or received. @@ -1970,6 +1977,7 @@ pub type RootsListChangedNotification = NotificationNoParam, + message: String, + requested_schema: ElicitationSchema, + }, + #[serde(rename = "url", rename_all = "camelCase")] + UrlElicitationParam { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + message: String, + url: String, + elicitation_id: String, + }, + #[serde(untagged, rename_all = "camelCase")] + FormElicitationParamBackwardsCompat { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + message: String, + requested_schema: ElicitationSchema, + }, +} + +impl TryFrom for CreateElicitationRequestParams { + type Error = serde_json::Error; + + fn try_from( + value: CreateElicitationRequestParamDeserializeHelper, + ) -> Result { + match value { + CreateElicitationRequestParamDeserializeHelper::FormElicitationParam { + meta, + message, + requested_schema, + } + | CreateElicitationRequestParamDeserializeHelper::FormElicitationParamBackwardsCompat { + meta, + message, + requested_schema, + } => Ok(CreateElicitationRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + }), + CreateElicitationRequestParamDeserializeHelper::UrlElicitationParam { + meta, + message, + url, + elicitation_id, + } => Ok(CreateElicitationRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + }), + } + } +} + /// Parameters for creating an elicitation request to gather user input. /// /// This structure contains everything needed to request interactive input from a user: @@ -1996,12 +2070,12 @@ pub enum ElicitationAction { /// - A type-safe schema defining the expected structure of the response /// /// # Example -/// +/// 1. Form-based elicitation request /// ```rust /// use rmcp::model::*; /// -/// let params = CreateElicitationRequestParams { -/// meta: None, +/// let params = CreateElicitationRequestParams::FormElicitationParams { +/// meta: None, /// message: "Please provide your email".to_string(), /// requested_schema: ElicitationSchema::builder() /// .required_email("email") @@ -2009,31 +2083,68 @@ pub enum ElicitationAction { /// .unwrap(), /// }; /// ``` +/// 2. URL-based elicitation request +/// ```rust +/// use rmcp::model::*; +/// let params = CreateElicitationRequestParams::UrlElicitationParams { +/// meta: None, +/// message: "Please provide your feedback at the following URL".to_string(), +/// url: "https://example.com/feedback".to_string(), +/// elicitation_id: "unique-id-123".to_string(), +/// }; +/// ``` #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct CreateElicitationRequestParams { - /// Protocol-level metadata for this request (SEP-1319) - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - - /// Human-readable message explaining what input is needed from the user. - /// This should be clear and provide sufficient context for the user to understand - /// what information they need to provide. - pub message: String, - - /// Type-safe schema defining the expected structure and validation rules for the user's response. - /// This enforces the MCP 2025-06-18 specification that elicitation schemas must be objects - /// with primitive-typed properties. - pub requested_schema: ElicitationSchema, +#[serde( + tag = "mode", + try_from = "CreateElicitationRequestParamDeserializeHelper" +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum CreateElicitationRequestParams { + #[serde(rename = "form", rename_all = "camelCase")] + FormElicitationParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + /// Human-readable message explaining what input is needed from the user. + /// This should be clear and provide sufficient context for the user to understand + /// what information they need to provide. + message: String, + + /// Type-safe schema defining the expected structure and validation rules for the user's response. + /// This enforces the MCP 2025-06-18 specification that elicitation schemas must be objects + /// with primitive-typed properties. + requested_schema: ElicitationSchema, + }, + #[serde(rename = "url", rename_all = "camelCase")] + UrlElicitationParams { + /// Protocol-level metadata for this request (SEP-1319) + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + meta: Option, + /// Human-readable message explaining what input is needed from the user. + /// This should be clear and provide sufficient context for the user to understand + /// what information they need to provide. + message: String, + + /// The URL where the user can provide the requested information. + /// The client should direct the user to this URL to complete the elicitation. + url: String, + /// The unique identifier for this elicitation request. + elicitation_id: String, + }, } impl RequestParamsMeta for CreateElicitationRequestParams { fn meta(&self) -> Option<&Meta> { - self.meta.as_ref() + match self { + CreateElicitationRequestParams::FormElicitationParams { meta, .. } => meta.as_ref(), + CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta.as_ref(), + } } fn meta_mut(&mut self) -> &mut Option { - &mut self.meta + match self { + CreateElicitationRequestParams::FormElicitationParams { meta, .. } => meta, + CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta, + } } } @@ -2063,6 +2174,18 @@ pub struct CreateElicitationResult { pub type CreateElicitationRequest = Request; +/// Notification parameters for an url elicitation completion notification. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ElicitationResponseNotificationParam { + pub elicitation_id: String, +} + +/// Notification sent when an url elicitation process is completed. +pub type ElicitationCompletionNotification = + Notification; + // ============================================================================= // TOOL EXECUTION RESULTS // ============================================================================= @@ -2575,6 +2698,7 @@ ts_union!( | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification + | ElicitationCompletionNotification | CustomNotification; ); @@ -3080,4 +3204,142 @@ mod tests { assert_eq!(json["serverInfo"]["icons"][0]["sizes"][0], "48x48"); assert_eq!(json["serverInfo"]["websiteUrl"], "https://docs.example.com"); } + + #[test] + fn test_elicitation_deserialization_untagged() { + // Test deserialization without the "type" field (should default to FormElicitationParam) + let json_data_without_tag = json!({ + "message": "Please provide more details.", + "requestedSchema": { + "title": "User Details", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name", "age"] + } + }); + let elicitation: CreateElicitationRequestParams = + serde_json::from_value(json_data_without_tag).expect("Deserialization failed"); + if let CreateElicitationRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + } = elicitation + { + assert_eq!(meta, None); + assert_eq!(message, "Please provide more details."); + assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); + assert_eq!(requested_schema.type_, ObjectTypeConst); + } else { + panic!("Expected FormElicitationParam"); + } + } + + #[test] + fn test_elicitation_deserialization() { + let json_data_form = json!({ + "_meta": { "meta_form_key_1": "meta form value 1" }, + "mode": "form", + "message": "Please provide more details.", + "requestedSchema": { + "title": "User Details", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name", "age"] + } + }); + let elicitation_form: CreateElicitationRequestParams = + serde_json::from_value(json_data_form).expect("Deserialization failed"); + if let CreateElicitationRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + } = elicitation_form + { + assert_eq!( + meta, + Some(Meta(object!({ "meta_form_key_1": "meta form value 1" }))) + ); + assert_eq!(message, "Please provide more details."); + assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); + assert_eq!(requested_schema.type_, ObjectTypeConst); + } else { + panic!("Expected FormElicitationParam"); + } + + let json_data_url = json!({ + "_meta": { "meta_url_key_1": "meta url value 1" }, + "mode": "url", + "message": "Please fill out the form at the following URL.", + "url": "https://example.com/form", + "elicitationId": "elicitation-123" + }); + let elicitation_url: CreateElicitationRequestParams = + serde_json::from_value(json_data_url).expect("Deserialization failed"); + if let CreateElicitationRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + } = elicitation_url + { + assert_eq!( + meta, + Some(Meta(object!({ "meta_url_key_1": "meta url value 1" }))) + ); + assert_eq!(message, "Please fill out the form at the following URL."); + assert_eq!(url, "https://example.com/form"); + assert_eq!(elicitation_id, "elicitation-123"); + } else { + panic!("Expected UrlElicitationParam"); + } + } + + #[test] + fn test_elicitation_serialization() { + let form_elicitation = CreateElicitationRequestParams::FormElicitationParams { + meta: Some(Meta(object!({ "meta_form_key_1": "meta form value 1" }))), + message: "Please provide more details.".to_string(), + requested_schema: ElicitationSchema::builder() + .title("User Details") + .string_property("name", |s| s) + .build() + .expect("Valid schema"), + }; + let json_form = serde_json::to_value(&form_elicitation).expect("Serialization failed"); + let expected_form_json = json!({ + "_meta": { "meta_form_key_1": "meta form value 1" }, + "mode": "form", + "message": "Please provide more details.", + "requestedSchema": { + "title":"User Details", + "type":"object", + "properties":{ + "name": { "type": "string" }, + }, + } + }); + assert_eq!(json_form, expected_form_json); + + let url_elicitation = CreateElicitationRequestParams::UrlElicitationParams { + meta: Some(Meta(object!({ "meta_url_key_1": "meta url value 1" }))), + message: "Please fill out the form at the following URL.".to_string(), + url: "https://example.com/form".to_string(), + elicitation_id: "elicitation-123".to_string(), + }; + let json_url = serde_json::to_value(&url_elicitation).expect("Serialization failed"); + let expected_url_json = json!({ + "_meta": { "meta_url_key_1": "meta url value 1" }, + "mode": "url", + "message": "Please fill out the form at the following URL.", + "url": "https://example.com/form", + "elicitationId": "elicitation-123" + }); + assert_eq!(json_url, expected_url_json); + } } diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 6f4e0648a..d0f8e1b2e 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -179,14 +179,15 @@ impl TasksCapability { } /// Capability for handling elicitation requests from servers. -/// /// Elicitation allows servers to request interactive input from users during tool execution. /// This capability indicates that a client can handle elicitation requests and present /// appropriate UI to users for collecting the requested information. +/// +/// Capability for form mode elicitation. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct ElicitationCapability { +pub struct FormElicitationCapability { /// Whether the client supports JSON Schema validation for elicitation responses. /// When true, the client will validate user input against the requested_schema /// before sending the response back to the server. @@ -194,6 +195,26 @@ pub struct ElicitationCapability { pub schema_validation: Option, } +/// Capability for URL mode elicitation. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct UrlElicitationCapability {} + +/// Elicitation allows servers to request interactive input from users during tool execution. +/// This capability indicates that a client can handle elicitation requests and present +/// appropriate UI to users for collecting the requested information. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ElicitationCapability { + /// Whether client supports form-based elicitation. + #[serde(skip_serializing_if = "Option::is_none")] + pub form: Option, + /// Whether client supports URL-based elicitation. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + /// Sampling capability with optional sub-capabilities (SEP-1577). #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] @@ -504,12 +525,14 @@ impl ClientCapabilitiesBuilder> { - /// Enable JSON Schema validation for elicitation responses. + /// Enable JSON Schema validation for elicitation responses in form mode. /// When enabled, the client will validate user input against the requested_schema /// before sending responses back to the server. pub fn enable_elicitation_schema_validation(mut self) -> Self { if let Some(c) = self.elicitation.as_mut() { - c.schema_validation = Some(true); + c.form = Some(FormElicitationCapability { + schema_validation: Some(true), + }); } self } diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index c979318da..c60762a35 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -188,6 +188,7 @@ variant_extension! { ResourceListChangedNotification ToolListChangedNotification PromptListChangedNotification + ElicitationCompletionNotification CustomNotification } } diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index eeb880c00..5f54f3dcd 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -1,11 +1,16 @@ use std::borrow::Cow; +#[cfg(feature = "elicitation")] +use std::collections::HashSet; use thiserror::Error; +#[cfg(feature = "elicitation")] +use url::Url; use super::*; #[cfg(feature = "elicitation")] use crate::model::{ CreateElicitationRequest, CreateElicitationRequestParams, CreateElicitationResult, + ElicitationAction, ElicitationCompletionNotification, ElicitationResponseNotificationParam, }; use crate::{ model::{ @@ -432,6 +437,8 @@ impl Peer { method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult); #[cfg(feature = "elicitation")] method!(peer_req_with_timeout create_elicitation_with_timeout CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult); + #[cfg(feature = "elicitation")] + method!(peer_not notify_url_elicitation_completed ElicitationCompletionNotification(ElicitationResponseNotificationParam)); method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); @@ -536,6 +543,12 @@ macro_rules! elicit_safe { }; } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ElicitationMode { + Form, + Url, +} + #[cfg(feature = "elicitation")] impl Peer { /// Check if the client supports elicitation capability @@ -543,11 +556,27 @@ impl Peer { /// Returns true if the client declared elicitation capability during initialization, /// false otherwise. According to MCP 2025-06-18 specification, clients that support /// elicitation MUST declare the capability during initialization. - pub fn supports_elicitation(&self) -> bool { + pub fn supported_elicitation_modes(&self) -> HashSet { if let Some(client_info) = self.peer_info() { - client_info.capabilities.elicitation.is_some() + if let Some(elicit_capability) = &client_info.capabilities.elicitation { + let mut modes = HashSet::new(); + // Backward compatibility: if neither form nor url is specified, assume form + if elicit_capability.form.is_none() && elicit_capability.url.is_none() { + modes.insert(ElicitationMode::Form); + } else { + if elicit_capability.form.is_some() { + modes.insert(ElicitationMode::Form); + } + if elicit_capability.url.is_some() { + modes.insert(ElicitationMode::Url); + } + } + modes + } else { + HashSet::new() + } } else { - false + HashSet::new() } } @@ -698,8 +727,11 @@ impl Peer { where T: ElicitationSafe + for<'de> serde::Deserialize<'de>, { - // Check if client supports elicitation capability - if !self.supports_elicitation() { + // Check if client supports form elicitation capability + if !self + .supported_elicitation_modes() + .contains(&ElicitationMode::Form) + { return Err(ElicitationError::CapabilityNotSupported); } @@ -717,7 +749,7 @@ impl Peer { let response = self .create_elicitation_with_timeout( - CreateElicitationRequestParams { + CreateElicitationRequestParams::FormElicitationParams { meta: None, message: message.into(), requested_schema: schema, @@ -741,4 +773,123 @@ impl Peer { crate::model::ElicitationAction::Cancel => Err(ElicitationError::UserCancelled), } } + + /// Request the user to visit a URL and confirm completion. + /// + /// This method sends a URL elicitation request to the client, prompting the user + /// to visit the specified URL and confirm completion. It returns the user's action + /// (accept/decline/cancel) without any additional data. + /// **Requires the `elicitation` feature to be enabled.** + /// + /// # Arguments + /// * `message` - The prompt message for the user + /// * `url` - The URL the user is requested to visit + /// * `elicitation_id` - A unique identifier for this elicitation request + /// # Returns + /// * `Ok(action)` indicating the user's response action + /// * `Err(ElicitationError::CapabilityNotSupported)` if client does not support elicitation via URL + /// * `Err(ElicitationError::Service(_))` if the underlying service call failed + /// # Example + /// ```rust,no_run + /// # use rmcp::*; + /// # use rmcp::model::ElicitationAction; + /// # use url::Url; + /// + /// async fn example(peer: Peer) -> Result<(), Box> { + /// let elicit_result = peer.elicit_url("Please visit the following URL to complete the action", + /// Url::parse("https://example.com/complete_action")?, "elicit_123").await?; + /// match elicit_result { + /// ElicitationAction::Accept => { + /// println!("User accepted and confirmed completion"); + /// } + /// ElicitationAction::Decline => { + /// println!("User declined the request"); + /// } + /// ElicitationAction::Cancel => { + /// println!("User cancelled/dismissed the request"); + /// } + /// } + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "elicitation")] + pub async fn elicit_url( + &self, + message: impl Into, + url: impl Into, + elicitation_id: impl Into, + ) -> Result { + self.elicit_url_with_timeout(message, url, elicitation_id, None) + .await + } + + /// Request the user to visit a URL and confirm completion. + /// + /// Same as `elicit_url()` but allows specifying a custom timeout for the request. + /// + /// # Arguments + /// * `message` - The prompt message for the user + /// * `url` - The URL the user is requested to visit + /// * `elicitation_id` - A unique identifier for this elicitation request + /// * `timeout` - Optional timeout duration. If None, uses default timeout behavior + /// # Returns + /// * `Ok(action)` indicating the user's response action + /// * `Err(ElicitationError::CapabilityNotSupported)` if client does not support elicitation via URL + /// * `Err(ElicitationError::Service(_))` if the underlying service call failed + /// # Example + /// ```rust,no_run + /// # use std::time::Duration; + /// use rmcp::*; + /// # use rmcp::model::ElicitationAction; + /// # use url::Url; + /// + /// async fn example(peer: Peer) -> Result<(), Box> { + /// let elicit_result = peer.elicit_url_with_timeout("Please visit the following URL to complete the action", + /// Url::parse("https://example.com/complete_action")?, + /// "elicit_123", + /// Some(Duration::from_secs(30))).await?; + /// match elicit_result { + /// ElicitationAction::Accept => { + /// println!("User accepted and confirmed completion"); + /// } + /// ElicitationAction::Decline => { + /// println!("User declined the request"); + /// } + /// ElicitationAction::Cancel => { + /// println!("User cancelled/dismissed the request"); + /// } + /// } + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "elicitation")] + pub async fn elicit_url_with_timeout( + &self, + message: impl Into, + url: impl Into, + elicitation_id: impl Into, + timeout: Option, + ) -> Result { + // Check if client supports url elicitation + if !self + .supported_elicitation_modes() + .contains(&ElicitationMode::Url) + { + return Err(ElicitationError::CapabilityNotSupported); + } + + let action = self + .create_elicitation_with_timeout( + CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: message.into(), + url: url.into().to_string(), + elicitation_id: elicitation_id.into(), + }, + timeout, + ) + .await? + .action; + Ok(action) + } } diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 65f21e235..3cc3c0d2d 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -44,7 +44,7 @@ async fn test_elicitation_request_param_serialization() { .build() .unwrap(); - let request_param = CreateElicitationRequestParams { + let request_param = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Please provide your email address".to_string(), requested_schema: schema, @@ -53,6 +53,7 @@ async fn test_elicitation_request_param_serialization() { // Test serialization let json = serde_json::to_value(&request_param).unwrap(); let expected = json!({ + "mode": "form", "message": "Please provide your email address", "requestedSchema": { "type": "object", @@ -70,11 +71,24 @@ async fn test_elicitation_request_param_serialization() { // Test deserialization let deserialized: CreateElicitationRequestParams = serde_json::from_value(expected).unwrap(); - assert_eq!(deserialized.message, request_param.message); - assert_eq!( - deserialized.requested_schema, - request_param.requested_schema - ); + match (&deserialized, &request_param) { + ( + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: msg1, + requested_schema: schema1, + }, + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: msg2, + requested_schema: schema2, + }, + ) => { + assert_eq!(msg1, msg2); + assert_eq!(schema1, schema2); + } + _ => panic!("Expected FormElicitationParam variant"), + } } /// Test CreateElicitationResult structure with different action types @@ -129,7 +143,7 @@ async fn test_elicitation_json_rpc_protocol() { id: RequestId::Number(1), request: CreateElicitationRequest { method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParams { + params: CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Do you want to continue?".to_string(), requested_schema: schema, @@ -149,10 +163,12 @@ async fn test_elicitation_json_rpc_protocol() { let deserialized: JsonRpcRequest = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.id, RequestId::Number(1)); - assert_eq!( - deserialized.request.params.message, - "Do you want to continue?" - ); + match &deserialized.request.params { + CreateElicitationRequestParams::FormElicitationParams { message, .. } => { + assert_eq!(message, "Do you want to continue?"); + } + _ => panic!("Expected FormElicitationParam variant"), + } } /// Test elicitation action types and their expected behavior @@ -214,7 +230,7 @@ async fn test_elicitation_spec_compliance() { #[tokio::test] async fn test_elicitation_error_handling() { // Test minimal schema handling (empty properties is technically valid) - let minimal_schema_request = CreateElicitationRequestParams { + let minimal_schema_request = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Test message".to_string(), requested_schema: ElicitationSchema::builder().build().unwrap(), @@ -224,7 +240,7 @@ async fn test_elicitation_error_handling() { let _json = serde_json::to_value(&minimal_schema_request).unwrap(); // Test empty message - let empty_message_request = CreateElicitationRequestParams { + let empty_message_request = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "".to_string(), requested_schema: ElicitationSchema::builder() @@ -250,7 +266,7 @@ async fn test_elicitation_performance() { .build() .unwrap(); - let request = CreateElicitationRequestParams { + let request = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Performance test message".to_string(), requested_schema: schema, @@ -286,19 +302,25 @@ async fn test_elicitation_capabilities() { // Test basic elicitation capability let mut elicitation_cap = ElicitationCapability::default(); - assert_eq!(elicitation_cap.schema_validation, None); + assert_eq!(elicitation_cap.form, None); + assert_eq!(elicitation_cap.url, None); // Test with schema validation enabled - elicitation_cap.schema_validation = Some(true); + elicitation_cap.form = Some(FormElicitationCapability { + schema_validation: Some(true), + }); // Test serialization let json = serde_json::to_value(&elicitation_cap).unwrap(); - let expected = json!({"schemaValidation": true}); + let expected = json!({"form":{"schemaValidation": true}}); assert_eq!(json, expected); // Test deserialization let deserialized: ElicitationCapability = serde_json::from_value(expected).unwrap(); - assert_eq!(deserialized.schema_validation, Some(true)); + assert_eq!( + deserialized.form.as_ref().unwrap().schema_validation, + Some(true) + ); // Test ClientCapabilities builder with elicitation let client_caps = ClientCapabilities::builder() @@ -308,14 +330,21 @@ async fn test_elicitation_capabilities() { assert!(client_caps.elicitation.is_some()); assert_eq!( - client_caps.elicitation.as_ref().unwrap().schema_validation, + client_caps + .elicitation + .as_ref() + .unwrap() + .form + .as_ref() + .unwrap() + .schema_validation, Some(true) ); // Test full client capabilities serialization let json = serde_json::to_value(&client_caps).unwrap(); assert!( - json["elicitation"]["schemaValidation"] + json["elicitation"]["form"]["schemaValidation"] .as_bool() .unwrap_or(false) ); @@ -374,8 +403,8 @@ async fn test_elicitation_convenience_methods() { .contains("Option A") ); - // Test that CreateElicitationRequestParams can be created with type-safe schemas - let confirmation_request = CreateElicitationRequestParams { + // Test that CreateElicitationRequestParam can be created with type-safe schemas + let confirmation_request = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Test confirmation".to_string(), requested_schema: ElicitationSchema::builder() @@ -418,7 +447,7 @@ async fn test_elicitation_structured_schemas() { .build() .unwrap(); - let request = CreateElicitationRequestParams { + let request = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, @@ -428,42 +457,31 @@ async fn test_elicitation_structured_schemas() { let json = serde_json::to_value(&request).unwrap(); let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.message, "Please provide your user information"); - assert_eq!(deserialized.requested_schema.properties.len(), 5); - assert!( - deserialized - .requested_schema - .properties - .contains_key("name") - ); - assert!( - deserialized - .requested_schema - .properties - .contains_key("email") - ); - assert!(deserialized.requested_schema.properties.contains_key("age")); - assert!( - deserialized - .requested_schema - .properties - .contains_key("newsletter") - ); - assert!( - deserialized - .requested_schema - .properties - .contains_key("country") - ); - assert_eq!( - deserialized.requested_schema.required, - Some(vec![ - "name".to_string(), - "email".to_string(), - "age".to_string(), - "country".to_string() - ]) - ); + match deserialized { + CreateElicitationRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 5); + assert!(requested_schema.properties.contains_key("name")); + assert!(requested_schema.properties.contains_key("email")); + assert!(requested_schema.properties.contains_key("age")); + assert!(requested_schema.properties.contains_key("newsletter")); + assert!(requested_schema.properties.contains_key("country")); + assert_eq!( + requested_schema.required, + Some(vec![ + "name".to_string(), + "email".to_string(), + "age".to_string(), + "country".to_string() + ]) + ); + } + _ => panic!("Expected FormElicitationParam variant"), + } } // Typed elicitation tests using the API with schemars @@ -661,7 +679,7 @@ async fn test_elicitation_multi_select_enum() { .build() .unwrap(); - let request = CreateElicitationRequestParams { + let request = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, @@ -671,58 +689,56 @@ async fn test_elicitation_multi_select_enum() { let json = serde_json::to_value(&request).unwrap(); let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.message, "Please provide your user information"); - assert_eq!(deserialized.requested_schema.properties.len(), 1); - assert!( - deserialized - .requested_schema - .properties - .contains_key("choices") - ); - assert_eq!( - deserialized.requested_schema.required, - Some(vec!["choices".to_string()]) - ); - - assert!(matches!( - deserialized - .requested_schema - .properties - .get("choices") - .unwrap(), - PrimitiveSchema::Enum(EnumSchema::Multi(_)) - )); - - if let Some(PrimitiveSchema::Enum(schema)) = - deserialized.requested_schema.properties.get("choices") - { - assert_eq!( - schema, - &EnumSchema::Multi(MultiSelectEnumSchema::Titled(TitledMultiSelectEnumSchema { - type_: ArrayTypeConst, - title: None, - description: None, - min_items: Some(1), - max_items: Some(2), - items: TitledItems { - any_of: vec![ - ConstTitle { - const_: "A".to_string(), - title: "A name".to_string() - }, - ConstTitle { - const_: "B".to_string(), - title: "B name".to_string() - }, - ConstTitle { - const_: "C".to_string(), - title: "C name".to_string() + match deserialized { + CreateElicitationRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("choices")); + assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); + + assert!(matches!( + requested_schema.properties.get("choices").unwrap(), + PrimitiveSchema::Enum(EnumSchema::Multi(_)) + )); + + if let Some(PrimitiveSchema::Enum(schema)) = requested_schema.properties.get("choices") + { + assert_eq!( + schema, + &EnumSchema::Multi(MultiSelectEnumSchema::Titled( + TitledMultiSelectEnumSchema { + type_: ArrayTypeConst, + title: None, + description: None, + min_items: Some(1), + max_items: Some(2), + items: TitledItems { + any_of: vec![ + ConstTitle { + const_: "A".to_string(), + title: "A name".to_string() + }, + ConstTitle { + const_: "B".to_string(), + title: "B name".to_string() + }, + ConstTitle { + const_: "C".to_string(), + title: "C name".to_string() + } + ], + }, + default: None } - ], - }, - default: None - })) - ) + )) + ) + } + } + _ => panic!("Expected FormElicitationParam variant"), } } @@ -743,7 +759,7 @@ async fn test_elicitation_single_select_enum() { .build() .unwrap(); - let request = CreateElicitationRequestParams { + let request = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, @@ -752,55 +768,52 @@ async fn test_elicitation_single_select_enum() { // Test that complex schemas serialize/deserialize correctly let json = serde_json::to_value(&request).unwrap(); let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.message, "Please provide your user information"); - assert_eq!(deserialized.requested_schema.properties.len(), 1); - assert!( - deserialized - .requested_schema - .properties - .contains_key("choices") - ); - assert_eq!( - deserialized.requested_schema.required, - Some(vec!["choices".to_string()]) - ); - assert!(matches!( - deserialized - .requested_schema - .properties - .get("choices") - .unwrap(), - PrimitiveSchema::Enum(EnumSchema::Single(_)) - )); - - if let Some(PrimitiveSchema::Enum(schema)) = - deserialized.requested_schema.properties.get("choices") - { - assert_eq!( - schema, - &EnumSchema::Single(SingleSelectEnumSchema::Titled( - TitledSingleSelectEnumSchema { - type_: StringTypeConst, - title: None, - description: None, - one_of: vec![ - ConstTitle { - const_: "A".to_string(), - title: "A name".to_string() - }, - ConstTitle { - const_: "B".to_string(), - title: "B name".to_string() - }, - ConstTitle { - const_: "C".to_string(), - title: "C name".to_string() + + match deserialized { + CreateElicitationRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("choices")); + assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); + assert!(matches!( + requested_schema.properties.get("choices").unwrap(), + PrimitiveSchema::Enum(EnumSchema::Single(_)) + )); + + if let Some(PrimitiveSchema::Enum(schema)) = requested_schema.properties.get("choices") + { + assert_eq!( + schema, + &EnumSchema::Single(SingleSelectEnumSchema::Titled( + TitledSingleSelectEnumSchema { + type_: StringTypeConst, + title: None, + description: None, + one_of: vec![ + ConstTitle { + const_: "A".to_string(), + title: "A name".to_string() + }, + ConstTitle { + const_: "B".to_string(), + title: "B name".to_string() + }, + ConstTitle { + const_: "C".to_string(), + title: "C name".to_string() + } + ], + default: None } - ], - default: None - } - )) - ) + )) + ) + } + } + _ => panic!("Expected FormElicitationParam variant"), } } @@ -825,7 +838,7 @@ async fn test_elicitation_direction_server_to_client() { .build() .unwrap(); - let elicitation_request = CreateElicitationRequestParams { + let elicitation_request = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Please enter your name".to_string(), requested_schema: schema, @@ -878,7 +891,7 @@ async fn test_elicitation_json_rpc_direction() { let server_request = ServerJsonRpcMessage::request( ServerRequest::CreateElicitationRequest(CreateElicitationRequest { method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParams { + params: CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Do you want to continue?".to_string(), requested_schema: schema, @@ -984,32 +997,54 @@ async fn test_elicitation_result_in_client_result() { async fn test_elicitation_capability_structure() { // Test default ElicitationCapability let default_cap = ElicitationCapability::default(); - assert!(default_cap.schema_validation.is_none()); + assert!(default_cap.form.is_none()); + assert!(default_cap.url.is_none()); // Test ElicitationCapability with schema validation enabled let cap_with_validation = ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }; - assert_eq!(cap_with_validation.schema_validation, Some(true)); + assert_eq!( + cap_with_validation.form.as_ref().unwrap().schema_validation, + Some(true) + ); // Test ElicitationCapability with schema validation disabled let cap_without_validation = ElicitationCapability { - schema_validation: Some(false), + form: Some(FormElicitationCapability { + schema_validation: Some(false), + }), + url: None, }; - assert_eq!(cap_without_validation.schema_validation, Some(false)); + assert_eq!( + cap_without_validation + .form + .as_ref() + .unwrap() + .schema_validation, + Some(false) + ); // Test JSON serialization let json = serde_json::to_value(&cap_with_validation).unwrap(); assert_eq!( json, serde_json::json!({ - "schemaValidation": true + "form": { + "schemaValidation": true + } }) ); // Test JSON deserialization let deserialized: ElicitationCapability = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.schema_validation, Some(true)); + assert_eq!( + deserialized.form.as_ref().unwrap().schema_validation, + Some(true) + ); } /// Test ClientCapabilities with elicitation capability @@ -1018,7 +1053,10 @@ async fn test_client_capabilities_with_elicitation() { // Test ClientCapabilities with elicitation capability let capabilities = ClientCapabilities { elicitation: Some(ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }), ..Default::default() }; @@ -1026,14 +1064,21 @@ async fn test_client_capabilities_with_elicitation() { // Verify elicitation capability is present assert!(capabilities.elicitation.is_some()); assert_eq!( - capabilities.elicitation.as_ref().unwrap().schema_validation, + capabilities + .elicitation + .as_ref() + .unwrap() + .form + .as_ref() + .unwrap() + .schema_validation, Some(true) ); // Test JSON serialization let json = serde_json::to_value(&capabilities).unwrap(); assert!( - json["elicitation"]["schemaValidation"] + json["elicitation"]["form"]["schemaValidation"] .as_bool() .unwrap_or(false) ); @@ -1050,13 +1095,16 @@ async fn test_client_capabilities_with_elicitation() { /// Test InitializeRequestParam with elicitation capability #[tokio::test] async fn test_initialize_request_with_elicitation() { - // Test InitializeRequestParams with elicitation capability + // Test InitializeRequestParam with elicitation capability let init_param = InitializeRequestParams { meta: None, protocol_version: ProtocolVersion::LATEST, capabilities: ClientCapabilities { elicitation: Some(ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }), ..Default::default() }, @@ -1077,6 +1125,9 @@ async fn test_initialize_request_with_elicitation() { .elicitation .as_ref() .unwrap() + .form + .as_ref() + .unwrap() .schema_validation, Some(true) ); @@ -1084,7 +1135,7 @@ async fn test_initialize_request_with_elicitation() { // Test JSON serialization let json = serde_json::to_value(&init_param).unwrap(); assert!( - json["capabilities"]["elicitation"]["schemaValidation"] + json["capabilities"]["elicitation"]["form"]["schemaValidation"] .as_bool() .unwrap_or(false) ); @@ -1101,7 +1152,10 @@ async fn test_capability_checking_logic() { protocol_version: ProtocolVersion::LATEST, capabilities: ClientCapabilities { elicitation: Some(ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }), ..Default::default() }, @@ -1230,37 +1284,47 @@ async fn test_elicitation_capability_serialization() { // Test capability with schema validation enabled let cap_with_validation = ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }; let json = serde_json::to_value(&cap_with_validation).unwrap(); assert_eq!( json, serde_json::json!({ - "schemaValidation": true + "form": { + "schemaValidation": true + } }) ); // Test capability with schema validation disabled let cap_without_validation = ElicitationCapability { - schema_validation: Some(false), + form: Some(FormElicitationCapability { + schema_validation: Some(false), + }), + url: None, }; let json = serde_json::to_value(&cap_without_validation).unwrap(); assert_eq!( json, serde_json::json!({ - "schemaValidation": false + "form": { + "schemaValidation": false + } }) ); // Test deserialization let deserialized: ElicitationCapability = serde_json::from_value(serde_json::json!({ - "schemaValidation": true + "form":{"schemaValidation": true} })) .unwrap(); - assert_eq!(deserialized.schema_validation, Some(true)); + assert_eq!(deserialized.form.unwrap().schema_validation, Some(true)); } /// Test ClientCapabilities builder with elicitation capability methods @@ -1272,7 +1336,7 @@ async fn test_client_capabilities_elicitation_builder() { let caps = ClientCapabilities::builder().enable_elicitation().build(); assert!(caps.elicitation.is_some()); - assert_eq!(caps.elicitation.as_ref().unwrap().schema_validation, None); + assert_eq!(caps.elicitation.as_ref().unwrap().form, None); // Test enabling elicitation with schema validation let caps_with_validation = ClientCapabilities::builder() @@ -1286,13 +1350,19 @@ async fn test_client_capabilities_elicitation_builder() { .elicitation .as_ref() .unwrap() + .form + .as_ref() + .unwrap() .schema_validation, Some(true) ); // Test enabling elicitation with custom capability let custom_elicitation = ElicitationCapability { - schema_validation: Some(false), + form: Some(FormElicitationCapability { + schema_validation: Some(false), + }), + url: None, }; let caps_custom = ClientCapabilities::builder() @@ -1322,7 +1392,7 @@ async fn test_create_elicitation_with_timeout_basic() { .build() .unwrap(); - let _params = CreateElicitationRequestParams { + let _params = CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Enter your details".to_string(), requested_schema: schema, @@ -1784,3 +1854,421 @@ async fn test_required_typed_property_methods() { assert!(required.contains(&"age".to_string())); assert!(required.contains(&"active".to_string())); } + +// ============================================================================= +// URL ELICITATION TESTS +// ============================================================================= + +/// Test URL elicitation request parameter serialization/deserialization +#[tokio::test] +async fn test_url_elicitation_request_param_serialization() { + let request_param = CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: "Please visit the following URL to complete verification".to_string(), + url: "https://example.com/verify".to_string(), + elicitation_id: "elicit-123".to_string(), + }; + + // Test serialization + let json = serde_json::to_value(&request_param).unwrap(); + let expected = json!({ + "mode": "url", + "message": "Please visit the following URL to complete verification", + "url": "https://example.com/verify", + "elicitationId": "elicit-123" + }); + + assert_eq!(json, expected); + + // Test deserialization + let deserialized: CreateElicitationRequestParams = serde_json::from_value(expected).unwrap(); + match deserialized { + CreateElicitationRequestParams::UrlElicitationParams { + message, + url, + elicitation_id, + .. + } => { + assert_eq!( + message, + "Please visit the following URL to complete verification" + ); + assert_eq!(url, "https://example.com/verify"); + assert_eq!(elicitation_id, "elicit-123"); + } + _ => panic!("Expected UrlElicitationParam variant"), + } +} + +/// Test URL elicitation request in JSON-RPC protocol +#[tokio::test] +async fn test_url_elicitation_json_rpc_protocol() { + // Create a complete JSON-RPC request for URL elicitation + let request = JsonRpcRequest { + jsonrpc: JsonRpcVersion2_0, + id: RequestId::Number(1), + request: CreateElicitationRequest { + method: ElicitationCreateRequestMethod, + params: CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: "Please authorize this action at the following URL".to_string(), + url: "https://auth.example.com/authorize/abc123".to_string(), + elicitation_id: "auth-request-456".to_string(), + }, + extensions: Default::default(), + }, + }; + + // Test serialization of complete request + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["jsonrpc"], "2.0"); + assert_eq!(json["id"], 1); + assert_eq!(json["method"], "elicitation/create"); + assert_eq!(json["params"]["mode"], "url"); + assert_eq!( + json["params"]["message"], + "Please authorize this action at the following URL" + ); + assert_eq!( + json["params"]["url"], + "https://auth.example.com/authorize/abc123" + ); + assert_eq!(json["params"]["elicitationId"], "auth-request-456"); + + // Test deserialization + let deserialized: JsonRpcRequest = + serde_json::from_value(json).unwrap(); + assert_eq!(deserialized.id, RequestId::Number(1)); + match &deserialized.request.params { + CreateElicitationRequestParams::UrlElicitationParams { + message, + url, + elicitation_id, + .. + } => { + assert_eq!(message, "Please authorize this action at the following URL"); + assert_eq!(url, "https://auth.example.com/authorize/abc123"); + assert_eq!(elicitation_id, "auth-request-456"); + } + _ => panic!("Expected UrlElicitationParam variant"), + } +} + +/// Test ElicitationCompletionNotification serialization/deserialization +#[tokio::test] +async fn test_elicitation_completion_notification() { + let notification_params = ElicitationResponseNotificationParam { + elicitation_id: "elicit-789".to_string(), + }; + + // Test serialization + let json = serde_json::to_value(¬ification_params).unwrap(); + let expected = json!({ + "elicitationId": "elicit-789" + }); + assert_eq!(json, expected); + + // Test deserialization + let deserialized: ElicitationResponseNotificationParam = + serde_json::from_value(expected).unwrap(); + assert_eq!(deserialized.elicitation_id, "elicit-789"); + + // Test complete notification structure + let notification = ElicitationCompletionNotification { + method: ElicitationCompletionNotificationMethod, + params: notification_params, + extensions: Default::default(), + }; + + let json = serde_json::to_value(¬ification).unwrap(); + assert_eq!(json["method"], "notifications/elicitation/complete"); + assert_eq!(json["params"]["elicitationId"], "elicit-789"); +} + +/// Test UrlElicitationCapability structure and serialization +#[tokio::test] +async fn test_url_elicitation_capability() { + // Test default UrlElicitationCapability + let url_cap = UrlElicitationCapability::default(); + + // Test serialization - should be empty object + let json = serde_json::to_value(&url_cap).unwrap(); + assert_eq!(json, json!({})); + + // Test deserialization + let deserialized: UrlElicitationCapability = serde_json::from_value(json!({})).unwrap(); + assert_eq!(deserialized, url_cap); + + // Test ElicitationCapability with URL mode enabled + let elicitation_cap = ElicitationCapability { + form: None, + url: Some(UrlElicitationCapability::default()), + }; + + let json = serde_json::to_value(&elicitation_cap).unwrap(); + assert_eq!( + json, + json!({ + "url": {} + }) + ); + + // Test ElicitationCapability with both form and URL modes + let both_cap = ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: Some(UrlElicitationCapability::default()), + }; + + let json = serde_json::to_value(&both_cap).unwrap(); + assert_eq!( + json, + json!({ + "form": { + "schemaValidation": true + }, + "url": {} + }) + ); +} + +/// Test backward compatibility: CreateElicitationRequestParam without mode tag +#[tokio::test] +async fn test_elicitation_backward_compatibility_no_mode() { + // JSON without "mode" field should deserialize as FormElicitationParam + let json_without_mode = json!({ + "message": "Please enter your details", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }); + + let deserialized: CreateElicitationRequestParams = + serde_json::from_value(json_without_mode).unwrap(); + + match deserialized { + CreateElicitationRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } => { + assert_eq!(message, "Please enter your details"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("name")); + } + _ => panic!("Expected FormElicitationParam for backward compatibility"), + } +} + +/// Test both form and URL elicitation modes in the same test +#[tokio::test] +async fn test_elicitation_both_modes() { + // Form mode + let form_schema = ElicitationSchema::builder() + .required_property("email", PrimitiveSchema::String(StringSchema::email())) + .build() + .unwrap(); + + let form_request = CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Enter email".to_string(), + requested_schema: form_schema, + }; + + let form_json = serde_json::to_value(&form_request).unwrap(); + assert_eq!(form_json["mode"], "form"); + assert!(form_json.get("requestedSchema").is_some()); + assert!(form_json.get("url").is_none()); + + // URL mode + let url_request = CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: "Visit URL".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "id-123".to_string(), + }; + + let url_json = serde_json::to_value(&url_request).unwrap(); + assert_eq!(url_json["mode"], "url"); + assert!(url_json.get("url").is_some()); + assert!(url_json.get("elicitationId").is_some()); + assert!(url_json.get("requestedSchema").is_none()); +} + +/// Test URL_ELICITATION_REQUIRED error code +#[tokio::test] +async fn test_url_elicitation_required_error_code() { + // Test the error code constant + assert_eq!(ErrorCode::URL_ELICITATION_REQUIRED.0, -32042); + + // Test creating error data with URL_ELICITATION_REQUIRED + let error_data = ErrorData::url_elicitation_required( + "URL elicitation is required for this operation", + Some(json!({ + "url": "https://example.com/complete", + "elicitationId": "elicit-999" + })), + ); + + assert_eq!(error_data.code, ErrorCode::URL_ELICITATION_REQUIRED); + assert_eq!( + error_data.message, + "URL elicitation is required for this operation" + ); + assert!(error_data.data.is_some()); + + // Test serialization + let json = serde_json::to_value(&error_data).unwrap(); + assert_eq!(json["code"], -32042); + assert_eq!( + json["message"], + "URL elicitation is required for this operation" + ); + assert_eq!(json["data"]["url"], "https://example.com/complete"); + assert_eq!(json["data"]["elicitationId"], "elicit-999"); +} + +/// Test ClientCapabilities with different elicitation mode combinations +#[tokio::test] +async fn test_client_capabilities_elicitation_modes() { + // Test with form-only capability + let form_only_caps = ClientCapabilities { + elicitation: Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, + }), + ..Default::default() + }; + + let json = serde_json::to_value(&form_only_caps).unwrap(); + assert!(json["elicitation"]["form"].is_object()); + assert!( + json["elicitation"]["url"].is_null() + || !json["elicitation"].as_object().unwrap().contains_key("url") + ); + + // Test with URL-only capability + let url_only_caps = ClientCapabilities { + elicitation: Some(ElicitationCapability { + form: None, + url: Some(UrlElicitationCapability::default()), + }), + ..Default::default() + }; + + let json = serde_json::to_value(&url_only_caps).unwrap(); + assert!(json["elicitation"]["url"].is_object()); + assert!( + json["elicitation"]["form"].is_null() + || !json["elicitation"] + .as_object() + .unwrap() + .contains_key("form") + ); + + // Test with both capabilities + let both_caps = ClientCapabilities { + elicitation: Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(false), + }), + url: Some(UrlElicitationCapability::default()), + }), + ..Default::default() + }; + + let json = serde_json::to_value(&both_caps).unwrap(); + assert!(json["elicitation"]["form"].is_object()); + assert!(json["elicitation"]["url"].is_object()); +} + +/// Test ElicitationCompletionNotification in ServerNotification enum +#[tokio::test] +async fn test_elicitation_completion_in_server_notification() { + let notification_param = ElicitationResponseNotificationParam { + elicitation_id: "notify-123".to_string(), + }; + + let completion_notification = ElicitationCompletionNotification { + method: ElicitationCompletionNotificationMethod, + params: notification_param.clone(), + extensions: Default::default(), + }; + + // Test that it's part of ServerNotification + let server_notification = + ServerNotification::ElicitationCompletionNotification(completion_notification); + + // Test serialization + let json = serde_json::to_value(&server_notification).unwrap(); + assert_eq!(json["method"], "notifications/elicitation/complete"); + assert_eq!(json["params"]["elicitationId"], "notify-123"); + + // Test deserialization + let deserialized: ServerNotification = serde_json::from_value(json).unwrap(); + match deserialized { + ServerNotification::ElicitationCompletionNotification(notif) => { + assert_eq!(notif.params.elicitation_id, "notify-123"); + } + _ => panic!("Expected ElicitationCompletionNotification variant"), + } +} + +/// Test ElicitationAction with URL elicitation workflow +#[tokio::test] +async fn test_url_elicitation_action_workflow() { + // Test Accept action for URL elicitation (user visited URL and confirmed) + let accept_result = CreateElicitationResult { + action: ElicitationAction::Accept, + content: None, // URL elicitation doesn't return content, just confirmation + }; + + let json = serde_json::to_value(&accept_result).unwrap(); + assert_eq!(json["action"], "accept"); + // content should be omitted when None + assert!(json.get("content").is_none() || json["content"].is_null()); + + // Test Decline action for URL elicitation + let decline_result = CreateElicitationResult { + action: ElicitationAction::Decline, + content: None, + }; + + let json = serde_json::to_value(&decline_result).unwrap(); + assert_eq!(json["action"], "decline"); + + // Test Cancel action for URL elicitation + let cancel_result = CreateElicitationResult { + action: ElicitationAction::Cancel, + content: None, + }; + + let json = serde_json::to_value(&cancel_result).unwrap(); + assert_eq!(json["action"], "cancel"); +} + +/// Test method constants for URL elicitation +#[tokio::test] +async fn test_elicitation_method_constants() { + // Test existing methods + assert_eq!(ElicitationCreateRequestMethod::VALUE, "elicitation/create"); + assert_eq!( + ElicitationResponseNotificationMethod::VALUE, + "notifications/elicitation/response" + ); + + // Test new completion notification method + assert_eq!( + ElicitationCompletionNotificationMethod::VALUE, + "notifications/elicitation/complete" + ); +} diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index c9d7dab00..c7a2092a1 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -529,14 +529,29 @@ ] }, "ElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\n\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", "type": "object", "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } ] } } @@ -588,6 +603,19 @@ "message" ] }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, "GetPromptRequestMethod": { "type": "string", "format": "const", @@ -2126,6 +2154,10 @@ "required": [ "uri" ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" } } } \ No newline at end of file diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index c9d7dab00..c7a2092a1 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -529,14 +529,29 @@ ] }, "ElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\n\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", "type": "object", "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } ] } } @@ -588,6 +603,19 @@ "message" ] }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, "GetPromptRequestMethod": { "type": "string", "format": "const", @@ -2126,6 +2154,10 @@ "required": [ "uri" ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" } } } \ No newline at end of file diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 7bd25060e..2986077e2 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -505,33 +505,88 @@ ] }, "CreateElicitationRequestParams": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = CreateElicitationRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] }, - "message": { - "description": "Human-readable message explaining what input is needed from the user.\nThis should be clear and provide sufficient context for the user to understand\nwhat information they need to provide.", - "type": "string" + { + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] }, - "requestedSchema": { - "description": "Type-safe schema defining the expected structure and validation rules for the user's response.\nThis enforces the MCP 2025-06-18 specification that elicitation schemas must be objects\nwith primitive-typed properties.", - "allOf": [ - { + { + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "requestedSchema": { "$ref": "#/definitions/ElicitationSchema" } + }, + "required": [ + "message", + "requestedSchema" ] } - }, - "required": [ - "message", - "requestedSchema" ] }, "CreateElicitationResult": { @@ -730,11 +785,28 @@ } ] }, + "ElicitationCompletionNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/elicitation/complete" + }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", "const": "elicitation/create" }, + "ElicitationResponseNotificationParam": { + "description": "Notification parameters for an url elicitation completion notification.", + "type": "object", + "properties": { + "elicitationId": { + "type": "string" + } + }, + "required": [ + "elicitationId" + ] + }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", @@ -1089,6 +1161,9 @@ { "$ref": "#/definitions/NotificationNoParam3" }, + { + "$ref": "#/definitions/Notification5" + }, { "$ref": "#/definitions/CustomNotification" } @@ -1505,6 +1580,21 @@ "params" ] }, + "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ElicitationCompletionNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ElicitationResponseNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, "NotificationNoParam": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 7bd25060e..2986077e2 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -505,33 +505,88 @@ ] }, "CreateElicitationRequestParams": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = CreateElicitationRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ + { + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] }, - "message": { - "description": "Human-readable message explaining what input is needed from the user.\nThis should be clear and provide sufficient context for the user to understand\nwhat information they need to provide.", - "type": "string" + { + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] }, - "requestedSchema": { - "description": "Type-safe schema defining the expected structure and validation rules for the user's response.\nThis enforces the MCP 2025-06-18 specification that elicitation schemas must be objects\nwith primitive-typed properties.", - "allOf": [ - { + { + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "requestedSchema": { "$ref": "#/definitions/ElicitationSchema" } + }, + "required": [ + "message", + "requestedSchema" ] } - }, - "required": [ - "message", - "requestedSchema" ] }, "CreateElicitationResult": { @@ -730,11 +785,28 @@ } ] }, + "ElicitationCompletionNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/elicitation/complete" + }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", "const": "elicitation/create" }, + "ElicitationResponseNotificationParam": { + "description": "Notification parameters for an url elicitation completion notification.", + "type": "object", + "properties": { + "elicitationId": { + "type": "string" + } + }, + "required": [ + "elicitationId" + ] + }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", @@ -1089,6 +1161,9 @@ { "$ref": "#/definitions/NotificationNoParam3" }, + { + "$ref": "#/definitions/Notification5" + }, { "$ref": "#/definitions/CustomNotification" } @@ -1505,6 +1580,21 @@ "params" ] }, + "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ElicitationCompletionNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ElicitationResponseNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, "NotificationNoParam": { "type": "object", "properties": { diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 10ee6611d..82f8d696a 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -17,6 +17,7 @@ use rmcp::{ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tracing_subscriber::{self, EnvFilter}; +use url::Url; /// User information request #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -106,6 +107,48 @@ impl ElicitationServer { "User name reset. Next greeting will ask for name again.".to_string(), )])) } + + #[tool(description = "Example of URL elicitation")] + pub async fn secure_tool_call( + &self, + context: RequestContext, + ) -> std::result::Result { + let elicit_result = context + .peer + .elicit_url( + "User must visit the following URL to complete tool call", + Url::parse("https://example.com/complete_tool").expect("valid URL"), + "elicit_123", + ) + .await + .map_err(|e| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!("Url elicitation has failed: {}", e), + None, + ) + })?; + match elicit_result { + ElicitationAction::Accept => { + // Mock notifying completion + let _ = context + .peer + .notify_url_elicitation_completed(ElicitationResponseNotificationParam { + elicitation_id: "elicit_123".to_string(), + }) + .await; + Ok(CallToolResult::success(vec![Content::text( + "Elicitation via URL successful".to_string(), + )])) + } + ElicitationAction::Cancel => Ok(CallToolResult::success(vec![Content::text( + "Elicitation via URL cancelled by user".to_string(), + )])), + ElicitationAction::Decline => Ok(CallToolResult::error(vec![Content::text( + "Elicitation via URL declined by user".to_string(), + )])), + } + } } #[tool_handler] From 07028bc0aa5f4962a5c2250e6bbd1707033ac7ae Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:36:47 -0500 Subject: [PATCH 030/333] Add optional description field to Implementation struct (#649) * feat: add optional description field to Implementation struct * test: update snapshots --- crates/rmcp/src/model.rs | 7 +++++++ crates/rmcp/tests/test_elicitation.rs | 3 +++ .../client_json_rpc_message_schema.json | 6 ++++++ .../client_json_rpc_message_schema_current.json | 6 ++++++ .../server_json_rpc_message_schema.json | 6 ++++++ .../server_json_rpc_message_schema_current.json | 6 ++++++ examples/clients/src/streamable_http.rs | 1 + 7 files changed, 35 insertions(+) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 16ccc0a69..00c51bcb3 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -841,6 +841,8 @@ pub struct Implementation { pub title: Option, pub version: String, #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub website_url: Option, @@ -858,6 +860,7 @@ impl Implementation { name: env!("CARGO_CRATE_NAME").to_owned(), title: None, version: env!("CARGO_PKG_VERSION").to_owned(), + description: None, icons: None, website_url: None, } @@ -3136,6 +3139,7 @@ mod tests { name: "test-server".to_string(), title: Some("Test Server".to_string()), version: "1.0.0".to_string(), + description: Some("A test server for unit testing".to_string()), icons: Some(vec![ Icon { src: "https://example.com/icon.png".to_string(), @@ -3153,6 +3157,7 @@ mod tests { let json = serde_json::to_value(&implementation).unwrap(); assert_eq!(json["name"], "test-server"); + assert_eq!(json["description"], "A test server for unit testing"); assert_eq!(json["websiteUrl"], "https://example.com"); assert!(json["icons"].is_array()); assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png"); @@ -3172,6 +3177,7 @@ mod tests { let implementation: Implementation = serde_json::from_value(old_json).unwrap(); assert_eq!(implementation.name, "legacy-server"); assert_eq!(implementation.version, "0.9.0"); + assert_eq!(implementation.description, None); assert_eq!(implementation.icons, None); assert_eq!(implementation.website_url, None); } @@ -3185,6 +3191,7 @@ mod tests { name: "icon-server".to_string(), title: None, version: "2.0.0".to_string(), + description: None, icons: Some(vec![Icon { src: "https://example.com/server.png".to_string(), mime_type: Some("image/png".to_string()), diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 3cc3c0d2d..ce8be280e 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -1112,6 +1112,7 @@ async fn test_initialize_request_with_elicitation() { name: "test-client".to_string(), version: "1.0.0".to_string(), title: None, + description: None, website_url: None, icons: None, }, @@ -1163,6 +1164,7 @@ async fn test_capability_checking_logic() { name: "test-client".to_string(), version: "1.0.0".to_string(), title: None, + description: None, website_url: None, icons: None, }, @@ -1184,6 +1186,7 @@ async fn test_capability_checking_logic() { name: "test-client".to_string(), version: "1.0.0".to_string(), title: None, + description: None, website_url: None, icons: None, }, diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index c7a2092a1..940f03f1b 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -729,6 +729,12 @@ "Implementation": { "type": "object", "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, "icons": { "type": [ "array", diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index c7a2092a1..940f03f1b 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -729,6 +729,12 @@ "Implementation": { "type": "object", "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, "icons": { "type": [ "array", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 2986077e2..f0b617354 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -986,6 +986,12 @@ "Implementation": { "type": "object", "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, "icons": { "type": [ "array", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 2986077e2..f0b617354 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -986,6 +986,12 @@ "Implementation": { "type": "object", "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, "icons": { "type": [ "array", diff --git a/examples/clients/src/streamable_http.rs b/examples/clients/src/streamable_http.rs index 7af9b5bcb..baf1838a3 100644 --- a/examples/clients/src/streamable_http.rs +++ b/examples/clients/src/streamable_http.rs @@ -25,6 +25,7 @@ async fn main() -> Result<()> { name: "test sse client".to_string(), title: None, version: "0.0.1".to_string(), + description: None, website_url: None, icons: None, }, From 9cfc905a9ef17c8bba6748dc0a9bdd2452681733 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:00:28 -0500 Subject: [PATCH 031/333] chore: release v0.15.0 (#636) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 7 +++++++ crates/rmcp/CHANGELOG.md | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e91a99d36..0bc1f08b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "0.14.0", path = "./crates/rmcp" } -rmcp-macros = { version = "0.14.0", path = "./crates/rmcp-macros" } +rmcp = { version = "0.15.0", path = "./crates/rmcp" } +rmcp-macros = { version = "0.15.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "0.14.0" +version = "0.15.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index ef895f2f5..5029d9dcf 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.14.0...rmcp-macros-v0.15.0) - 2026-02-10 + +### Fixed + +- *(tasks)* avoid dropping completed task results during collection ([#639](https://github.com/modelcontextprotocol/rust-sdk/pull/639)) +- *(tasks)* expose `execution.taskSupport` on tools ([#635](https://github.com/modelcontextprotocol/rust-sdk/pull/635)) + ## [0.14.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.13.0...rmcp-macros-v0.14.0) - 2026-01-23 ### Other diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 4cdead0a6..33fa6e849 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.14.0...rmcp-v0.15.0) - 2026-02-10 + +### Added + +- *(elicitation)* add support URL elicitation. SEP-1036 ([#605](https://github.com/modelcontextprotocol/rust-sdk/pull/605)) +- enforce SEP-1577 MUST requirements for sampling with tools ([#646](https://github.com/modelcontextprotocol/rust-sdk/pull/646)) +- add native-tls as an optional TLS backend ([#631](https://github.com/modelcontextprotocol/rust-sdk/pull/631)) +- *(capabilities)* add extensions field for SEP-1724 ([#643](https://github.com/modelcontextprotocol/rust-sdk/pull/643)) + +### Fixed + +- *(tasks)* avoid dropping completed task results during collection ([#639](https://github.com/modelcontextprotocol/rust-sdk/pull/639)) +- *(auth)* oauth metadata discovery ([#641](https://github.com/modelcontextprotocol/rust-sdk/pull/641)) +- compilation with --no-default-features ([#593](https://github.com/modelcontextprotocol/rust-sdk/pull/593)) +- *(tasks)* expose `execution.taskSupport` on tools ([#635](https://github.com/modelcontextprotocol/rust-sdk/pull/635)) +- *(tasks)* correct enum variant ordering for deserialization ([#634](https://github.com/modelcontextprotocol/rust-sdk/pull/634)) + +### Other + +- Add optional description field to Implementation struct ([#649](https://github.com/modelcontextprotocol/rust-sdk/pull/649)) +- Implement SEP-1577: Sampling With Tools ([#628](https://github.com/modelcontextprotocol/rust-sdk/pull/628)) + ## [0.14.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.13.0...rmcp-v0.14.0) - 2026-01-23 ### Fixed From 3eb4c384d3a293307bba7220fd6eed57082d68cd Mon Sep 17 00:00:00 2001 From: Samuel Bustamante Larriet <145044934+samuel-bustamante@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:52:51 +0100 Subject: [PATCH 032/333] docs: add rudof-mcp to MCP servers list (#645) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7fdb47c91..892d55655 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. - [NexusCore MCP](https://github.com/sjkim1127/Nexuscore_MCP) - Advanced malware analysis & dynamic instrumentation MCP server with Frida integration and stealth unpacking capabilities - [spreadsheet-mcp](https://github.com/PSU3D0/spreadsheet-mcp) - Token-efficient MCP server for spreadsheet analysis with automatic region detection, recalculation, screenshot, and editing support for LLM agents - [hyper-mcp](https://github.com/hyper-mcp-rs/hyper-mcp) - A fast, secure MCP server that extends its capabilities through WebAssembly (WASM) plugins +- [rudof-mcp](https://github.com/rudof-project/rudof/tree/master/rudof_mcp) - RDF validation and data processing MCP server with ShEx/SHACL validation, SPARQL queries, and format conversion. Supports stdio and streamable HTTP transports with full MCP capabilities (tools, prompts, resources, logging, completions, tasks) ## Development From a1c66a8a364f53bd5b44559712ce51018733f681 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 12 Feb 2026 11:29:19 -0500 Subject: [PATCH 033/333] chore: make pre-commit do formatting (#653) --- .githooks/pre-commit | 5 +++++ 1 file changed, 5 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..f4dfc5363 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +cargo +nightly fmt --all + +# stage any formatting changes +git add -u From 61845d61c441aaca289ec0cbb66069f2f428962e Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Thu, 12 Feb 2026 08:30:13 -0800 Subject: [PATCH 034/333] 11-25-2025 compliant Auth (#651) * fix: correct discovery for AS metadata * fix: add commitlint to dev container * feat: add RFC 8707 support for resource parameter * feat: pkce method verification * feat(auth): implement SEP-835 scope handling and 403 upgrade flow - add WWWAuthenticateParams for parsing scope and resource_metadata from headers - add ScopeUpgradeConfig and scope tracking in AuthorizationManager - add InsufficientScopeError and 403 handling in streamable HTTP client - add scope union computation for progressive authorization - export new public types: AuthClient, ScopeUpgradeConfig, WWWAuthenticateParams Co-authored-by: fizy069 * fix: reorg auth tests * feat: add error to www-authenticate header parsing * feat: consider protected resource metadata in scope selection * fix: reorganize auth tests * feat: add examples and docs for updated auth --------- Co-authored-by: fizy069 --- .devcontainer/devcontainer.json | 74 +- crates/rmcp/src/transport.rs | 6 +- crates/rmcp/src/transport/auth.rs | 952 ++++++++++++++---- .../common/reqwest/streamable_http_client.rs | 94 ++ .../src/transport/streamable_http_client.rs | 20 + docs/OAUTH_SUPPORT.md | 93 +- examples/clients/src/auth/oauth_client.rs | 8 +- .../servers/src/complex_auth_streamhttp.rs | 5 +- 8 files changed, 996 insertions(+), 256 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8ac58ca43..4005b2fe0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,41 +1,41 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/rust { - "name": "Rust", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", - "features": { - "ghcr.io/devcontainers/features/node:1": {}, - "ghcr.io/devcontainers/features/python:1": { - "version": "3.10", - "toolsToInstall": "uv" - } - }, - // Configure tool-specific properties. - "customizations": { - "vscode": { - "settings": { - "editor.formatOnSave": true, - "[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer" - } - } - } - }, - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "uv venv" - // Use 'mounts' to make the cargo cache persistent in a Docker Volume. - // "mounts": [ - // { - // "source": "devcontainer-cargo-cache-${devcontainerId}", - // "target": "/usr/local/cargo", - // "type": "volume" - // } - // ] - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" + "name": "Rust", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", + "features": { + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.10", + "toolsToInstall": "uv" + } + }, + // Configure tool-specific properties. + "customizations": { + "vscode": { + "settings": { + "editor.formatOnSave": true, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + } + } + } + }, + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "uv venv && npm install -g @commitlint/config-conventional" + // Use 'mounts' to make the cargo cache persistent in a Docker Volume. + // "mounts": [ + // { + // "source": "devcontainer-cargo-cache-${devcontainerId}", + // "target": "/usr/local/cargo", + // "type": "volume" + // } + // ] + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" } \ No newline at end of file diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 5b9318d96..8228ce7c6 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -107,9 +107,9 @@ pub mod auth; #[cfg(feature = "auth")] #[cfg_attr(docsrs, doc(cfg(feature = "auth")))] pub use auth::{ - AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, CredentialStore, - InMemoryCredentialStore, InMemoryStateStore, StateStore, StoredAuthorizationState, - StoredCredentials, + AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, + CredentialStore, InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore, + StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams, }; // #[cfg(feature = "transport-ws")] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index de2cf5e9d..ad2d69abb 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -23,6 +23,8 @@ const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; pub struct StoredCredentials { pub client_id: String, pub token_response: Option, + #[serde(default)] + pub granted_scopes: Vec, } /// Trait for storing and retrieving OAuth2 credentials @@ -238,6 +240,12 @@ pub enum AuthError { #[error("Registration failed: {0}")] RegistrationFailed(String), + + #[error("Insufficient scope: {required_scope}")] + InsufficientScope { + required_scope: String, + upgrade_url: Option, + }, } /// oauth2 metadata @@ -250,6 +258,7 @@ pub struct AuthorizationMetadata { pub jwks_uri: Option, pub scopes_supported: Option>, pub response_types_supported: Option>, + pub code_challenge_methods_supported: Option>, // allow additional fields #[serde(flatten)] pub additional_fields: HashMap, @@ -259,6 +268,28 @@ pub struct AuthorizationMetadata { struct ResourceServerMetadata { authorization_server: Option, authorization_servers: Option>, + scopes_supported: Option>, +} + +/// Parameters extracted from WWW-Authenticate header +#[derive(Debug, Clone, Default)] +pub struct WWWAuthenticateParams { + pub resource_metadata_url: Option, + pub scope: Option, + pub error: Option, + pub error_description: Option, +} + +impl WWWAuthenticateParams { + /// check if this is an insufficient_scope error + pub fn is_insufficient_scope(&self) -> bool { + self.error.as_deref() == Some("insufficient_scope") + } + + /// check if this is an invalid_token error (expired/revoked) + pub fn is_invalid_token(&self) -> bool { + self.error.as_deref() == Some("invalid_token") + } } /// oauth2 client config @@ -291,6 +322,24 @@ type OAuthClient = oauth2::Client< >; type Credentials = (String, Option); +/// Configuration for scope upgrade behavior +#[derive(Debug, Clone)] +pub struct ScopeUpgradeConfig { + /// Maximum number of scope upgrade attempts before giving up + pub max_upgrade_attempts: u32, + /// Whether to automatically attempt scope upgrades on 403 + pub auto_upgrade: bool, +} + +impl Default for ScopeUpgradeConfig { + fn default() -> Self { + Self { + max_upgrade_attempts: 3, + auto_upgrade: true, + } + } +} + /// oauth2 auth manager pub struct AuthorizationManager { http_client: HttpClient, @@ -299,6 +348,13 @@ pub struct AuthorizationManager { credential_store: Arc, state_store: Arc, base_url: Url, + current_scopes: RwLock>, + scope_upgrade_attempts: RwLock, + scope_upgrade_config: ScopeUpgradeConfig, + /// scopes from the initial 401 WWW-Authenticate header, used by select_scopes() + www_auth_scopes: RwLock>, + /// scopes_supported from protected resource metadata (RFC 9728) + resource_scopes: RwLock>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -373,11 +429,21 @@ impl AuthorizationManager { credential_store: Arc::new(InMemoryCredentialStore::new()), state_store: Arc::new(InMemoryStateStore::new()), base_url, + current_scopes: RwLock::new(Vec::new()), + scope_upgrade_attempts: RwLock::new(0), + scope_upgrade_config: ScopeUpgradeConfig::default(), + www_auth_scopes: RwLock::new(Vec::new()), + resource_scopes: RwLock::new(Vec::new()), }; Ok(manager) } + /// Set the scope upgrade configuration + pub fn set_scope_upgrade_config(&mut self, config: ScopeUpgradeConfig) { + self.scope_upgrade_config = config; + } + /// Set a custom credential store /// /// This allows you to provide your own implementation of credential storage, @@ -426,13 +492,13 @@ impl AuthorizationManager { Ok(()) } - /// discover oauth2 metadata + /// discover oauth2 metadata (per SEP-985: Protected Resource Metadata first, then direct OAuth) pub async fn discover_metadata(&self) -> Result { - if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? { + if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? { return Ok(metadata); } - if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? { + if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? { return Ok(metadata); } @@ -485,15 +551,34 @@ impl AuthorizationManager { self.oauth_client = Some(client_builder); Ok(()) } - /// validate if the server support the response type - fn validate_response_supported(&self, response_type: &str) -> Result<(), AuthError> { - if let Some(metadata) = self.metadata.as_ref() { - if let Some(response_types_supported) = metadata.response_types_supported.as_ref() { - if !response_types_supported.contains(&response_type.to_string()) { - return Err(AuthError::InvalidScope(response_type.to_string())); - } + /// validate authorization server metadata before starting authorization. + fn validate_server_metadata(&self, response_type: &str) -> Result<(), AuthError> { + let Some(metadata) = self.metadata.as_ref() else { + return Ok(()); + }; + + // RFC 8414 RECOMMENDS response_types_supported in the metadata. This field is optional, + // but if present and does not include the flow we use ("code"), bail out early with a clear error. + if let Some(response_types_supported) = metadata.response_types_supported.as_ref() { + if !response_types_supported.contains(&response_type.to_string()) { + return Err(AuthError::InvalidScope(response_type.to_string())); + } + } + + // for PKCE, we always send s256 since oauth 2.1 requires servers to support it, + // but warn if the server metadata suggests otherwise + match &metadata.code_challenge_methods_supported { + Some(methods) if !methods.iter().any(|m| m == "S256") => { + warn!( + ?methods, + "server does not advertise S256 in code_challenge_methods_supported, \ + proceeding with S256 anyway as oauth 2.1 requires it. \ + The server is not compliant with the specification!" + ); } + _ => {} } + Ok(()) } /// dynamic register oauth2 client @@ -512,10 +597,7 @@ impl AuthorizationManager { "Dynamic client registration not supported".to_string(), )); }; - - // RFC 8414 RECOMMENDS response_types_supported in the metadata. This field is optional, - // but if present and does not include the flow we use ("code"), bail out early with a clear error. - self.validate_response_supported("code")?; + self.validate_server_metadata("code")?; let registration_request = ClientRegistrationRequest { client_name: name.to_string(), @@ -604,9 +686,7 @@ impl AuthorizationManager { .oauth_client .as_ref() .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; - - // ensure the server supports the response type we intend to use when metadata is available - self.validate_response_supported("code")?; + self.validate_server_metadata("code")?; // generate pkce challenge let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); @@ -614,7 +694,8 @@ impl AuthorizationManager { // build authorization request let mut auth_request = oauth_client .authorize_url(CsrfToken::new_random) - .set_pkce_challenge(pkce_challenge); + .set_pkce_challenge(pkce_challenge) + .add_extra_param("resource", self.base_url.to_string()); // add request scopes for scope in scopes { @@ -632,6 +713,111 @@ impl AuthorizationManager { Ok(auth_url.to_string()) } + /// get the current granted scopes + pub async fn get_current_scopes(&self) -> Vec { + self.current_scopes.read().await.clone() + } + + /// compute the union of current scopes and required scopes + fn compute_scope_union(current: &[String], required: &str) -> Vec { + let mut scope_set: std::collections::HashSet = current.iter().cloned().collect(); + for scope in required.split_whitespace() { + scope_set.insert(scope.to_string()); + } + scope_set.into_iter().collect() + } + + /// check if a scope upgrade is possible and allowed + pub async fn can_attempt_scope_upgrade(&self) -> bool { + if !self.scope_upgrade_config.auto_upgrade { + return false; + } + let attempts = *self.scope_upgrade_attempts.read().await; + attempts < self.scope_upgrade_config.max_upgrade_attempts + } + + /// select scopes based on SEP-835 priority: + /// 1. scope from WWW-Authenticate header (argument or stored from initial 401 probe) + /// 2. scopes_supported from protected resource metadata (RFC 9728) + /// 3. scopes_supported from authorization server metadata + /// 4. provided default scopes + pub fn select_scopes( + &self, + www_authenticate_scope: Option<&str>, + default_scopes: &[&str], + ) -> Vec { + if let Some(scope) = www_authenticate_scope { + return scope.split_whitespace().map(|s| s.to_string()).collect(); + } + + // use scopes from initial 401 WWW-Authenticate header + if let Ok(guard) = self.www_auth_scopes.try_read() { + if !guard.is_empty() { + return guard.clone(); + } + } + + // use scopes_supported from protected resource metadata (RFC 9728) + if let Ok(guard) = self.resource_scopes.try_read() { + if !guard.is_empty() { + return guard.clone(); + } + } + + // use scopes_supported from authorization server metadata + if let Some(metadata) = &self.metadata { + if let Some(scopes_supported) = &metadata.scopes_supported { + if !scopes_supported.is_empty() { + return scopes_supported.clone(); + } + } + } + + default_scopes.iter().map(|s| s.to_string()).collect() + } + + /// attempt to upgrade scopes after receiving a 403 insufficient_scope error. + /// returns the authorization URL for re-authorization with upgraded scopes. + pub async fn request_scope_upgrade(&self, required_scope: &str) -> Result { + if !self.scope_upgrade_config.auto_upgrade { + return Err(AuthError::InvalidScope( + "Scope upgrade is disabled".to_string(), + )); + } + + let mut attempts = self.scope_upgrade_attempts.write().await; + if *attempts >= self.scope_upgrade_config.max_upgrade_attempts { + return Err(AuthError::InvalidScope(format!( + "Maximum scope upgrade attempts ({}) exceeded", + self.scope_upgrade_config.max_upgrade_attempts + ))); + } + + *attempts += 1; + drop(attempts); + + let current_scopes = self.current_scopes.read().await.clone(); + let upgraded_scopes = Self::compute_scope_union(¤t_scopes, required_scope); + + debug!( + "Requesting scope upgrade: current={:?}, required={}, union={:?}", + current_scopes, required_scope, upgraded_scopes + ); + + let scope_refs: Vec<&str> = upgraded_scopes.iter().map(|s| s.as_str()).collect(); + self.get_authorization_url(&scope_refs).await + } + + /// reset scope upgrade attempt counter + pub async fn reset_scope_upgrade_attempts(&self) { + *self.scope_upgrade_attempts.write().await = 0; + } + + /// get the number of scope upgrade attempts made + pub async fn get_scope_upgrade_attempts(&self) -> u32 { + *self.scope_upgrade_attempts.read().await + } + /// exchange authorization code for access token pub async fn exchange_code_for_token( &self, @@ -666,6 +852,7 @@ impl AuthorizationManager { let token_result = match oauth_client .exchange_code(AuthorizationCode::new(code.to_string())) .set_pkce_verifier(pkce_verifier) + .add_extra_param("resource", self.base_url.to_string()) .request_async(&http_client) .await { @@ -690,11 +877,19 @@ impl AuthorizationManager { debug!("exchange token result: {:?}", token_result); - // Store credentials in the credential store + let granted_scopes: Vec = token_result + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + *self.current_scopes.write().await = granted_scopes.clone(); + *self.scope_upgrade_attempts.write().await = 0; + let client_id = oauth_client.client_id().to_string(); let stored = StoredCredentials { client_id, token_response: Some(token_result.clone()), + granted_scopes, }; self.credential_store.save(stored).await?; @@ -751,10 +946,18 @@ impl AuthorizationManager { .await .map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?; + let granted_scopes: Vec = token_result + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_else(|| self.current_scopes.blocking_read().clone()); + + *self.current_scopes.write().await = granted_scopes.clone(); + let client_id = oauth_client.client_id().to_string(); let stored = StoredCredentials { client_id, token_response: Some(token_result.clone()), + granted_scopes, }; self.credential_store.save(stored).await?; @@ -770,17 +973,31 @@ impl AuthorizationManager { Ok(request.header(AUTHORIZATION, format!("Bearer {}", token))) } - /// handle response, check if need to re-authorize + /// handle response, check if need to re-authorize or scope upgrade pub async fn handle_response( &self, response: reqwest::Response, ) -> Result { if response.status() == StatusCode::UNAUTHORIZED { - // 401 Unauthorized, need to re-authorize - Err(AuthError::AuthorizationRequired) - } else { - Ok(response) + return Err(AuthError::AuthorizationRequired); } + if response.status() == StatusCode::FORBIDDEN { + for value in response.headers().get_all(WWW_AUTHENTICATE).iter() { + let Ok(value_str) = value.to_str() else { + continue; + }; + let params = Self::extract_www_authenticate_params(value_str, &self.base_url); + if params.is_insufficient_scope() { + let required_scope = params.scope.unwrap_or_default(); + return Err(AuthError::InsufficientScope { + required_scope, + upgrade_url: None, + }); + } + } + return Err(AuthError::AuthorizationFailed("Forbidden".to_string())); + } + Ok(response) } /// Generate discovery endpoint URLs following the priority order in spec-2025-11-25 4.3 "Authorization Server Metadata Discovery". @@ -874,6 +1091,13 @@ impl AuthorizationManager { return Ok(None); }; + // store scopes_supported from protected resource metadata for select_scopes() + if let Some(scopes) = resource_metadata.scopes_supported { + if !scopes.is_empty() { + *self.resource_scopes.write().await = scopes; + } + } + let mut candidates = Vec::new(); if let Some(single) = resource_metadata.authorization_server { @@ -973,9 +1197,14 @@ impl AuthorizationManager { let Ok(value_str) = value.to_str() else { continue; }; - if let Some(url) = - Self::extract_resource_metadata_url_from_header(value_str, &self.base_url) - { + let params = Self::extract_www_authenticate_params(value_str, &self.base_url); + if let Some(url) = params.resource_metadata_url { + if let Some(scope) = ¶ms.scope { + debug!("WWW-Authenticate header contains scope: {}", scope); + let scopes: Vec = + scope.split_whitespace().map(|s| s.to_string()).collect(); + *self.www_auth_scopes.write().await = scopes; + } parsed_url = Some(url); break; } @@ -1023,21 +1252,25 @@ impl AuthorizationManager { Ok(Some(metadata)) } - /// Extracts a url following `resource_metadata=` in a header value - fn extract_resource_metadata_url_from_header(header: &str, base_url: &Url) -> Option { + /// extract parameters from WWW-Authenticate header (resource_metadata and scope) + fn extract_www_authenticate_params(header: &str, base_url: &Url) -> WWWAuthenticateParams { + let mut params = WWWAuthenticateParams::default(); let header_lowercase = header.to_ascii_lowercase(); - let fragment_key = "resource_metadata="; - let mut search_offset = 0; - while let Some(pos) = header_lowercase[search_offset..].find(fragment_key) { - let global_pos = search_offset + pos + fragment_key.len(); + // extract resource_metadata + let mut search_offset = 0; + let resource_key = "resource_metadata="; + while let Some(pos) = header_lowercase[search_offset..].find(resource_key) { + let global_pos = search_offset + pos + resource_key.len(); let value_slice = &header[global_pos..]; if let Some((value, consumed)) = Self::parse_next_header_value(value_slice) { if let Ok(url) = Url::parse(&value) { - return Some(url); + params.resource_metadata_url = Some(url); + break; } if let Ok(url) = base_url.join(&value) { - return Some(url); + params.resource_metadata_url = Some(url); + break; } debug!("failed to parse resource metadata value `{value}` as URL"); search_offset = global_pos + consumed; @@ -1047,7 +1280,37 @@ impl AuthorizationManager { } } - None + // extract scope + let scope_key = "scope="; + if let Some(pos) = header_lowercase.find(scope_key) { + let global_pos = pos + scope_key.len(); + let value_slice = &header[global_pos..]; + if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { + params.scope = Some(value); + } + } + + // extract error + let error_key = "error="; + if let Some(pos) = header_lowercase.find(error_key) { + let global_pos = pos + error_key.len(); + let value_slice = &header[global_pos..]; + if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { + params.error = Some(value); + } + } + + // extract error_description + let desc_key = "error_description="; + if let Some(pos) = header_lowercase.find(desc_key) { + let global_pos = pos + desc_key.len(); + let value_slice = &header[global_pos..]; + if let Some((value, _consumed)) = Self::parse_next_header_value(value_slice) { + params.error_description = Some(value); + } + } + + params } /// Parses an authentication parameter value from a `WWW-Authenticate` header fragment. @@ -1164,6 +1427,19 @@ impl AuthorizationSession { }) } + /// create session for scope upgrade flow (existing manager + pre-computed auth url) + pub fn for_scope_upgrade( + auth_manager: AuthorizationManager, + auth_url: String, + redirect_uri: &str, + ) -> Self { + Self { + auth_manager, + auth_url, + redirect_uri: redirect_uri.to_string(), + } + } + /// get client_id and credentials pub async fn get_credentials(&self) -> Result { self.auth_manager.get_credentials().await @@ -1278,9 +1554,17 @@ impl OAuthState { AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?, ); + let granted_scopes: Vec = credentials + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + *manager.current_scopes.write().await = granted_scopes.clone(); + let stored = StoredCredentials { client_id: client_id.to_string(), token_response: Some(credentials), + granted_scopes, }; manager.credential_store.save(stored).await?; @@ -1324,10 +1608,16 @@ impl OAuthState { debug!("start discovery"); let metadata = manager.discover_metadata().await?; manager.metadata = Some(metadata); + let selected_scopes: Vec = if scopes.is_empty() { + manager.select_scopes(None, &[]) + } else { + scopes.iter().map(|s| s.to_string()).collect() + }; + let scope_refs: Vec<&str> = selected_scopes.iter().map(|s| s.as_str()).collect(); debug!("start session"); let session = AuthorizationSession::new( manager, - scopes, + &scope_refs, redirect_uri, client_name, client_metadata_url, @@ -1371,6 +1661,35 @@ impl OAuthState { )) } } + + /// request scope upgrade (Authorized -> Session); returns auth URL to open + pub async fn request_scope_upgrade( + &mut self, + required_scope: &str, + redirect_uri: &str, + ) -> Result { + let placeholder = + OAuthState::Authorized(AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?); + let old = std::mem::replace(self, placeholder); + let OAuthState::Authorized(manager) = old else { + *self = old; + return Err(AuthError::InternalError( + "Not in authorized state".to_string(), + )); + }; + let auth_url = match manager.request_scope_upgrade(required_scope).await { + Ok(url) => url, + Err(e) => { + *self = OAuthState::Authorized(manager); + return Err(e); + } + }; + let session = + AuthorizationSession::for_scope_upgrade(manager, auth_url.clone(), redirect_uri); + *self = OAuthState::Session(session); + Ok(auth_url) + } + /// get current authorization url pub async fn get_authorization_url(&self) -> Result { match self { @@ -1457,101 +1776,27 @@ mod tests { use url::Url; use super::{ - AuthError, AuthorizationManager, InMemoryStateStore, StateStore, StoredAuthorizationState, - is_https_url, + AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore, + ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, }; - // SEP-991: URL-based Client IDs - // Tests adapted from the TypeScript SDK's isHttpsUrl test suite + // -- url helpers -- + #[test] fn test_is_https_url_scenarios() { - // Returns true for valid https url with path assert!(is_https_url("https://example.com/client-metadata.json")); - // Returns true for https url with query params assert!(is_https_url("https://example.com/metadata?version=1")); - // Returns false for https url without path assert!(!is_https_url("https://example.com")); assert!(!is_https_url("https://example.com/")); assert!(!is_https_url("https://")); - // Returns false for http url assert!(!is_https_url("http://example.com/metadata")); - // Returns false for non-url strings assert!(!is_https_url("not a url")); - // Returns false for empty string assert!(!is_https_url("")); - // Returns false for javascript scheme assert!(!is_https_url("javascript:alert(1)")); - // Returns false for data scheme assert!(!is_https_url("data:text/html,")); } - #[test] - fn parses_resource_metadata_parameter() { - let header = r#"Bearer error="invalid_request", error_description="missing token", resource_metadata="https://example.com/.well-known/oauth-protected-resource/api""#; - let base = Url::parse("https://example.com/api").unwrap(); - let parsed = AuthorizationManager::extract_resource_metadata_url_from_header(header, &base); - assert_eq!( - parsed.unwrap().as_str(), - "https://example.com/.well-known/oauth-protected-resource/api" - ); - } - - #[test] - fn parses_relative_resource_metadata_parameter() { - let header = r#"Bearer error="invalid_request", resource_metadata="/.well-known/oauth-protected-resource/api""#; - let base = Url::parse("https://example.com/api").unwrap(); - let parsed = AuthorizationManager::extract_resource_metadata_url_from_header(header, &base); - assert_eq!( - parsed.unwrap().as_str(), - "https://example.com/.well-known/oauth-protected-resource/api" - ); - } - - #[test] - fn parse_auth_param_value_handles_quoted_string() { - let fragment = r#""example", realm="foo""#; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, "example"); - assert_eq!(parsed.1, 9); - } - - #[test] - fn parse_auth_param_value_handles_escaped_quotes_and_whitespace() { - let fragment = r#" "a\"b\\c" ,next=value"#; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, r#"a"b\c"#); - assert_eq!(parsed.1, 12); - } - - #[test] - fn parse_auth_param_value_handles_token_values() { - let fragment = " token,next"; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, "token"); - assert_eq!(parsed.1, 7); - } - - #[test] - fn parse_auth_param_value_handles_semicolon_separated_tokens() { - let fragment = r#" https://example.com/meta; error="invalid_token""#; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, "https://example.com/meta"); - assert_eq!(&fragment[..parsed.1], " https://example.com/meta"); - } - - #[test] - fn parse_auth_param_value_handles_semicolon_after_quoted_value() { - let fragment = r#" "https://example.com/meta"; error="invalid_token""#; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, "https://example.com/meta"); - assert_eq!(&fragment[..parsed.1], r#" "https://example.com/meta""#); - } - - #[test] - fn parse_auth_param_value_returns_none_for_unterminated_quotes() { - let fragment = r#""unterminated,value"#; - assert!(AuthorizationManager::parse_next_header_value(fragment).is_none()); - } + // -- well-known path generation -- #[test] fn well_known_paths_root() { @@ -1589,9 +1834,18 @@ mod tests { ); } + #[test] + fn test_protected_resource_metadata_paths() { + let paths = + AuthorizationManager::well_known_paths("/mcp/example", "oauth-protected-resource"); + assert!(paths.contains(&"/.well-known/oauth-protected-resource/mcp/example".to_string())); + assert!(paths.contains(&"/.well-known/oauth-protected-resource".to_string())); + } + + // -- discovery url generation -- + #[test] fn generate_discovery_urls() { - // Test root URL (no path components): OAuth first, then OpenID Connect let base_url = Url::parse("https://auth.example.com").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); assert_eq!(urls.len(), 2); @@ -1604,7 +1858,6 @@ mod tests { "https://auth.example.com/.well-known/openid-configuration" ); - // Test URL with single path segment: follow spec priority order let base_url = Url::parse("https://auth.example.com/tenant1").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); assert_eq!(urls.len(), 4); @@ -1625,7 +1878,6 @@ mod tests { "https://auth.example.com/.well-known/oauth-authorization-server" ); - // Test URL with path and trailing slash let base_url = Url::parse("https://auth.example.com/v1/mcp/").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); assert_eq!(urls.len(), 4); @@ -1646,7 +1898,6 @@ mod tests { "https://auth.example.com/.well-known/oauth-authorization-server" ); - // Test URL with multiple path segments let base_url = Url::parse("https://auth.example.com/tenant1/subtenant").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); assert_eq!(urls.len(), 4); @@ -1668,57 +1919,181 @@ mod tests { ); } - // StateStore and StoredAuthorizationState tests + #[test] + fn test_discovery_urls_with_path_suffix() { + let base_url = Url::parse("https://mcp.example.com/mcp").unwrap(); + let urls = AuthorizationManager::generate_discovery_urls(&base_url); - #[tokio::test] - async fn test_in_memory_state_store_save_and_load() { - let store = InMemoryStateStore::new(); - let pkce = PkceCodeVerifier::new("test-verifier".to_string()); - let csrf = CsrfToken::new("test-csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); + let canonical_oauth_fallback = + "https://mcp.example.com/.well-known/oauth-authorization-server"; - // Save state - store.save("test-csrf", state).await.unwrap(); + assert!( + urls.iter().any(|u| u.as_str() == canonical_oauth_fallback), + "Expected discovery URLs to include canonical OAuth fallback '{}', but got: {:?}", + canonical_oauth_fallback, + urls.iter().map(|u| u.as_str()).collect::>() + ); + } - // Load state - let loaded = store.load("test-csrf").await.unwrap(); - assert!(loaded.is_some()); - let loaded = loaded.unwrap(); - assert_eq!(loaded.csrf_token, "test-csrf"); - assert_eq!(loaded.pkce_verifier, "test-verifier"); + // -- header value parsing -- + + #[test] + fn parse_auth_param_value_handles_quoted_string() { + let fragment = r#""example", realm="foo""#; + let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); + assert_eq!(parsed.0, "example"); + assert_eq!(parsed.1, 9); } - #[tokio::test] - async fn test_in_memory_state_store_load_nonexistent() { - let store = InMemoryStateStore::new(); - let result = store.load("nonexistent").await.unwrap(); - assert!(result.is_none()); + #[test] + fn parse_auth_param_value_handles_escaped_quotes_and_whitespace() { + let fragment = r#" "a\"b\\c" ,next=value"#; + let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); + assert_eq!(parsed.0, r#"a"b\c"#); + assert_eq!(parsed.1, 12); } - #[tokio::test] - async fn test_in_memory_state_store_delete() { - let store = InMemoryStateStore::new(); - let pkce = PkceCodeVerifier::new("verifier".to_string()); - let csrf = CsrfToken::new("csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); + #[test] + fn parse_auth_param_value_handles_token_values() { + let fragment = " token,next"; + let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); + assert_eq!(parsed.0, "token"); + assert_eq!(parsed.1, 7); + } - store.save("csrf", state).await.unwrap(); - store.delete("csrf").await.unwrap(); + #[test] + fn parse_auth_param_value_handles_semicolon_separated_tokens() { + let fragment = r#" https://example.com/meta; error="invalid_token""#; + let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); + assert_eq!(parsed.0, "https://example.com/meta"); + assert_eq!(&fragment[..parsed.1], " https://example.com/meta"); + } - let result = store.load("csrf").await.unwrap(); - assert!(result.is_none()); + #[test] + fn parse_auth_param_value_handles_semicolon_after_quoted_value() { + let fragment = r#" "https://example.com/meta"; error="invalid_token""#; + let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); + assert_eq!(parsed.0, "https://example.com/meta"); + assert_eq!(&fragment[..parsed.1], r#" "https://example.com/meta""#); + } + + #[test] + fn parse_auth_param_value_returns_none_for_unterminated_quotes() { + let fragment = r#""unterminated,value"#; + assert!(AuthorizationManager::parse_next_header_value(fragment).is_none()); + } + + // -- www-authenticate param extraction -- + + #[test] + fn parses_resource_metadata_parameter() { + let header = r#"Bearer error="invalid_request", error_description="missing token", resource_metadata="https://example.com/.well-known/oauth-protected-resource/api""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + assert_eq!( + params.resource_metadata_url.unwrap().as_str(), + "https://example.com/.well-known/oauth-protected-resource/api" + ); + } + + #[test] + fn parses_relative_resource_metadata_parameter() { + let header = r#"Bearer error="invalid_request", resource_metadata="/.well-known/oauth-protected-resource/api""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + assert_eq!( + params.resource_metadata_url.unwrap().as_str(), + "https://example.com/.well-known/oauth-protected-resource/api" + ); + } + + #[test] + fn extract_www_authenticate_params_with_all_fields() { + let header = r#"Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource", scope="read:data write:data", error_description="token expired""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert_eq!( + params.resource_metadata_url.unwrap().as_str(), + "https://example.com/.well-known/oauth-protected-resource" + ); + assert_eq!(params.scope.unwrap(), "read:data write:data"); + assert_eq!(params.error.unwrap(), "invalid_token"); + assert_eq!(params.error_description.unwrap(), "token expired"); + } + + #[test] + fn extract_www_authenticate_params_insufficient_scope() { + let header = r#"Bearer error="insufficient_scope", scope="admin:write", error_description="Additional file write permission required""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + assert!(params.is_insufficient_scope()); + assert!(!params.is_invalid_token()); + assert_eq!(params.scope.unwrap(), "admin:write"); + assert_eq!( + params.error_description.unwrap(), + "Additional file write permission required" + ); + } + + #[test] + fn extract_www_authenticate_params_with_only_resource_metadata() { + let header = r#"Bearer resource_metadata="/.well-known/oauth-protected-resource""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert_eq!( + params.resource_metadata_url.unwrap().as_str(), + "https://example.com/.well-known/oauth-protected-resource" + ); + assert!(params.scope.is_none()); } + #[test] + fn extract_www_authenticate_params_bare_bearer() { + let header = "Bearer"; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + assert!(params.scope.is_none()); + assert!(params.error.is_none()); + assert!(params.error_description.is_none()); + } + + #[test] + fn extract_www_authenticate_params_error_only() { + let header = r#"Bearer error="invalid_token""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + assert!(params.scope.is_none()); + assert!(params.is_invalid_token()); + assert!(!params.is_insufficient_scope()); + assert!(params.error_description.is_none()); + } + + #[test] + fn extract_www_authenticate_params_with_unquoted_scope() { + let header = r#"Bearer scope=read:data, error="insufficient_scope""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert_eq!(params.scope.unwrap(), "read:data"); + } + + // -- stored authorization state -- + #[test] fn test_stored_authorization_state_serialization() { let pkce = PkceCodeVerifier::new("my-verifier".to_string()); let csrf = CsrfToken::new("my-csrf".to_string()); let state = StoredAuthorizationState::new(&pkce, &csrf); - // Serialize to JSON let json = serde_json::to_string(&state).unwrap(); - - // Deserialize back let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.pkce_verifier, "my-verifier"); @@ -1741,28 +2116,63 @@ mod tests { let csrf = CsrfToken::new("csrf".to_string()); let state = StoredAuthorizationState::new(&pkce, &csrf); - // created_at should be a reasonable timestamp (after year 2020) assert!(state.created_at > 1577836800); // Jan 1, 2020 } + // -- state store -- + + #[tokio::test] + async fn test_in_memory_state_store_save_and_load() { + let store = InMemoryStateStore::new(); + let pkce = PkceCodeVerifier::new("test-verifier".to_string()); + let csrf = CsrfToken::new("test-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save("test-csrf", state).await.unwrap(); + + let loaded = store.load("test-csrf").await.unwrap(); + assert!(loaded.is_some()); + let loaded = loaded.unwrap(); + assert_eq!(loaded.csrf_token, "test-csrf"); + assert_eq!(loaded.pkce_verifier, "test-verifier"); + } + + #[tokio::test] + async fn test_in_memory_state_store_load_nonexistent() { + let store = InMemoryStateStore::new(); + let result = store.load("nonexistent").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_in_memory_state_store_delete() { + let store = InMemoryStateStore::new(); + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + + store.save("csrf", state).await.unwrap(); + store.delete("csrf").await.unwrap(); + + let result = store.load("csrf").await.unwrap(); + assert!(result.is_none()); + } + #[tokio::test] async fn test_in_memory_state_store_overwrite() { let store = InMemoryStateStore::new(); let csrf_key = "same-csrf"; - // Save first state let pkce1 = PkceCodeVerifier::new("verifier-1".to_string()); let csrf1 = CsrfToken::new(csrf_key.to_string()); let state1 = StoredAuthorizationState::new(&pkce1, &csrf1); store.save(csrf_key, state1).await.unwrap(); - // Overwrite with second state let pkce2 = PkceCodeVerifier::new("verifier-2".to_string()); let csrf2 = CsrfToken::new(csrf_key.to_string()); let state2 = StoredAuthorizationState::new(&pkce2, &csrf2); store.save(csrf_key, state2).await.unwrap(); - // Should get the second state let loaded = store.load(csrf_key).await.unwrap().unwrap(); assert_eq!(loaded.pkce_verifier, "verifier-2"); } @@ -1772,7 +2182,6 @@ mod tests { let store = Arc::new(InMemoryStateStore::new()); let mut handles = vec![]; - // Spawn 10 concurrent tasks that each save and load their own state for i in 0..10 { let store = Arc::clone(&store); let handle = tokio::spawn(async move { @@ -1794,36 +2203,15 @@ mod tests { handles.push(handle); } - // Wait for all tasks to complete for handle in handles { handle.await.unwrap(); } } - #[test] - fn test_discovery_urls_with_path_suffix() { - // When the base URL has a path suffix (e.g., /mcp), the discovery should - // eventually fall back to checking /.well-known/oauth-authorization-server - // at the root, not just /.well-known/oauth-authorization-server/{path}. - let base_url = Url::parse("https://mcp.example.com/mcp").unwrap(); - let urls = AuthorizationManager::generate_discovery_urls(&base_url); - - let canonical_oauth_fallback = - "https://mcp.example.com/.well-known/oauth-authorization-server"; - - assert!( - urls.iter().any(|u| u.as_str() == canonical_oauth_fallback), - "Expected discovery URLs to include canonical OAuth fallback '{}', but got: {:?}", - canonical_oauth_fallback, - urls.iter().map(|u| u.as_str()).collect::>() - ); - } - #[tokio::test] async fn test_custom_state_store_with_authorization_manager() { use std::sync::atomic::{AtomicUsize, Ordering}; - // Custom state store that tracks calls #[derive(Debug, Default)] struct TrackingStateStore { inner: InMemoryStateStore, @@ -1857,7 +2245,6 @@ mod tests { } } - // Verify custom store works standalone let store = TrackingStateStore::default(); let pkce = PkceCodeVerifier::new("test-verifier".to_string()); let csrf = CsrfToken::new("test-csrf".to_string()); @@ -1872,8 +2259,211 @@ mod tests { store.delete("test-csrf").await.unwrap(); assert_eq!(store.delete_count.load(Ordering::SeqCst), 1); - // Verify custom store can be set on AuthorizationManager let mut manager = AuthorizationManager::new("http://localhost").await.unwrap(); manager.set_state_store(TrackingStateStore::default()); } + + // -- metadata deserialization -- + + #[test] + fn test_code_challenge_methods_supported_deserialization() { + let json = r#"{ + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "code_challenge_methods_supported": ["S256", "plain"] + }"#; + let metadata: AuthorizationMetadata = serde_json::from_str(json).unwrap(); + let methods = metadata.code_challenge_methods_supported.unwrap(); + assert!(methods.contains(&"S256".to_string())); + assert!(methods.contains(&"plain".to_string())); + } + + #[test] + fn test_code_challenge_methods_supported_missing_from_json() { + let json = r#"{ + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }"#; + let metadata: AuthorizationMetadata = serde_json::from_str(json).unwrap(); + assert!(metadata.code_challenge_methods_supported.is_none()); + } + + // -- server validation -- + + #[tokio::test] + async fn test_validate_as_metadata_rejects_unsupported_response_type() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + response_types_supported: Some(vec!["token".to_string()]), + ..Default::default() + }; + manager.set_metadata(metadata); + assert!(manager.validate_server_metadata("code").is_err()); + } + + #[tokio::test] + async fn test_validate_as_metadata_passes_without_pkce_s256() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: Some(vec!["plain".to_string()]), + ..Default::default() + }; + manager.set_metadata(metadata); + assert!(manager.validate_server_metadata("code").is_ok()); + } + + #[tokio::test] + async fn test_validate_as_metadata_passes_without_metadata() { + let manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + assert!(manager.validate_server_metadata("code").is_ok()); + } + + // -- authorization flow -- + + #[tokio::test] + async fn test_authorization_url_is_valid() { + let base_url = "https://mcp.example.com/api"; + let auth_endpoint = "https://auth.example.com/authorize"; + let mut manager = AuthorizationManager::new(base_url).await.unwrap(); + + let metadata = AuthorizationMetadata { + authorization_endpoint: auth_endpoint.to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: None, + issuer: None, + jwks_uri: None, + scopes_supported: None, + response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), + additional_fields: std::collections::HashMap::new(), + }; + manager.set_metadata(metadata); + manager.configure_client_id("test-client-id").unwrap(); + + let auth_url = manager + .get_authorization_url(&["read", "write"]) + .await + .unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + + assert!(auth_url.starts_with(auth_endpoint)); + + let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); + + assert_eq!( + params.get("response_type").map(|v| v.as_ref()), + Some("code") + ); + assert_eq!( + params.get("client_id").map(|v| v.as_ref()), + Some("test-client-id") + ); + assert!(params.contains_key("state")); + assert_eq!( + params.get("redirect_uri").map(|v| v.as_ref()), + Some(base_url) + ); + assert!(params.contains_key("code_challenge")); + assert_eq!( + params.get("code_challenge_method").map(|v| v.as_ref()), + Some("S256") + ); + assert_eq!(params.get("resource").map(|v| v.as_ref()), Some(base_url)); + + let scope = params + .get("scope") + .map(|v| v.to_string()) + .unwrap_or_default(); + assert!(scope.contains("read")); + assert!(scope.contains("write")); + } + + // -- scope management -- + + #[test] + fn compute_scope_union_adds_new_scopes() { + let current = vec!["read".to_string(), "write".to_string()]; + let result = AuthorizationManager::compute_scope_union(¤t, "admin delete"); + + assert!(result.contains(&"read".to_string())); + assert!(result.contains(&"write".to_string())); + assert!(result.contains(&"admin".to_string())); + assert!(result.contains(&"delete".to_string())); + assert_eq!(result.len(), 4); + } + + #[test] + fn compute_scope_union_deduplicates() { + let current = vec!["read".to_string(), "write".to_string()]; + let result = AuthorizationManager::compute_scope_union(¤t, "read admin"); + + assert!(result.contains(&"read".to_string())); + assert!(result.contains(&"write".to_string())); + assert!(result.contains(&"admin".to_string())); + assert_eq!(result.len(), 3); + } + + #[test] + fn compute_scope_union_handles_empty_current() { + let current: Vec = vec![]; + let result = AuthorizationManager::compute_scope_union(¤t, "read write"); + + assert!(result.contains(&"read".to_string())); + assert!(result.contains(&"write".to_string())); + assert_eq!(result.len(), 2); + } + + #[test] + fn scope_upgrade_config_default_values() { + let config = ScopeUpgradeConfig::default(); + assert_eq!(config.max_upgrade_attempts, 3); + assert!(config.auto_upgrade); + } + + #[tokio::test] + async fn authorization_manager_tracks_scope_upgrade_attempts() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + + assert_eq!(manager.get_scope_upgrade_attempts().await, 0); + + *manager.scope_upgrade_attempts.write().await = 2; + assert_eq!(manager.get_scope_upgrade_attempts().await, 2); + + manager.reset_scope_upgrade_attempts().await; + assert_eq!(manager.get_scope_upgrade_attempts().await, 0); + } + + #[tokio::test] + async fn authorization_manager_can_attempt_scope_upgrade_respects_config() { + let mut manager = AuthorizationManager::new("http://localhost").await.unwrap(); + + assert!(manager.can_attempt_scope_upgrade().await); + + manager.set_scope_upgrade_config(ScopeUpgradeConfig { + max_upgrade_attempts: 3, + auto_upgrade: false, + }); + assert!(!manager.can_attempt_scope_upgrade().await); + + manager.set_scope_upgrade_config(ScopeUpgradeConfig { + max_upgrade_attempts: 2, + auto_upgrade: true, + }); + *manager.scope_upgrade_attempts.write().await = 2; + assert!(!manager.can_attempt_scope_upgrade().await); + + *manager.scope_upgrade_attempts.write().await = 1; + assert!(manager.can_attempt_scope_upgrade().await); + } } diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 0ecbad20d..cc26bdc94 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -120,6 +120,22 @@ impl StreamableHttpClient for reqwest::Client { })); } } + if response.status() == reqwest::StatusCode::FORBIDDEN { + if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); + } + } let status = response.status(); if matches!( status, @@ -197,3 +213,81 @@ impl StreamableHttpClientTransport { StreamableHttpClientTransport::with_client(reqwest::Client::default(), config) } } + +/// extract scope parameter from WWW-Authenticate header +fn extract_scope_from_header(header: &str) -> Option { + let header_lowercase = header.to_ascii_lowercase(); + let scope_key = "scope="; + + if let Some(pos) = header_lowercase.find(scope_key) { + let start = pos + scope_key.len(); + let value_slice = &header[start..]; + + if let Some(stripped) = value_slice.strip_prefix('"') { + if let Some(end_quote) = stripped.find('"') { + return Some(stripped[..end_quote].to_string()); + } + } else { + let end = value_slice + .find(|c: char| c == ',' || c == ';' || c.is_whitespace()) + .unwrap_or(value_slice.len()); + if end > 0 { + return Some(value_slice[..end].to_string()); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::extract_scope_from_header; + use crate::transport::streamable_http_client::InsufficientScopeError; + + #[test] + fn extract_scope_quoted() { + let header = r#"Bearer error="insufficient_scope", scope="files:read files:write""#; + assert_eq!( + extract_scope_from_header(header), + Some("files:read files:write".to_string()) + ); + } + + #[test] + fn extract_scope_unquoted() { + let header = r#"Bearer scope=read:data, error="insufficient_scope""#; + assert_eq!( + extract_scope_from_header(header), + Some("read:data".to_string()) + ); + } + + #[test] + fn extract_scope_missing() { + let header = r#"Bearer error="invalid_token""#; + assert_eq!(extract_scope_from_header(header), None); + } + + #[test] + fn extract_scope_empty_header() { + assert_eq!(extract_scope_from_header("Bearer"), None); + } + + #[test] + fn insufficient_scope_error_can_upgrade() { + let with_scope = InsufficientScopeError { + www_authenticate_header: "Bearer scope=\"admin\"".to_string(), + required_scope: Some("admin".to_string()), + }; + assert!(with_scope.can_upgrade()); + assert_eq!(with_scope.get_required_scope(), Some("admin")); + + let without_scope = InsufficientScopeError { + www_authenticate_header: "Bearer error=\"insufficient_scope\"".to_string(), + required_scope: None, + }; + assert!(!without_scope.can_upgrade()); + assert_eq!(without_scope.get_required_scope(), None); + } +} diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 35140c1b2..550d261b6 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -24,6 +24,24 @@ pub struct AuthRequiredError { pub www_authenticate_header: String, } +#[derive(Debug)] +pub struct InsufficientScopeError { + pub www_authenticate_header: String, + pub required_scope: Option, +} + +impl InsufficientScopeError { + /// check if scope upgrade is possible (i.e., we know what scope is required) + pub fn can_upgrade(&self) -> bool { + self.required_scope.is_some() + } + + /// get the required scope for upgrade + pub fn get_required_scope(&self) -> Option<&str> { + self.required_scope.as_deref() + } +} + #[derive(Error, Debug)] pub enum StreamableHttpError { #[error("SSE error: {0}")] @@ -56,6 +74,8 @@ pub enum StreamableHttpError { Auth(#[from] crate::transport::auth::AuthError), #[error("Auth required")] AuthRequired(AuthRequiredError), + #[error("Insufficient scope")] + InsufficientScope(InsufficientScopeError), } #[derive(Debug, Clone, Error)] diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 3142c62b1..b0b59f9f7 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -1,13 +1,17 @@ # Model Context Protocol OAuth Authorization -This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP 2025-03-26 Authorization Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization/). +This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP 2025-11-25 Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization/). ## Features -- Full support for OAuth 2.1 authorization flow -- PKCE support for enhanced security -- Authorization server metadata discovery -- Dynamic client registration +- Full support for OAuth 2.1 authorization flow with PKCE (S256) +- RFC 8707 resource parameter binding +- Protected Resource Metadata discovery (RFC 9728) +- Authorization Server Metadata discovery (RFC 8414 + OpenID Connect) +- Dynamic client registration (RFC 7591) +- Client ID Metadata Documents (CIMD) (SEP-991 / Client ID Metadata Documents ) +- Scope selection from WWW-Authenticate, Protected Resource Metadata, and AS metadata +- Scope upgrade on 403 insufficient_scope (SEP-835) - Automatic token refresh - Authorized HTTP Client implementation @@ -24,32 +28,43 @@ rmcp = { version = "0.1", features = ["auth", "transport-streamable-http-client- ### 2. Use OAuthState +The `OAuthState` state machine manages the full authorization lifecycle. When no +scopes are provided, the SDK automatically selects scopes from the server's +WWW-Authenticate header, Protected Resource Metadata, or AS metadata. + ```rust ignore - // Initialize oauth state machine + // initialize oauth state machine let mut oauth_state = OAuthState::new(&server_url, None) .await .context("Failed to initialize oauth state machine")?; + + // start authorization - pass empty scopes to let the SDK auto-select oauth_state - .start_authorization(&["mcp", "profile", "email"], MCP_REDIRECT_URI) + .start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client")) .await .context("Failed to start authorization")?; +``` +If you know the scopes you need, you can still pass them explicitly: + +```rust ignore + oauth_state + .start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client")) + .await + .context("Failed to start authorization")?; ``` -### 3. Get authorization url and do callback +### 3. Get authorization url and handle callback ```rust ignore - // Get authorization URL and guide user to open it + // get authorization URL and guide user to open it let auth_url = oauth_state.get_authorization_url().await?; println!("Please open the following URL in your browser for authorization:\n{}", auth_url); - // Handle callback - In real applications, this is typically done in a callback server + // handle callback - in real applications, this is typically done in a callback server let auth_code = "Authorization code (`code` param) obtained from browser after user authorization"; let csrf_token = "CSRF token (`state` param) obtained from browser after user authorization"; - let credentials = oauth_state.handle_callback(auth_code, csrf_token).await?; - - println!("Authorization successful, access token: {}", credentials.access_token); - + oauth_state.handle_callback(auth_code, csrf_token).await?; ``` ### 4. Use Authorized Streamable HTTP Transport and create client @@ -64,15 +79,27 @@ rmcp = { version = "0.1", features = ["auth", "transport-streamable-http-client- StreamableHttpClientTransportConfig::with_uri(MCP_SERVER_URL), ); - // Create client and connect to MCP server + // create client and connect to MCP server let client_service = ClientInfo::default(); let client = client_service.serve(transport).await?; ``` -### 5. Use Authorized HTTP Client after authorized +### 5. Handle scope upgrades + +If a server returns 403 with `insufficient_scope`, you can request a scope +upgrade. The SDK computes the union of current and required scopes and +transitions back to the session state for re-authorization. ```rust ignore - let client = oauth_state.to_authorized_http_client().await?; + match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await { + Ok(auth_url) => { + // open auth_url in browser, handle callback as before + println!("Re-authorize at: {}", auth_url); + } + Err(e) => { + eprintln!("Scope upgrade failed: {}", e); + } + } ``` ## Complete Examples @@ -92,19 +119,24 @@ cargo run -p mcp-client-examples --example clients_oauth_client ## Authorization Flow Description -1. **Metadata Discovery**: Client attempts to get authorization server metadata from `/.well-known/oauth-authorization-server` -2. **Client Registration**: If supported, client dynamically registers itself -3. **Authorization Request**: Build authorization URL with PKCE and guide user to access -4. **Authorization Code Exchange**: After user authorization, exchange authorization code for access token -5. **Token Usage**: Use access token for API calls -6. **Token Refresh**: Automatically use refresh token to get new access token when current one expires +1. **Resource Metadata Discovery**: Client probes the server and extracts `WWW-Authenticate` parameters including `resource_metadata` URL and `scope` +2. **Protected Resource Metadata**: Client fetches resource server metadata (RFC 9728) to find authorization server(s) and supported scopes +3. **AS Metadata Discovery**: Client discovers authorization server metadata via RFC 8414 and OpenID Connect well-known endpoints +4. **Client Registration**: If supported, client dynamically registers itself (or uses URL-based Client ID via SEP-991) +5. **Scope Selection**: SDK picks scopes from WWW-Authenticate > PRM > AS metadata > caller defaults +6. **Authorization Request**: Build authorization URL with PKCE (S256) and RFC 8707 resource parameter +7. **Authorization Code Exchange**: After user authorization, exchange code for access token (with resource parameter) +8. **Token Usage**: Use access token for API calls via `AuthClient` or `AuthorizedHttpClient` +9. **Token Refresh**: Automatically use refresh token to get new access token when current one expires +10. **Scope Upgrade**: On 403 insufficient_scope, compute scope union and re-authorize with upgraded scopes ## Security Considerations -- All tokens are securely stored in memory -- PKCE implementation prevents authorization code interception attacks -- Automatic token refresh support reduces user intervention -- Only accepts HTTPS connections or secure local callback URIs +- **PKCE S256 always enforced**: never falls back to `plain` or no challenge. OAuth 2.1 mandates S256 as Mandatory To Implement for servers. +- **RFC 8707 resource binding**: authorization and token requests include the `resource` parameter to bind tokens to the protected resource +- All tokens are securely stored in memory (custom credential stores supported) +- Automatic token refresh reduces user intervention +- Server metadata validation warns on non-compliant configurations but proceeds where relatively safe ## Troubleshooting @@ -114,10 +146,15 @@ If you encounter authorization issues, check the following: 2. Verify callback URI matches server's allowed redirect URIs 3. Check network connection and firewall settings 4. Verify server supports metadata discovery or dynamic client registration +5. If PKCE fails, the server may not support S256 (non-compliant with OAuth 2.1) +6. Check `tracing` logs at debug level for detailed discovery and validation info ## References -- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization/) +- [MCP Authorization Specification (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization/) - [OAuth 2.1 Specification Draft](https://oauth.net/2.1/) - [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) - [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591) +- [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) +- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +- [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636) diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 4f94a3ced..456f32698 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -114,14 +114,16 @@ async fn main() -> Result<()> { client_metadata_url ); - // Initialize oauth state machine + // initialize oauth state machine let mut oauth_state = OAuthState::new(&server_url, None) .await .context("Failed to initialize oauth state machine")?; - // Use CIMD (SEP-991) with client metadata URL + // use CIMD (SEP-991) with client metadata URL. + // passing empty scopes lets the SDK auto-select from the server's + // WWW-Authenticate header, Protected Resource Metadata, or AS metadata. oauth_state .start_authorization_with_metadata_url( - &["mcp", "profile", "email"], + &[], MCP_REDIRECT_URI, Some("Test MCP Client"), Some(&client_metadata_url), diff --git a/examples/servers/src/complex_auth_streamhttp.rs b/examples/servers/src/complex_auth_streamhttp.rs index 33fa445c3..84b68d9c6 100644 --- a/examples/servers/src/complex_auth_streamhttp.rs +++ b/examples/servers/src/complex_auth_streamhttp.rs @@ -520,16 +520,13 @@ async fn oauth_authorization_server() -> impl IntoResponse { "response_types_supported".into(), Value::Array(vec![Value::String("code".into())]), ); - additional_fields.insert( - "code_challenge_methods_supported".into(), - Value::Array(vec![Value::String("S256".into())]), - ); let metadata = AuthorizationMetadata { authorization_endpoint: format!("http://{}/oauth/authorize", BIND_ADDRESS), token_endpoint: format!("http://{}/oauth/token", BIND_ADDRESS), scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]), registration_endpoint: Some(format!("http://{}/oauth/register", BIND_ADDRESS)), response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), issuer: Some(BIND_ADDRESS.to_string()), jwks_uri: Some(format!("http://{}/oauth/jwks", BIND_ADDRESS)), additional_fields, From bb534a7a68933b587b03167431fa7dc0fcd6d40e Mon Sep 17 00:00:00 2001 From: Andrew Gazelka Date: Thu, 12 Feb 2026 09:00:47 -0800 Subject: [PATCH 035/333] refactor: remove unused axum dependency from server-side-http feature (#642) * refactor: remove unused axum dependency from server-side-http feature The `server-side-http` feature included `dep:axum` but axum was never actually used in the rmcp library source code (0 references found). The `StreamableHttpService` is a tower service that works with any HTTP server framework. Users can choose to use: - axum (via `Router::nest_service()` or `fallback_service()`) - hyper directly (via `hyper_util::service::TowerToHyperService`) - any other tower-compatible HTTP server This change removes the unnecessary transitive dependency, giving users more flexibility in their choice of HTTP server framework. Examples that use axum already have their own explicit axum dependency in their Cargo.toml, so they continue to work unchanged. * refactor: move axum to dev-dependencies with minimal features - Remove axum from library dependencies (not used in library source) - Add axum to dev-dependencies for tests with minimal features: default-features = false, features = ["http1", "tokio"] - Examples have their own axum dependency and are unaffected This addresses review feedback from @ofek to use minimal features, while ensuring axum is only bundled for running rmcp's own tests, not for downstream users. --- crates/rmcp/Cargo.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index cdcdddbf7..2b08f2929 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -54,7 +54,6 @@ process-wrap = { version = "9.0", features = ["tokio1"], optional = true } # tokio-tungstenite ={ version = "0.26", optional = true } # for http-server transport -axum = { version = "0.8", features = [], optional = true } rand = { version = "0.9", optional = true } tokio-stream = { version = "0.1", optional = true } uuid = { version = "1", features = ["v4"], optional = true } @@ -99,7 +98,6 @@ server-side-http = [ "dep:http-body-util", "dep:bytes", "dep:sse-stream", - "dep:axum", "tower", ] @@ -136,7 +134,7 @@ schemars = ["dep:schemars"] [dev-dependencies] tokio = { version = "1", features = ["full"] } schemars = { version = "1.1.0", features = ["chrono04"] } - +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } anyhow = "1.0" tracing-subscriber = { version = "0.3", features = [ "env-filter", From d9a55609531545b656b584d46b912cba365585f3 Mon Sep 17 00:00:00 2001 From: Anar Azadaliyev Date: Fri, 13 Feb 2026 16:56:07 +0200 Subject: [PATCH 036/333] feat(auth): add token_endpoint_auth_method to OAuthClientConfig (#648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): add token_endpoint_auth_method to OAuthClientConfig Some OAuth providers (e.g. HubSpot) require client credentials to be sent as POST body parameters (client_secret_post) instead of via HTTP Basic Auth header. The oauth2 crate defaults to BasicAuth, and rmcp had no way to override this, causing TokenExchangeFailed errors. Add an optional `token_endpoint_auth_method` field to OAuthClientConfig that accepts "client_secret_post" (RequestBody) and "client_secret_basic" (BasicAuth). Unknown values are silently ignored, preserving the default. Co-Authored-By: Claude Opus 4.6 * refactor(auth): derive token_endpoint_auth_method from server metadata Move auth method selection from per-client config to server's AuthorizationMetadata, which is the correct OAuth 2.0 approach. Servers like HubSpot advertise token_endpoint_auth_methods_supported in their metadata; reading it from there avoids manual configuration and prevents TokenExchangeFailed errors with non-BasicAuth providers. Co-Authored-By: Claude Opus 4.6 * refactor(auth): read token_endpoint_auth_methods_supported from additional_fields Move token_endpoint_auth_methods_supported out of AuthorizationMetadata as an explicit field and read it from the serde(flatten) additional_fields HashMap instead. This avoids serializing `null` when the field is absent, which broke Zod validation in downstream consumers like MCP Inspector. Co-Authored-By: Claude Opus 4.6 * feat(auth): prefer basic auth when both methods supported and improve test assertions When token_endpoint_auth_methods_supported contains both client_secret_post and client_secret_basic, default to basic auth per RFC 6749 §2.3.1. Update configure_client tests to assert actual AuthType instead of is_some(). Co-Authored-By: Claude Opus 4.6 * style(auth): apply cargo fmt formatting * style(auth): apply nightly cargo fmt import grouping * revert: undo .gitignore change --------- Co-authored-by: Anar Azadaliyev Co-authored-by: Claude Opus 4.6 --- crates/rmcp/src/transport/auth.rs | 142 +++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index ad2d69abb..9c9a1d06c 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use async_trait::async_trait; use oauth2::{ - AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, + AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, TokenResponse, TokenUrl, basic::{BasicClient, BasicTokenType}, @@ -548,6 +548,23 @@ impl AuthorizationManager { client_builder = client_builder.set_client_secret(ClientSecret::new(secret)); } + let uses_secret_post = metadata + .additional_fields + .get("token_endpoint_auth_methods_supported") + .and_then(|v| v.as_array()) + .map(|arr| { + let has_basic = arr + .iter() + .any(|m| m.as_str() == Some("client_secret_basic")); + let has_post = arr.iter().any(|m| m.as_str() == Some("client_secret_post")); + has_post && !has_basic + }) + .unwrap_or(false); + + if uses_secret_post { + client_builder = client_builder.set_auth_type(AuthType::RequestBody); + } + self.oauth_client = Some(client_builder); Ok(()) } @@ -1770,14 +1787,14 @@ impl OAuthState { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{collections::HashMap, sync::Arc}; - use oauth2::{CsrfToken, PkceCodeVerifier}; + use oauth2::{AuthType, CsrfToken, PkceCodeVerifier}; use url::Url; use super::{ AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore, - ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, + OAuthClientConfig, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, }; // -- url helpers -- @@ -2263,6 +2280,123 @@ mod tests { manager.set_state_store(TrackingStateStore::default()); } + /// Helper: create an AuthorizationManager with minimal metadata so + /// `configure_client` can be exercised without a live server. + async fn manager_with_metadata( + metadata_override: Option, + ) -> AuthorizationManager { + let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap(); + mgr.set_metadata(metadata_override.unwrap_or(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + ..Default::default() + })); + mgr + } + + fn test_client_config() -> OAuthClientConfig { + OAuthClientConfig { + client_id: "my-client".to_string(), + client_secret: Some("my-secret".to_string()), + scopes: vec![], + redirect_uri: "http://localhost/callback".to_string(), + } + } + + #[tokio::test] + async fn test_configure_client_uses_client_secret_post_from_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_post"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::RequestBody + )); + } + + #[tokio::test] + async fn test_configure_client_defaults_to_basic_auth() { + let mut mgr = manager_with_metadata(None).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::BasicAuth + )); + } + + #[tokio::test] + async fn test_configure_client_with_explicit_basic_in_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::BasicAuth + )); + } + + #[tokio::test] + async fn test_configure_client_ignores_unsupported_auth_methods_in_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["private_key_jwt"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + // Unsupported method should fall through to default (basic auth) + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::BasicAuth + )); + } + + #[tokio::test] + async fn test_configure_client_prefers_basic_when_both_methods_supported() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_post", "client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!( + mgr.oauth_client.as_ref().unwrap().auth_type(), + AuthType::BasicAuth + )); + } // -- metadata deserialization -- #[test] From 70f6380b48e45892bbf8bc638a7478b7c7b82d18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:24:17 -0500 Subject: [PATCH 037/333] chore(deps): update rand requirement from 0.9 to 0.10 (#650) * chore(deps): update rand requirement from 0.9 to 0.10 Updates the requirements on [rand](https://github.com/rust-random/rand) to permit the latest version. - [Release notes](https://github.com/rust-random/rand/releases) - [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-random/rand/compare/rand_core-0.9.1...0.10.0) --- updated-dependencies: - dependency-name: rand dependency-version: 0.10.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * fix: update rand import from Rng to RngExt for rand 0.10 compatibility In rand 0.10, the Rng trait was renamed to RngExt. This updates the imports in the example servers to use the new trait name. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alex Hancock --- crates/rmcp/Cargo.toml | 2 +- examples/clients/Cargo.toml | 2 +- examples/servers/Cargo.toml | 2 +- examples/servers/src/cimd_auth_streamhttp.rs | 2 +- examples/servers/src/complex_auth_streamhttp.rs | 2 +- examples/transport/Cargo.toml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 2b08f2929..f109a9f01 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -54,7 +54,7 @@ process-wrap = { version = "9.0", features = ["tokio1"], optional = true } # tokio-tungstenite ={ version = "0.26", optional = true } # for http-server transport -rand = { version = "0.9", optional = true } +rand = { version = "0.10", optional = true } tokio-stream = { version = "0.1", optional = true } uuid = { version = "1", features = ["v4"], optional = true } http-body = { version = "1", optional = true } diff --git a/examples/clients/Cargo.toml b/examples/clients/Cargo.toml index e086e8111..078a9d584 100644 --- a/examples/clients/Cargo.toml +++ b/examples/clients/Cargo.toml @@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -rand = "0.9" +rand = "0.10" futures = "0.3" anyhow = "1.0" url = "2.4" diff --git a/examples/servers/Cargo.toml b/examples/servers/Cargo.toml index 4a4b06553..1db8ceeeb 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -32,7 +32,7 @@ tracing-subscriber = { version = "0.3", features = [ "fmt", ] } futures = "0.3" -rand = { version = "0.9", features = ["std"] } +rand = { version = "0.10", features = ["std"] } axum = { version = "0.8", features = ["macros"] } schemars = "1.0" reqwest = { version = "0.12", features = ["json"] } diff --git a/examples/servers/src/cimd_auth_streamhttp.rs b/examples/servers/src/cimd_auth_streamhttp.rs index 73eab5e6b..7b402c9fb 100644 --- a/examples/servers/src/cimd_auth_streamhttp.rs +++ b/examples/servers/src/cimd_auth_streamhttp.rs @@ -13,7 +13,7 @@ use axum::{ response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; -use rand::{Rng, distr::Alphanumeric}; +use rand::{RngExt, distr::Alphanumeric}; use rmcp::transport::{ StreamableHttpServerConfig, streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, diff --git a/examples/servers/src/complex_auth_streamhttp.rs b/examples/servers/src/complex_auth_streamhttp.rs index 84b68d9c6..4afacf8d2 100644 --- a/examples/servers/src/complex_auth_streamhttp.rs +++ b/examples/servers/src/complex_auth_streamhttp.rs @@ -11,7 +11,7 @@ use axum::{ response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; -use rand::{Rng, distr::Alphanumeric}; +use rand::{RngExt, distr::Alphanumeric}; use rmcp::transport::{ StreamableHttpServerConfig, auth::{AuthorizationMetadata, ClientRegistrationResponse, OAuthClientConfig}, diff --git a/examples/transport/Cargo.toml b/examples/transport/Cargo.toml index 7017bdca3..9396b4d37 100644 --- a/examples/transport/Cargo.toml +++ b/examples/transport/Cargo.toml @@ -36,7 +36,7 @@ tracing-subscriber = { version = "0.3", features = [ "fmt", ] } futures = "0.3" -rand = { version = "0.9" } +rand = { version = "0.10" } schemars = { version = "1.0", optional = true } hyper = { version = "1", features = ["client", "server", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } From 016b7d3bfab2607c0151a87c3f03888595c1f26e Mon Sep 17 00:00:00 2001 From: Arc <29599723+Arichy@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:28:55 +0800 Subject: [PATCH 038/333] feat: add support for custom HTTP headers in StreamableHttpClient (#655) * feat: add support for custom HTTP headers in StreamableHttpClient * feat: implement reserved header checks for custom HTTP headers in StreamableHttpClient --- crates/rmcp/Cargo.toml | 10 + crates/rmcp/src/transport/auth.rs | 8 +- .../common/auth/streamable_http_client.rs | 7 +- .../rmcp/src/transport/common/http_header.rs | 1 + .../common/reqwest/streamable_http_client.rs | 28 +- .../src/transport/streamable_http_client.rs | 45 +- crates/rmcp/tests/test_custom_headers.rs | 531 ++++++++++++++++++ 7 files changed, 622 insertions(+), 8 deletions(-) create mode 100644 crates/rmcp/tests/test_custom_headers.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index f109a9f01..8a672cfb4 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -230,3 +230,13 @@ path = "tests/test_sampling.rs" name = "test_close_connection" required-features = ["server", "client"] path = "tests/test_close_connection.rs" + +[[test]] +name = "test_custom_headers" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] +path = "tests/test_custom_headers.rs" diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 9c9a1d06c..7099ff7e6 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -16,6 +16,8 @@ use thiserror::Error; use tokio::sync::{Mutex, RwLock}; use tracing::{debug, error, warn}; +use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; + const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; /// Stored credentials for OAuth2 authorization @@ -1068,7 +1070,7 @@ impl AuthorizationManager { let response = match self .http_client .get(discovery_url.clone()) - .header("MCP-Protocol-Version", "2024-11-05") + .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") .send() .await { @@ -1188,7 +1190,7 @@ impl AuthorizationManager { let response = match self .http_client .get(url.clone()) - .header("MCP-Protocol-Version", "2024-11-05") + .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") .send() .await { @@ -1241,7 +1243,7 @@ impl AuthorizationManager { let response = match self .http_client .get(resource_metadata_url.clone()) - .header("MCP-Protocol-Version", "2024-11-05") + .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") .send() .await { diff --git a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs index 49ebefcd6..35e3ed5a0 100644 --- a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs @@ -1,3 +1,7 @@ +use std::collections::HashMap; + +use http::{HeaderName, HeaderValue}; + use crate::transport::{ auth::AuthClient, streamable_http_client::{StreamableHttpClient, StreamableHttpError}, @@ -47,6 +51,7 @@ where message: crate::model::ClientJsonRpcMessage, session_id: Option>, mut auth_token: Option, + custom_headers: HashMap, ) -> Result< crate::transport::streamable_http_client::StreamableHttpPostResponse, StreamableHttpError, @@ -55,7 +60,7 @@ where auth_token = Some(self.get_access_token().await?); } self.http_client - .post_message(uri, message, session_id, auth_token) + .post_message(uri, message, session_id, auth_token, custom_headers) .await } } diff --git a/crates/rmcp/src/transport/common/http_header.rs b/crates/rmcp/src/transport/common/http_header.rs index 84bc7bfb2..441753260 100644 --- a/crates/rmcp/src/transport/common/http_header.rs +++ b/crates/rmcp/src/transport/common/http_header.rs @@ -1,4 +1,5 @@ pub const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; pub const HEADER_LAST_EVENT_ID: &str = "Last-Event-Id"; +pub const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version"; pub const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; pub const JSON_MIME_TYPE: &str = "application/json"; diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index cc26bdc94..b4cdafd14 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -1,7 +1,7 @@ -use std::{borrow::Cow, sync::Arc}; +use std::{borrow::Cow, collections::HashMap, sync::Arc}; use futures::{StreamExt, stream::BoxStream}; -use http::header::WWW_AUTHENTICATE; +use http::{HeaderName, HeaderValue, header::WWW_AUTHENTICATE}; use reqwest::header::ACCEPT; use sse_stream::{Sse, SseStream}; @@ -9,7 +9,8 @@ use crate::{ model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, transport::{ common::http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION, + HEADER_SESSION_ID, JSON_MIME_TYPE, }, streamable_http_client::*, }, @@ -94,6 +95,7 @@ impl StreamableHttpClient for reqwest::Client { message: ClientJsonRpcMessage, session_id: Option>, auth_token: Option, + custom_headers: HashMap, ) -> Result> { let mut request = self .post(uri.as_ref()) @@ -101,6 +103,26 @@ impl StreamableHttpClient for reqwest::Client { if let Some(auth_header) = auth_token { request = request.bearer_auth(auth_header); } + + // Apply custom headers + let reserved_headers = [ + ACCEPT.as_str(), + HEADER_SESSION_ID, + HEADER_MCP_PROTOCOL_VERSION, + HEADER_LAST_EVENT_ID, + ]; + for (name, value) in custom_headers { + if reserved_headers + .iter() + .any(|&r| name.as_str().eq_ignore_ascii_case(r)) + { + return Err(StreamableHttpError::ReservedHeaderConflict( + name.to_string(), + )); + } + + request = request.header(name, value); + } if let Some(session_id) = session_id { request = request.header(HEADER_SESSION_ID, session_id.as_ref()); } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 550d261b6..37653c42f 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -1,6 +1,7 @@ -use std::{borrow::Cow, sync::Arc, time::Duration}; +use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration}; use futures::{Stream, StreamExt, future::BoxFuture, stream::BoxStream}; +use http::{HeaderName, HeaderValue}; pub use sse_stream::Error as SseError; use sse_stream::Sse; use thiserror::Error; @@ -76,6 +77,8 @@ pub enum StreamableHttpError { AuthRequired(AuthRequiredError), #[error("Insufficient scope")] InsufficientScope(InsufficientScopeError), + #[error("Header name '{0}' is reserved and conflicts with default headers")] + ReservedHeaderConflict(String), } #[derive(Debug, Clone, Error)] @@ -173,6 +176,7 @@ pub trait StreamableHttpClient: Clone + Send + 'static { message: ClientJsonRpcMessage, session_id: Option>, auth_header: Option, + custom_headers: HashMap, ) -> impl Future>> + Send + '_; @@ -324,6 +328,7 @@ impl Worker for StreamableHttpClientWorker { initialize_request, None, self.config.auth_header, + self.config.custom_headers, ) .await { @@ -372,6 +377,7 @@ impl Worker for StreamableHttpClientWorker { initialized_notification.message, session_id.clone(), config.auth_header.clone(), + config.custom_headers.clone(), ) .await .map_err(WorkerQuitReason::fatal_context( @@ -477,6 +483,7 @@ impl Worker for StreamableHttpClientWorker { message, session_id.clone(), config.auth_header.clone(), + config.custom_headers.clone(), ) .await; let send_result = match response { @@ -609,8 +616,10 @@ impl Worker for StreamableHttpClientWorker { /// StreamableHttpClientTransportConfig /// }; /// use std::sync::Arc; +/// use std::collections::HashMap; /// use futures::stream::BoxStream; /// use rmcp::model::ClientJsonRpcMessage; +/// use http::{HeaderName, HeaderValue}; /// use sse_stream::{Sse, Error as SseError}; /// /// #[derive(Clone)] @@ -634,6 +643,7 @@ impl Worker for StreamableHttpClientWorker { /// _message: ClientJsonRpcMessage, /// _session_id: Option>, /// _auth_header: Option, +/// _custom_headers: HashMap, /// ) -> Result> { /// todo!() /// } @@ -690,8 +700,10 @@ impl StreamableHttpClientTransport { /// StreamableHttpClientTransportConfig /// }; /// use std::sync::Arc; + /// use std::collections::HashMap; /// use futures::stream::BoxStream; /// use rmcp::model::ClientJsonRpcMessage; + /// use http::{HeaderName, HeaderValue}; /// use sse_stream::{Sse, Error as SseError}; /// /// // Define your custom client @@ -716,6 +728,7 @@ impl StreamableHttpClientTransport { /// _message: ClientJsonRpcMessage, /// _session_id: Option>, /// _auth_header: Option, + /// _custom_headers: HashMap, /// ) -> Result> { /// todo!() /// } @@ -759,6 +772,8 @@ pub struct StreamableHttpClientTransportConfig { pub allow_stateless: bool, /// The value to send in the authorization header pub auth_header: Option, + /// Custom HTTP headers to include with every request + pub custom_headers: HashMap, } impl StreamableHttpClientTransportConfig { @@ -779,6 +794,33 @@ impl StreamableHttpClientTransportConfig { self.auth_header = Some(value.into()); self } + + /// Set custom HTTP headers to include with every request + /// + /// # Arguments + /// + /// * `custom_headers` - A HashMap of header names to header values + /// + /// # Example + /// + /// ```rust,no_run + /// use std::collections::HashMap; + /// use http::{HeaderName, HeaderValue}; + /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + /// + /// let mut headers = HashMap::new(); + /// headers.insert( + /// HeaderName::from_static("x-custom-header"), + /// HeaderValue::from_static("custom-value") + /// ); + /// + /// let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8000") + /// .custom_headers(headers); + /// ``` + pub fn custom_headers(mut self, custom_headers: HashMap) -> Self { + self.custom_headers = custom_headers; + self + } } impl Default for StreamableHttpClientTransportConfig { @@ -789,6 +831,7 @@ impl Default for StreamableHttpClientTransportConfig { channel_buffer_capacity: 16, allow_stateless: true, auth_header: None, + custom_headers: HashMap::new(), } } } diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs new file mode 100644 index 000000000..c9307109f --- /dev/null +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -0,0 +1,531 @@ +use std::collections::HashMap; + +use http::{HeaderName, HeaderValue}; + +#[test] +fn test_config_custom_headers_default_empty() { + use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + + let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080"); + assert!( + config.custom_headers.is_empty(), + "Default custom_headers should be empty" + ); +} + +#[test] +fn test_config_custom_headers_builder() { + use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("x-test-header"), + HeaderValue::from_static("test-value"), + ); + + let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") + .custom_headers(headers); + + assert_eq!(config.custom_headers.len(), 1); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("x-test-header")), + Some(&HeaderValue::from_static("test-value")) + ); +} + +#[test] +fn test_config_custom_headers_multiple_values() { + use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("x-header-1"), + HeaderValue::from_static("value-1"), + ); + headers.insert( + HeaderName::from_static("x-header-2"), + HeaderValue::from_static("value-2"), + ); + headers.insert( + HeaderName::from_static("authorization"), + HeaderValue::from_static("Bearer token123"), + ); + + let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") + .custom_headers(headers); + + assert_eq!(config.custom_headers.len(), 3); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("x-header-1")), + Some(&HeaderValue::from_static("value-1")) + ); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("x-header-2")), + Some(&HeaderValue::from_static("value-2")) + ); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("authorization")), + Some(&HeaderValue::from_static("Bearer token123")) + ); +} + +#[test] +fn test_config_auth_header_and_custom_headers_together() { + use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("x-custom-header"), + HeaderValue::from_static("custom-value"), + ); + + let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8080") + .auth_header("my-bearer-token") + .custom_headers(headers); + + assert_eq!(config.auth_header, Some("my-bearer-token".to_string())); + assert_eq!( + config + .custom_headers + .get(&HeaderName::from_static("x-custom-header")), + Some(&HeaderValue::from_static("custom-value")) + ); +} + +/// Unit test: post_message should reject reserved header "accept" +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_rejects_accept_header() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("accept"), + HeaderValue::from_static("text/html"), + ); + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message, + None, + None, + custom_headers, + ) + .await; + + assert!(result.is_err(), "Should reject 'accept' header"); + match result { + Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { + assert_eq!( + header_name, "accept", + "Error should indicate 'accept' header" + ); + } + other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), + } +} + +/// Unit test: post_message should reject reserved header "mcp-session-id" +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_rejects_mcp_session_id() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("mcp-session-id"), + HeaderValue::from_static("my-session"), + ); + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message, + None, + None, + custom_headers, + ) + .await; + + assert!(result.is_err(), "Should reject 'mcp-session-id' header"); + match result { + Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { + assert_eq!( + header_name, "mcp-session-id", + "Error should indicate 'mcp-session-id' header" + ); + } + other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), + } +} + +/// Unit test: post_message should reject reserved header "mcp-protocol-version" +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_rejects_mcp_protocol_version() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("mcp-protocol-version"), + HeaderValue::from_static("1.0"), + ); + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message, + None, + None, + custom_headers, + ) + .await; + + assert!( + result.is_err(), + "Should reject 'mcp-protocol-version' header" + ); + match result { + Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { + assert_eq!( + header_name, "mcp-protocol-version", + "Error should indicate 'mcp-protocol-version' header" + ); + } + other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), + } +} + +/// Unit test: post_message should reject reserved header "last-event-id" +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_rejects_last_event_id() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("last-event-id"), + HeaderValue::from_static("event-123"), + ); + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message, + None, + None, + custom_headers, + ) + .await; + + assert!(result.is_err(), "Should reject 'last-event-id' header"); + match result { + Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { + assert_eq!( + header_name, "last-event-id", + "Error should indicate 'last-event-id' header" + ); + } + other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), + } +} + +/// Unit test: post_message should do case-insensitive matching for reserved headers +#[tokio::test] +#[cfg(feature = "transport-streamable-http-client-reqwest")] +async fn test_post_message_case_insensitive_matching() { + use std::sync::Arc; + + use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + }; + + let client = reqwest::Client::new(); + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + // Test different casings + let test_cases = vec![ + ("Accept", "Should reject 'Accept' (capitalized)"), + ("ACCEPT", "Should reject 'ACCEPT' (uppercase)"), + ("Mcp-Session-Id", "Should reject 'Mcp-Session-Id'"), + ("MCP-SESSION-ID", "Should reject 'MCP-SESSION-ID'"), + ]; + + for (header_name, error_msg) in test_cases { + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_bytes(header_name.as_bytes()).unwrap(), + HeaderValue::from_static("value"), + ); + + let result = client + .post_message( + Arc::from("http://localhost:9999/mcp"), + message.clone(), + None, + None, + custom_headers, + ) + .await; + + assert!(result.is_err(), "{}", error_msg); + if let Err(StreamableHttpError::ReservedHeaderConflict(_)) = result { + // Success + } else { + panic!( + "{}: Expected ReservedHeaderConflict, got: {:?}", + error_msg, result + ); + } + } +} + +/// Integration test: Verify that custom headers are actually sent in MCP HTTP requests +#[tokio::test] +#[cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest" +))] +async fn test_mcp_custom_headers_sent_to_server() -> anyhow::Result<()> { + use std::{net::SocketAddr, sync::Arc}; + + use axum::{ + Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, + routing::post, + }; + use rmcp::{ + ServiceExt, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + }, + }; + use serde_json::json; + use tokio::sync::Mutex; + + // State to capture received headers + #[derive(Clone)] + struct ServerState { + received_headers: Arc>>, + initialize_called: Arc, + } + + // Handler that captures headers from MCP requests + async fn mcp_handler( + State(state): State, + headers: http::HeaderMap, + body: Bytes, + ) -> impl IntoResponse { + // Capture all custom headers (starting with x-) + let mut headers_map = HashMap::new(); + for (name, value) in headers.iter() { + let name_str = name.as_str(); + if name_str.starts_with("x-") { + if let Ok(v) = value.to_str() { + headers_map.insert(name_str.to_string(), v.to_string()); + } + } + } + + // Store captured headers + let mut stored = state.received_headers.lock().await; + stored.extend(headers_map); + + // Parse the MCP request + if let Ok(json_body) = serde_json::from_slice::(&body) { + if let Some(method) = json_body.get("method").and_then(|m| m.as_str()) { + if method == "initialize" { + state.initialize_called.notify_one(); + // Return a valid MCP initialize response with session header + let response = json!({ + "jsonrpc": "2.0", + "id": json_body.get("id"), + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + } + }); + return ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-123", + ), + ], + response.to_string(), + ); + } else if method == "notifications/initialized" { + // For initialized notification, return 202 Accepted + return ( + StatusCode::ACCEPTED, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-123", + ), + ], + String::new(), + ); + } + } + } + + // Default response for other requests + let response = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {} + }); + ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-123", + ), + ], + response.to_string(), + ) + } + + // Setup test server + let state = ServerState { + received_headers: Arc::new(Mutex::new(HashMap::new())), + initialize_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let addr = SocketAddr::from(([127, 0, 0, 1], 0)); + let listener = tokio::net::TcpListener::bind(addr).await?; + let port = listener.local_addr()?.port(); + + let server_handle = tokio::spawn(async move { axum::serve(listener, app).await }); + + // Wait for server to be ready + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Create MCP client with custom headers + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("x-test-header"), + HeaderValue::from_static("test-value-123"), + ); + custom_headers.insert( + HeaderName::from_static("x-another-header"), + HeaderValue::from_static("another-value-456"), + ); + custom_headers.insert( + HeaderName::from_static("x-client-id"), + HeaderValue::from_static("test-client"), + ); + + let config = + StreamableHttpClientTransportConfig::with_uri(format!("http://127.0.0.1:{}/mcp", port)) + .custom_headers(custom_headers); + + let transport = StreamableHttpClientTransport::from_config(config); + + // Start MCP client with empty handler (this will trigger initialize request) + let client = ().serve(transport).await.expect("Failed to start client"); + + // Wait for initialize to be called + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialize_called.notified(), + ) + .await + .expect("Initialize request should be received"); + + // Verify that custom headers were received + let headers = state.received_headers.lock().await; + + assert_eq!( + headers.get("x-test-header"), + Some(&"test-value-123".to_string()), + "Custom header x-test-header should be sent to MCP server" + ); + assert_eq!( + headers.get("x-another-header"), + Some(&"another-value-456".to_string()), + "Custom header x-another-header should be sent to MCP server" + ); + assert_eq!( + headers.get("x-client-id"), + Some(&"test-client".to_string()), + "Custom header x-client-id should be sent to MCP server" + ); + + // Cleanup + drop(client); + server_handle.abort(); + + Ok(()) +} From 453032faedb5336a2ded4289eadfbd6672facd8a Mon Sep 17 00:00:00 2001 From: Rodolfo Olivieri Date: Fri, 13 Feb 2026 18:13:45 -0300 Subject: [PATCH 039/333] chore: include LICENSE in final crate tarball (#657) Required for packaging in distributions such as Fedora and others. Verified with: $ cargo package --list | grep LICENSE --- Cargo.toml | 1 + crates/rmcp-macros/Cargo.toml | 1 + crates/rmcp/Cargo.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 0bc1f08b9..48832dced 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ edition = "2024" version = "0.15.0" authors = ["4t145 "] license = "Apache-2.0" +license-file = "LICENSE" repository = "https://github.com/modelcontextprotocol/rust-sdk/" description = "Rust SDK for Model Context Protocol" keywords = ["mcp", "sdk", "tokio", "modelcontextprotocol"] diff --git a/crates/rmcp-macros/Cargo.toml b/crates/rmcp-macros/Cargo.toml index 8413e5d93..b59929265 100644 --- a/crates/rmcp-macros/Cargo.toml +++ b/crates/rmcp-macros/Cargo.toml @@ -3,6 +3,7 @@ [package] name = "rmcp-macros" license = { workspace = true } +license-file = { workspace = true } version = { workspace = true } edition = { workspace = true } repository = { workspace = true } diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 8a672cfb4..ea7a308af 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "rmcp" license = { workspace = true } +license-file = { workspace = true } version = { workspace = true } edition = { workspace = true } repository = { workspace = true } From 08a5b0551bd2c5936009f29b1b26efafc2712b40 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:56:44 -0500 Subject: [PATCH 040/333] fix: align task response types with MCP spec (#658) --- crates/rmcp-macros/src/task_handler.rs | 39 ++-- crates/rmcp/src/handler/server.rs | 19 +- crates/rmcp/src/model.rs | 16 +- crates/rmcp/src/model/task.rs | 61 ++++-- .../server_json_rpc_message_schema.json | 174 +++++++++++++----- ...erver_json_rpc_message_schema_current.json | 174 +++++++++++++----- 6 files changed, 349 insertions(+), 134 deletions(-) diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index 09d43f96e..4ad02d6b8 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -47,7 +47,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result syn::Result, - ) -> Result { + ) -> Result { use rmcp::task_manager::current_timestamp; let task_id = request.task_id.clone(); let mut processor = (#processor).lock().await; @@ -156,11 +156,11 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result(get_info_fn)?); @@ -191,7 +191,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - ) -> Result { + ) -> Result { use std::time::Duration; let task_id = request.task_id.clone(); @@ -207,11 +207,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result { let value = ::serde_json::to_value(call_tool).unwrap_or(::serde_json::Value::Null); - return Ok(rmcp::model::TaskResult { - content_type: "application/json".to_string(), - value, - summary: None, - }); + return Ok(rmcp::model::GetTaskPayloadResult(value)); } Err(err) => return Err(McpError::internal_error( format!("task failed: {}", err), @@ -251,12 +247,23 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - ) -> Result<(), McpError> { + ) -> Result { + use rmcp::task_manager::current_timestamp; let task_id = request.task_id; let mut processor = (#processor).lock().await; if processor.cancel_task(&task_id) { - return Ok(()); + let timestamp = current_timestamp(); + let task = rmcp::model::Task { + task_id, + status: rmcp::model::TaskStatus::Cancelled, + status_message: None, + created_at: timestamp.clone(), + last_updated_at: timestamp, + ttl: None, + poll_interval: None, + }; + return Ok(rmcp::model::CancelTaskResult { meta: None, task }); } // If already completed, signal it's not cancellable diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 86773d878..a7ae335b0 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -116,15 +116,15 @@ impl Service for H { ClientRequest::GetTaskInfoRequest(request) => self .get_task_info(request.params, context) .await - .map(ServerResult::GetTaskInfoResult), + .map(ServerResult::GetTaskResult), ClientRequest::GetTaskResultRequest(request) => self .get_task_result(request.params, context) .await - .map(ServerResult::TaskResult), + .map(ServerResult::GetTaskPayloadResult), ClientRequest::CancelTaskRequest(request) => self .cancel_task(request.params, context) .await - .map(ServerResult::empty), + .map(ServerResult::CancelTaskResult), } } @@ -339,7 +339,8 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: GetTaskInfoParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { + let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -347,7 +348,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: GetTaskResultParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -356,7 +357,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -543,7 +544,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: GetTaskInfoParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { (**self).get_task_info(request, context) } @@ -551,7 +552,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: GetTaskResultParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { (**self).get_task_result(request, context) } @@ -559,7 +560,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { (**self).cancel_task(request, context) } } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 00c51bcb3..3832271ac 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2537,14 +2537,9 @@ impl RequestParamsMeta for CancelTaskParams { /// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance). #[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")] pub type CancelTaskParam = CancelTaskParams; -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[serde(deny_unknown_fields)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct GetTaskInfoResult { - #[serde(skip_serializing_if = "Option::is_none")] - pub task: Option, -} +/// Deprecated: Use [`GetTaskResult`] instead (spec alignment). +#[deprecated(since = "0.15.0", note = "Use GetTaskResult instead")] +pub type GetTaskInfoResult = GetTaskResult; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] @@ -2720,9 +2715,10 @@ ts_union!( | EmptyResult | CreateTaskResult | ListTasksResult - | GetTaskInfoResult - | TaskResult + | GetTaskResult + | CancelTaskResult | CustomResult + | GetTaskPayloadResult ; ); diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index 8cb0ee583..a18ed0c59 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use super::Meta; + /// Canonical task lifecycle status as defined by SEP-1686. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -19,21 +21,10 @@ pub enum TaskStatus { Cancelled, } -/// Final result for a succeeded task (returned from `tasks/result`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct TaskResult { - /// MIME type or custom content-type identifier. - pub content_type: String, - /// The actual result payload, matching the underlying request's schema. - pub value: Value, - /// Optional short summary for UI surfaces. - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, -} - /// Primary Task object that surfaces metadata during the task lifecycle. +/// +/// Per spec, `lastUpdatedAt` and `ttl` are required fields. +/// `ttl` is nullable (`null` means unlimited retention). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -48,10 +39,9 @@ pub struct Task { /// ISO-8601 creation timestamp. pub created_at: String, /// ISO-8601 timestamp for the most recent status change. - #[serde(skip_serializing_if = "Option::is_none")] - pub last_updated_at: Option, + pub last_updated_at: String, /// Retention window in milliseconds that the receiver agreed to honor. - #[serde(skip_serializing_if = "Option::is_none")] + /// `None` (serialized as `null`) means unlimited retention. pub ttl: Option, /// Suggested polling interval (milliseconds). #[serde(skip_serializing_if = "Option::is_none")] @@ -66,6 +56,43 @@ pub struct CreateTaskResult { pub task: Task, } +/// Response to a `tasks/get` request. +/// +/// Per spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are +/// flattened at the top level, not nested under a `task` key. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct GetTaskResult { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(flatten)] + pub task: Task, +} + +/// Response to a `tasks/result` request. +/// +/// Per spec, the result structure matches the original request type +/// (e.g., `CallToolResult` for `tools/call`). This is represented as +/// an open object. The payload is the original request's result +/// serialized as a JSON value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct GetTaskPayloadResult(pub Value); + +/// Response to a `tasks/cancel` request. +/// +/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct CancelTaskResult { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(flatten)] + pub task: Task, +} + /// Paginated list of tasks #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index f0b617354..2bb71dfff 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -407,6 +407,70 @@ "content" ] }, + "CancelTaskResult": { + "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "CancelledNotificationMethod": { "type": "string", "format": "const", @@ -937,21 +1001,72 @@ "messages" ] }, - "GetTaskInfoResult": { + "GetTaskPayloadResult": { + "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." + }, + "GetTaskResult": { + "description": "Response to a `tasks/get` request.\n\nPer spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are\nflattened at the top level, not nested under a `task` key.", "type": "object", "properties": { - "task": { - "anyOf": [ - { - "$ref": "#/definitions/Task" - }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ { - "type": "null" + "$ref": "#/definitions/TaskStatus" } ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, - "additionalProperties": false + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] }, "Icon": { "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", @@ -2701,13 +2816,16 @@ "$ref": "#/definitions/ListTasksResult" }, { - "$ref": "#/definitions/GetTaskInfoResult" + "$ref": "#/definitions/GetTaskResult" }, { - "$ref": "#/definitions/TaskResult" + "$ref": "#/definitions/CancelTaskResult" }, { "$ref": "#/definitions/CustomResult" + }, + { + "$ref": "#/definitions/GetTaskPayloadResult" } ] }, @@ -2813,7 +2931,7 @@ "const": "string" }, "Task": { - "description": "Primary Task object that surfaces metadata during the task lifecycle.", + "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", "type": "object", "properties": { "createdAt": { @@ -2822,10 +2940,7 @@ }, "lastUpdatedAt": { "description": "ISO-8601 timestamp for the most recent status change.", - "type": [ - "string", - "null" - ] + "type": "string" }, "pollInterval": { "description": "Suggested polling interval (milliseconds).", @@ -2856,7 +2971,7 @@ "type": "string" }, "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.", + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", "type": [ "integer", "null" @@ -2868,7 +2983,8 @@ "required": [ "taskId", "status", - "createdAt" + "createdAt", + "lastUpdatedAt" ] }, "TaskRequestsCapability": { @@ -2907,30 +3023,6 @@ } } }, - "TaskResult": { - "description": "Final result for a succeeded task (returned from `tasks/result`).", - "type": "object", - "properties": { - "contentType": { - "description": "MIME type or custom content-type identifier.", - "type": "string" - }, - "summary": { - "description": "Optional short summary for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "value": { - "description": "The actual result payload, matching the underlying request's schema." - } - }, - "required": [ - "contentType", - "value" - ] - }, "TaskStatus": { "description": "Canonical task lifecycle status as defined by SEP-1686.", "oneOf": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index f0b617354..2bb71dfff 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -407,6 +407,70 @@ "content" ] }, + "CancelTaskResult": { + "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "CancelledNotificationMethod": { "type": "string", "format": "const", @@ -937,21 +1001,72 @@ "messages" ] }, - "GetTaskInfoResult": { + "GetTaskPayloadResult": { + "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." + }, + "GetTaskResult": { + "description": "Response to a `tasks/get` request.\n\nPer spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are\nflattened at the top level, not nested under a `task` key.", "type": "object", "properties": { - "task": { - "anyOf": [ - { - "$ref": "#/definitions/Task" - }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ { - "type": "null" + "$ref": "#/definitions/TaskStatus" } ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, - "additionalProperties": false + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] }, "Icon": { "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", @@ -2701,13 +2816,16 @@ "$ref": "#/definitions/ListTasksResult" }, { - "$ref": "#/definitions/GetTaskInfoResult" + "$ref": "#/definitions/GetTaskResult" }, { - "$ref": "#/definitions/TaskResult" + "$ref": "#/definitions/CancelTaskResult" }, { "$ref": "#/definitions/CustomResult" + }, + { + "$ref": "#/definitions/GetTaskPayloadResult" } ] }, @@ -2813,7 +2931,7 @@ "const": "string" }, "Task": { - "description": "Primary Task object that surfaces metadata during the task lifecycle.", + "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", "type": "object", "properties": { "createdAt": { @@ -2822,10 +2940,7 @@ }, "lastUpdatedAt": { "description": "ISO-8601 timestamp for the most recent status change.", - "type": [ - "string", - "null" - ] + "type": "string" }, "pollInterval": { "description": "Suggested polling interval (milliseconds).", @@ -2856,7 +2971,7 @@ "type": "string" }, "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.", + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", "type": [ "integer", "null" @@ -2868,7 +2983,8 @@ "required": [ "taskId", "status", - "createdAt" + "createdAt", + "lastUpdatedAt" ] }, "TaskRequestsCapability": { @@ -2907,30 +3023,6 @@ } } }, - "TaskResult": { - "description": "Final result for a succeeded task (returned from `tasks/result`).", - "type": "object", - "properties": { - "contentType": { - "description": "MIME type or custom content-type identifier.", - "type": "string" - }, - "summary": { - "description": "Optional short summary for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "value": { - "description": "The actual result payload, matching the underlying request's schema." - } - }, - "required": [ - "contentType", - "value" - ] - }, "TaskStatus": { "description": "Canonical task lifecycle status as defined by SEP-1686.", "oneOf": [ From 53cd5ed84ac785aa295d7e34e4eb3ac2742e8abe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:34:32 -0500 Subject: [PATCH 041/333] chore(deps): update toml requirement from 0.9 to 1.0 (#668) Updates the requirements on [toml](https://github.com/toml-rs/toml) to permit the latest version. - [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.0...toml-v1.0.2) --- updated-dependencies: - dependency-name: toml dependency-version: 1.0.2+spec-1.1.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/rig-integration/Cargo.toml | 2 +- examples/simple-chat-client/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/rig-integration/Cargo.toml b/examples/rig-integration/Cargo.toml index afb920b93..2d7a69dce 100644 --- a/examples/rig-integration/Cargo.toml +++ b/examples/rig-integration/Cargo.toml @@ -23,7 +23,7 @@ rmcp = { workspace = true, features = [ anyhow = "1.0" serde_json = "1" serde = { version = "1", features = ["derive"] } -toml = "0.9" +toml = "1.0" futures = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = [ diff --git a/examples/simple-chat-client/Cargo.toml b/examples/simple-chat-client/Cargo.toml index db8cdda4a..e5e17f0d4 100644 --- a/examples/simple-chat-client/Cargo.toml +++ b/examples/simple-chat-client/Cargo.toml @@ -13,7 +13,7 @@ anyhow = "1.0" thiserror = "2.0" async-trait = "0.1" futures = "0.3" -toml = "0.9" +toml = "1.0" rmcp = { workspace = true, features = [ "client", "transport-child-process", From 61ffba84b54d78d1a0c93793363af433784dcc67 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 17 Feb 2026 15:37:17 +0100 Subject: [PATCH 042/333] fix: sort list_all() output in ToolRouter and PromptRouter for deterministic ordering (#665) ToolRouter::list_all() and PromptRouter::list_all() iterate over a HashMap, which returns items in non-deterministic order. Since list_all() backs the tools/list and prompts/list MCP protocol responses, this causes MCP clients to receive differently-ordered results across calls and process restarts, leading to intermittent tool discovery failures. Sort the output alphabetically by name to guarantee stable ordering. --- .../rmcp/src/handler/server/router/prompt.rs | 4 ++- crates/rmcp/src/handler/server/router/tool.rs | 4 ++- crates/rmcp/tests/test_prompt_routers.rs | 36 +++++++++++++++++++ crates/rmcp/tests/test_tool_routers.rs | 17 +++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/handler/server/router/prompt.rs b/crates/rmcp/src/handler/server/router/prompt.rs index a48d2ad7d..6ea925a0e 100644 --- a/crates/rmcp/src/handler/server/router/prompt.rs +++ b/crates/rmcp/src/handler/server/router/prompt.rs @@ -187,7 +187,9 @@ where } pub fn list_all(&self) -> Vec { - self.map.values().map(|item| item.attr.clone()).collect() + let mut prompts: Vec<_> = self.map.values().map(|item| item.attr.clone()).collect(); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + prompts } } diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 72b7f2e26..51d49d971 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -252,7 +252,9 @@ where } pub fn list_all(&self) -> Vec { - self.map.values().map(|item| item.attr.clone()).collect() + let mut tools: Vec<_> = self.map.values().map(|item| item.attr.clone()).collect(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + tools } /// Get a tool definition by name. diff --git a/crates/rmcp/tests/test_prompt_routers.rs b/crates/rmcp/tests/test_prompt_routers.rs index 6dc223b39..0917a7f1d 100644 --- a/crates/rmcp/tests/test_prompt_routers.rs +++ b/crates/rmcp/tests/test_prompt_routers.rs @@ -103,3 +103,39 @@ fn test_prompt_router() { let prompts = test_prompt_router.list_all(); assert_eq!(prompts.len(), 4); } + +#[test] +fn test_prompt_router_list_all_is_sorted() { + let router = TestHandler::<()>::test_router() + .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( + async_function_prompt_attr(), + |mut context| { + Box::pin(async move { + use rmcp::handler::server::{ + common::FromContextPart, prompt::IntoGetPromptResult, + }; + let params = Parameters::::from_context_part(&mut context)?; + let result = async_function(params).await; + result.into_get_prompt_result() + }) + }, + )) + .with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn( + async_function2_prompt_attr(), + |context| { + Box::pin(async move { + use rmcp::handler::server::prompt::IntoGetPromptResult; + let result = async_function2(context.server).await; + result.into_get_prompt_result() + }) + }, + )); + let prompts = router.list_all(); + let names: Vec<&str> = prompts.iter().map(|p| p.name.as_ref()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!( + names, sorted, + "list_all() should return prompts sorted alphabetically by name" + ); +} diff --git a/crates/rmcp/tests/test_tool_routers.rs b/crates/rmcp/tests/test_tool_routers.rs index 442c70ea1..987d1a0b1 100644 --- a/crates/rmcp/tests/test_tool_routers.rs +++ b/crates/rmcp/tests/test_tool_routers.rs @@ -66,3 +66,20 @@ where H: CallToolHandler, { } + +#[test] +fn test_tool_router_list_all_is_sorted() { + let router: ToolRouter> = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)) + .with_route((async_function2_tool_attr(), async_function2)) + + TestHandler::<()>::test_router_1() + + TestHandler::<()>::test_router_2(); + let tools = router.list_all(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!( + names, sorted, + "list_all() should return tools sorted alphabetically by name" + ); +} From 5a6ff1f74c979584152cdb80c2da2aa2b74f3e0d Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:03:01 -0500 Subject: [PATCH 043/333] fix: duplicate meta serialization (#662) --- crates/rmcp/src/model/serde_impl.rs | 273 +++++++++++++++++++++++++++- 1 file changed, 270 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index 8b88b5e01..c262d6acd 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -6,14 +6,61 @@ use super::{ CustomNotification, CustomRequest, Extensions, Meta, Notification, NotificationNoParam, Request, RequestNoParam, RequestOptionalParam, }; -#[derive(Serialize, Deserialize)] +#[derive(Deserialize)] struct WithMeta<'a, P> { - #[serde(skip_serializing_if = "Option::is_none")] _meta: Option>, #[serde(flatten)] _rest: P, } +impl Serialize for WithMeta<'_, P> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + // Serialize _rest to a Value so we can inspect and strip any duplicate _meta + let mut rest_value = + serde_json::to_value(&self._rest).map_err(serde::ser::Error::custom)?; + + // Extract _meta from the serialized params (if it's an object containing one) + let params_meta: Option = rest_value + .as_object_mut() + .and_then(|obj| obj.remove("_meta")) + .and_then(|v| serde_json::from_value(v).ok()); + + // Merge: params-level _meta as base, extensions-level _meta overwrites on conflict + let merged_meta = match (self._meta.as_deref(), params_meta) { + (Some(ext_meta), Some(mut params_meta)) => { + params_meta.extend(ext_meta.clone()); + Some(params_meta) + } + (Some(ext_meta), None) => Some(ext_meta.clone()), + (None, Some(params_meta)) => Some(params_meta), + (None, None) => None, + }; + + // Serialize as a flat map: single _meta + remaining params fields + let rest_obj = match rest_value { + serde_json::Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + let meta_count = usize::from(merged_meta.is_some()); + let mut map = serializer.serialize_map(Some(rest_obj.len() + meta_count))?; + + if let Some(meta) = &merged_meta { + map.serialize_entry("_meta", meta)?; + } + + for (k, v) in &rest_obj { + map.serialize_entry(k, v)?; + } + + map.end() + } +} + #[derive(Serialize, Deserialize)] struct Proxy<'a, M, P> { method: M, @@ -359,7 +406,9 @@ impl<'de> Deserialize<'de> for CustomNotification { mod test { use serde_json::json; - use crate::model::ListToolsRequest; + use crate::model::{ + CallToolRequest, CallToolRequestParams, CustomRequest, Extensions, ListToolsRequest, Meta, + }; #[test] fn test_deserialize_lost_tools_request() { @@ -370,4 +419,222 @@ mod test { )) .unwrap(); } + + #[test] + fn test_no_duplicate_meta_both_sources() { + // When both extensions and params contain _meta, the output should have + // a single merged _meta key (not two separate ones). + let mut extensions = Extensions::new(); + let mut ext_meta = Meta::new(); + ext_meta.0.insert("traceId".to_string(), json!("abc")); + extensions.insert(ext_meta); + + let mut params_meta = Meta::new(); + params_meta.0.insert("progressToken".to_string(), json!(1)); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: Some(params_meta), + name: "my_tool".into(), + arguments: None, + task: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let params = value.get("params").unwrap(); + + // There should be exactly one _meta key (JSON objects naturally deduplicate) + let meta = params.get("_meta").unwrap(); + + // Both entries should be present in the merged _meta + assert_eq!(meta.get("traceId").unwrap(), "abc"); + assert_eq!(meta.get("progressToken").unwrap(), 1); + + // Verify the raw JSON string has exactly one occurrence of "_meta" + let raw = serde_json::to_string(&req).unwrap(); + assert_eq!( + raw.matches("\"_meta\"").count(), + 1, + "Expected exactly one _meta key in serialized output, got: {}", + raw + ); + } + + #[test] + fn test_meta_only_from_extensions() { + let mut extensions = Extensions::new(); + let mut ext_meta = Meta::new(); + ext_meta.0.insert("traceId".to_string(), json!("ext-only")); + extensions.insert(ext_meta); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: None, + task: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("traceId").unwrap(), "ext-only"); + } + + #[test] + fn test_meta_only_from_params() { + let mut params_meta = Meta::new(); + params_meta.0.insert("progressToken".to_string(), json!(42)); + + let req = CallToolRequest { + extensions: Extensions::new(), + method: Default::default(), + params: CallToolRequestParams { + meta: Some(params_meta), + name: "my_tool".into(), + arguments: None, + task: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("progressToken").unwrap(), 42); + } + + #[test] + fn test_no_meta_emitted_when_neither_source() { + let req = CallToolRequest { + extensions: Extensions::new(), + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: None, + task: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + assert!( + value["params"].get("_meta").is_none(), + "Expected no _meta when neither source is populated" + ); + } + + #[test] + fn test_extensions_meta_takes_priority_on_conflict() { + // When both sources have the same key, extensions should win. + let mut extensions = Extensions::new(); + let mut ext_meta = Meta::new(); + ext_meta + .0 + .insert("shared_key".to_string(), json!("from_extensions")); + extensions.insert(ext_meta); + + let mut params_meta = Meta::new(); + params_meta + .0 + .insert("shared_key".to_string(), json!("from_params")); + params_meta + .0 + .insert("params_only".to_string(), json!("kept")); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: Some(params_meta), + name: "my_tool".into(), + arguments: None, + task: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("shared_key").unwrap(), "from_extensions"); + assert_eq!(meta.get("params_only").unwrap(), "kept"); + } + + #[test] + fn test_round_trip_preserves_meta() { + let mut extensions = Extensions::new(); + let mut ext_meta = Meta::new(); + ext_meta + .0 + .insert("traceId".to_string(), json!("round-trip")); + extensions.insert(ext_meta); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])), + task: None, + }, + }; + + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: CallToolRequest = serde_json::from_str(&serialized).unwrap(); + + // Extensions should have the meta after round-trip + let meta = deserialized.extensions.get::().unwrap(); + assert_eq!(meta.0.get("traceId").unwrap(), "round-trip"); + + // Params should be preserved + assert_eq!(deserialized.params.name, "my_tool"); + assert_eq!( + deserialized + .params + .arguments + .as_ref() + .unwrap() + .get("x") + .unwrap(), + &json!(1) + ); + } + + #[test] + fn test_custom_request_no_duplicate_meta() { + // CustomRequest uses Option as params — verify no duplicate _meta. + let mut extensions = Extensions::new(); + let mut ext_meta = Meta::new(); + ext_meta + .0 + .insert("traceId".to_string(), json!("custom-ext")); + extensions.insert(ext_meta); + + let params = Some(json!({ + "_meta": { "progressToken": 99 }, + "foo": "bar" + })); + + let req = CustomRequest { + extensions, + method: "custom/method".into(), + params, + }; + + let raw = serde_json::to_string(&req).unwrap(); + assert_eq!( + raw.matches("\"_meta\"").count(), + 1, + "Expected exactly one _meta key in CustomRequest output, got: {}", + raw + ); + + let value: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("traceId").unwrap(), "custom-ext"); + assert_eq!(meta.get("progressToken").unwrap(), 99); + } } From 0b53bfd7b960cbc1e1c7cc41599ace4662d9c928 Mon Sep 17 00:00:00 2001 From: EvianZhang Date: Tue, 17 Feb 2026 23:07:40 +0800 Subject: [PATCH 044/333] fix: remove unnecessary doc-cfg (#661) --- crates/rmcp/src/handler.rs | 2 -- crates/rmcp/src/lib.rs | 12 ------------ crates/rmcp/src/model.rs | 1 - crates/rmcp/src/service.rs | 6 ------ crates/rmcp/src/transport.rs | 17 ----------------- crates/rmcp/src/transport/async_rw.rs | 2 -- crates/rmcp/src/transport/common.rs | 3 --- crates/rmcp/src/transport/common/auth.rs | 1 - crates/rmcp/src/transport/common/reqwest.rs | 1 - .../src/transport/streamable_http_client.rs | 1 - .../src/transport/streamable_http_server.rs | 2 -- 11 files changed, 48 deletions(-) diff --git a/crates/rmcp/src/handler.rs b/crates/rmcp/src/handler.rs index c2b9737b8..6b848cd2c 100644 --- a/crates/rmcp/src/handler.rs +++ b/crates/rmcp/src/handler.rs @@ -1,6 +1,4 @@ #[cfg(feature = "client")] -#[cfg_attr(docsrs, doc(cfg(feature = "client")))] pub mod client; #[cfg(feature = "server")] -#[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub mod server; diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 7a2ea49f3..1050e6aa7 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -9,25 +9,18 @@ pub use error::{Error, ErrorData, RmcpError}; /// Basic data types in MCP specification pub mod model; #[cfg(any(feature = "client", feature = "server"))] -#[cfg_attr(docsrs, doc(cfg(any(feature = "client", feature = "server"))))] pub mod service; #[cfg(feature = "client")] -#[cfg_attr(docsrs, doc(cfg(feature = "client")))] pub use handler::client::ClientHandler; #[cfg(feature = "server")] -#[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub use handler::server::ServerHandler; #[cfg(feature = "server")] -#[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub use handler::server::wrapper::Json; #[cfg(any(feature = "client", feature = "server"))] -#[cfg_attr(docsrs, doc(cfg(any(feature = "client", feature = "server"))))] pub use service::{Peer, Service, ServiceError, ServiceExt}; #[cfg(feature = "client")] -#[cfg_attr(docsrs, doc(cfg(feature = "client")))] pub use service::{RoleClient, serve_client}; #[cfg(feature = "server")] -#[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub use service::{RoleServer, serve_server}; pub mod handler; @@ -36,17 +29,12 @@ pub mod transport; // re-export #[cfg(all(feature = "macros", feature = "server"))] -#[cfg_attr(docsrs, doc(cfg(all(feature = "macros", feature = "server"))))] pub use pastey::paste; #[cfg(all(feature = "macros", feature = "server"))] -#[cfg_attr(docsrs, doc(cfg(all(feature = "macros", feature = "server"))))] pub use rmcp_macros::*; #[cfg(any(feature = "macros", feature = "server"))] -#[cfg_attr(docsrs, doc(cfg(any(feature = "macros", feature = "server"))))] pub use schemars; #[cfg(feature = "macros")] -#[cfg_attr(docsrs, doc(cfg(feature = "macros")))] pub use serde; #[cfg(feature = "macros")] -#[cfg_attr(docsrs, doc(cfg(feature = "macros")))] pub use serde_json; diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 3832271ac..a93f98d10 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -43,7 +43,6 @@ pub fn object(value: serde_json::Value) -> JsonObject { /// Use this macro just like [`serde_json::json!`] #[cfg(feature = "macros")] -#[cfg_attr(docsrs, doc(cfg(feature = "macros")))] #[macro_export] macro_rules! object { ({$($tt:tt)*}) => { diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index e0fd76425..3ad28b788 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -11,23 +11,17 @@ use crate::{ transport::{DynamicTransportError, IntoTransport, Transport}, }; #[cfg(feature = "client")] -#[cfg_attr(docsrs, doc(cfg(feature = "client")))] mod client; #[cfg(feature = "client")] -#[cfg_attr(docsrs, doc(cfg(feature = "client")))] pub use client::*; #[cfg(feature = "server")] -#[cfg_attr(docsrs, doc(cfg(feature = "server")))] mod server; #[cfg(feature = "server")] -#[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub use server::*; #[cfg(feature = "tower")] -#[cfg_attr(docsrs, doc(cfg(feature = "tower")))] mod tower; use tokio_util::sync::{CancellationToken, DropGuard}; #[cfg(feature = "tower")] -#[cfg_attr(docsrs, doc(cfg(feature = "tower")))] pub use tower::*; use tracing::{Instrument as _, instrument}; #[derive(Error, Debug)] diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 8228ce7c6..d7dfa9790 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -43,12 +43,10 @@ //! # ServiceExt, serve_server, //! # }; //! #[cfg(feature = "client")] -//! #[cfg_attr(docsrs, doc(cfg(feature = "client")))] //! # use rmcp::serve_client; //! //! // create transport from tcp stream //! #[cfg(feature = "client")] -//! #[cfg_attr(docsrs, doc(cfg(feature = "client")))] //! async fn client() -> Result<(), Box> { //! let stream = tokio::net::TcpSocket::new_v4()? //! .connect("127.0.0.1:8001".parse()?) @@ -61,7 +59,6 @@ //! //! // create transport from std io //! #[cfg(feature = "client")] -//! #[cfg_attr(docsrs, doc(cfg(feature = "client")))] //! async fn io() -> Result<(), Box> { //! let client = ().serve((tokio::io::stdin(), tokio::io::stdout())).await?; //! let tools = client.peer().list_tools(Default::default()).await?; @@ -77,35 +74,26 @@ use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; pub mod sink_stream; #[cfg(feature = "transport-async-rw")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-async-rw")))] pub mod async_rw; #[cfg(feature = "transport-worker")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-worker")))] pub mod worker; #[cfg(feature = "transport-worker")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-worker")))] pub use worker::WorkerTransport; #[cfg(feature = "transport-child-process")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-child-process")))] pub mod child_process; #[cfg(feature = "transport-child-process")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-child-process")))] pub use child_process::{ConfigureCommandExt, TokioChildProcess}; #[cfg(feature = "transport-io")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-io")))] pub mod io; #[cfg(feature = "transport-io")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-io")))] pub use io::stdio; #[cfg(feature = "auth")] -#[cfg_attr(docsrs, doc(cfg(feature = "auth")))] pub mod auth; #[cfg(feature = "auth")] -#[cfg_attr(docsrs, doc(cfg(feature = "auth")))] pub use auth::{ AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, CredentialStore, InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore, @@ -113,20 +101,15 @@ pub use auth::{ }; // #[cfg(feature = "transport-ws")] -// #[cfg_attr(docsrs, doc(cfg(feature = "transport-ws")))] // pub mod ws; #[cfg(feature = "transport-streamable-http-server-session")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-streamable-http-server-session")))] pub mod streamable_http_server; #[cfg(feature = "transport-streamable-http-server")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-streamable-http-server")))] pub use streamable_http_server::tower::{StreamableHttpServerConfig, StreamableHttpService}; #[cfg(feature = "transport-streamable-http-client")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-streamable-http-client")))] pub mod streamable_http_client; #[cfg(feature = "transport-streamable-http-client")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-streamable-http-client")))] pub use streamable_http_client::StreamableHttpClientTransport; /// Common use codes diff --git a/crates/rmcp/src/transport/async_rw.rs b/crates/rmcp/src/transport/async_rw.rs index acd1b4f65..ff4ecc65b 100644 --- a/crates/rmcp/src/transport/async_rw.rs +++ b/crates/rmcp/src/transport/async_rw.rs @@ -68,7 +68,6 @@ where } #[cfg(feature = "client")] -#[cfg_attr(docsrs, doc(cfg(feature = "client")))] impl AsyncRwTransport where R: Send + AsyncRead + Unpin, @@ -80,7 +79,6 @@ where } #[cfg(feature = "server")] -#[cfg_attr(docsrs, doc(cfg(feature = "server")))] impl AsyncRwTransport where R: Send + AsyncRead + Unpin, diff --git a/crates/rmcp/src/transport/common.rs b/crates/rmcp/src/transport/common.rs index e78d4a6e6..615b0e273 100644 --- a/crates/rmcp/src/transport/common.rs +++ b/crates/rmcp/src/transport/common.rs @@ -4,16 +4,13 @@ pub mod server_side_http; pub mod http_header; #[cfg(feature = "__reqwest")] -#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))] mod reqwest; // Note: This module provides SSE stream parsing and auto-reconnect utilities. // It's used by the streamable HTTP client (which receives SSE-formatted responses), // not the removed SSE transport. The name is historical. #[cfg(feature = "client-side-sse")] -#[cfg_attr(docsrs, doc(cfg(feature = "client-side-sse")))] pub mod client_side_sse; #[cfg(feature = "auth")] -#[cfg_attr(docsrs, doc(cfg(feature = "auth")))] pub mod auth; diff --git a/crates/rmcp/src/transport/common/auth.rs b/crates/rmcp/src/transport/common/auth.rs index f9e3a0710..6068f4e95 100644 --- a/crates/rmcp/src/transport/common/auth.rs +++ b/crates/rmcp/src/transport/common/auth.rs @@ -1,3 +1,2 @@ #[cfg(feature = "transport-streamable-http-client")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-streamable-http-client")))] mod streamable_http_client; diff --git a/crates/rmcp/src/transport/common/reqwest.rs b/crates/rmcp/src/transport/common/reqwest.rs index a51fa9552..420759219 100644 --- a/crates/rmcp/src/transport/common/reqwest.rs +++ b/crates/rmcp/src/transport/common/reqwest.rs @@ -1,3 +1,2 @@ #[cfg(feature = "transport-streamable-http-client-reqwest")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-streamable-http-client-reqwest")))] mod streamable_http_client; diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 37653c42f..d45613bb5 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -70,7 +70,6 @@ pub enum StreamableHttpError { #[error("Missing session id in HTTP response")] MissingSessionIdInResponse, #[cfg(feature = "auth")] - #[cfg_attr(docsrs, doc(cfg(feature = "auth")))] #[error("Auth error: {0}")] Auth(#[from] crate::transport::auth::AuthError), #[error("Auth required")] diff --git a/crates/rmcp/src/transport/streamable_http_server.rs b/crates/rmcp/src/transport/streamable_http_server.rs index 733fc5e51..b991ff2e2 100644 --- a/crates/rmcp/src/transport/streamable_http_server.rs +++ b/crates/rmcp/src/transport/streamable_http_server.rs @@ -1,8 +1,6 @@ pub mod session; #[cfg(feature = "transport-streamable-http-server")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-streamable-http-server")))] pub mod tower; pub use session::{SessionId, SessionManager}; #[cfg(feature = "transport-streamable-http-server")] -#[cfg_attr(docsrs, doc(cfg(feature = "transport-streamable-http-server")))] pub use tower::{StreamableHttpServerConfig, StreamableHttpService}; From 021a431bef2112ab3a6f6315137f259777cf3a7c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:41:12 -0500 Subject: [PATCH 045/333] chore: upgrade reqwest to 0.13.2 (#669) --- crates/rmcp/Cargo.toml | 8 ++--- crates/rmcp/src/transport/auth.rs | 44 +++++++++++++++++++++++--- examples/clients/Cargo.toml | 2 +- examples/servers/Cargo.toml | 2 +- examples/simple-chat-client/Cargo.toml | 2 +- examples/transport/Cargo.toml | 2 +- 6 files changed, 47 insertions(+), 13 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index ea7a308af..e62caaf4c 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -26,7 +26,7 @@ tokio-util = { version = "0.7" } pin-project-lite = "0.2" pastey = { version = "0.2.0", optional = true } # oauth2 support -oauth2 = { version = "5.0", optional = true, default-features = false, features = ["reqwest"] } +oauth2 = { version = "5.0", optional = true, default-features = false } # for auto generate schema schemars = { version = "1.0", optional = true, features = ["chrono04"] } @@ -35,7 +35,7 @@ schemars = { version = "1.0", optional = true, features = ["chrono04"] } base64 = { version = "0.22", optional = true } # for HTTP client -reqwest = { version = "0.12", default-features = false, features = [ +reqwest = { version = "0.13.2", default-features = false, features = [ "json", "stream", ], optional = true } @@ -84,9 +84,9 @@ elicitation = ["dep:url"] # reqwest http client __reqwest = ["dep:reqwest"] -reqwest = ["__reqwest", "reqwest?/rustls-tls"] +reqwest = ["__reqwest", "reqwest?/rustls"] -reqwest-tls-no-provider = ["__reqwest", "reqwest?/rustls-tls-no-provider"] +reqwest-tls-no-provider = ["__reqwest", "reqwest?/rustls-no-provider"] reqwest-native-tls = ["__reqwest", "reqwest?/native-tls"] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 7099ff7e6..1ff2ddd72 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2,9 +2,10 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use async_trait::async_trait; use oauth2::{ - AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, - StandardTokenResponse, TokenResponse, TokenUrl, + AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, + EmptyExtraTokenFields, HttpClientError, HttpRequest, HttpResponse, PkceCodeChallenge, + PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, + TokenResponse, TokenUrl, basic::{BasicClient, BasicTokenType}, }; use reqwest::{ @@ -18,6 +19,39 @@ use tracing::{debug, error, warn}; use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; +/// Owned wrapper around [`reqwest::Client`] that implements [`AsyncHttpClient`] for oauth2. +struct OAuthReqwestClient(HttpClient); + +impl<'c> AsyncHttpClient<'c> for OAuthReqwestClient { + type Error = HttpClientError; + + type Future = std::pin::Pin< + Box> + Send + Sync + 'c>, + >; + + fn call(&'c self, request: HttpRequest) -> Self::Future { + Box::pin(async move { + let response = self + .0 + .execute(request.try_into().map_err(Box::new)?) + .await + .map_err(Box::new)?; + + let mut builder = oauth2::http::Response::builder() + .status(response.status()) + .version(response.version()); + + for (name, value) in response.headers().iter() { + builder = builder.header(name, value); + } + + builder + .body(response.bytes().await.map_err(Box::new)?.to_vec()) + .map_err(HttpClientError::Http) + }) + } +} + const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; /// Stored credentials for OAuth2 authorization @@ -872,7 +906,7 @@ impl AuthorizationManager { .exchange_code(AuthorizationCode::new(code.to_string())) .set_pkce_verifier(pkce_verifier) .add_extra_param("resource", self.base_url.to_string()) - .request_async(&http_client) + .request_async(&OAuthReqwestClient(http_client)) .await { Ok(token) => token, @@ -961,7 +995,7 @@ impl AuthorizationManager { let token_result = oauth_client .exchange_refresh_token(&RefreshToken::new(refresh_token.secret().to_string())) - .request_async(&self.http_client) + .request_async(&OAuthReqwestClient(self.http_client.clone())) .await .map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?; diff --git a/examples/clients/Cargo.toml b/examples/clients/Cargo.toml index 078a9d584..ea35b0211 100644 --- a/examples/clients/Cargo.toml +++ b/examples/clients/Cargo.toml @@ -26,7 +26,7 @@ anyhow = "1.0" url = "2.4" tower = "0.5" axum = "0.8" -reqwest = "0.12" +reqwest = "0.13.2" clap = { version = "4.0", features = ["derive"] } [[example]] diff --git a/examples/servers/Cargo.toml b/examples/servers/Cargo.toml index 1db8ceeeb..12e3aae6a 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -35,7 +35,7 @@ futures = "0.3" rand = { version = "0.10", features = ["std"] } axum = { version = "0.8", features = ["macros"] } schemars = "1.0" -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.13.2", features = ["json"] } chrono = "0.4" uuid = { version = "1.6", features = ["v4", "serde"] } serde_urlencoded = "0.7" diff --git a/examples/simple-chat-client/Cargo.toml b/examples/simple-chat-client/Cargo.toml index e5e17f0d4..e382e63c7 100644 --- a/examples/simple-chat-client/Cargo.toml +++ b/examples/simple-chat-client/Cargo.toml @@ -8,7 +8,7 @@ publish = false tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.13.2", features = ["json"] } anyhow = "1.0" thiserror = "2.0" async-trait = "0.1" diff --git a/examples/transport/Cargo.toml b/examples/transport/Cargo.toml index 9396b4d37..bbb692521 100644 --- a/examples/transport/Cargo.toml +++ b/examples/transport/Cargo.toml @@ -41,7 +41,7 @@ schemars = { version = "1.0", optional = true } hyper = { version = "1", features = ["client", "server", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } tokio-tungstenite = "0.28.0" -reqwest = { version = "0.12" } +reqwest = { version = "0.13.2" } pin-project-lite = "0.2" [[example]] From 3df4c5bf5f074df600e7ebe00a5770344c3fce51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:51:44 -0500 Subject: [PATCH 046/333] chore: release v0.16.0 (#652) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 11 +++++++++++ crates/rmcp/CHANGELOG.md | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 48832dced..ddbff9580 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "0.15.0", path = "./crates/rmcp" } -rmcp-macros = { version = "0.15.0", path = "./crates/rmcp-macros" } +rmcp = { version = "0.16.0", path = "./crates/rmcp" } +rmcp-macros = { version = "0.16.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "0.15.0" +version = "0.16.0" authors = ["4t145 "] license = "Apache-2.0" license-file = "LICENSE" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 5029d9dcf..a45fb54bc 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.15.0...rmcp-macros-v0.16.0) - 2026-02-17 + +### Fixed + +- align task response types with MCP spec ([#658](https://github.com/modelcontextprotocol/rust-sdk/pull/658)) + +### Other + +- include LICENSE in final crate tarball ([#657](https://github.com/modelcontextprotocol/rust-sdk/pull/657)) +- add rudof-mcp to MCP servers list ([#645](https://github.com/modelcontextprotocol/rust-sdk/pull/645)) + ## [0.15.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.14.0...rmcp-macros-v0.15.0) - 2026-02-10 ### Fixed diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 33fa6e849..40c96902a 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.15.0...rmcp-v0.16.0) - 2026-02-17 + +### Added + +- add support for custom HTTP headers in StreamableHttpClient ([#655](https://github.com/modelcontextprotocol/rust-sdk/pull/655)) +- *(auth)* add token_endpoint_auth_method to OAuthClientConfig ([#648](https://github.com/modelcontextprotocol/rust-sdk/pull/648)) + +### Fixed + +- remove unnecessary doc-cfg ([#661](https://github.com/modelcontextprotocol/rust-sdk/pull/661)) +- duplicate meta serialization ([#662](https://github.com/modelcontextprotocol/rust-sdk/pull/662)) +- sort list_all() output in ToolRouter and PromptRouter for deterministic ordering ([#665](https://github.com/modelcontextprotocol/rust-sdk/pull/665)) +- align task response types with MCP spec ([#658](https://github.com/modelcontextprotocol/rust-sdk/pull/658)) + +### Other + +- upgrade reqwest to 0.13.2 ([#669](https://github.com/modelcontextprotocol/rust-sdk/pull/669)) +- include LICENSE in final crate tarball ([#657](https://github.com/modelcontextprotocol/rust-sdk/pull/657)) +- *(deps)* update rand requirement from 0.9 to 0.10 ([#650](https://github.com/modelcontextprotocol/rust-sdk/pull/650)) +- remove unused axum dependency from server-side-http feature ([#642](https://github.com/modelcontextprotocol/rust-sdk/pull/642)) +- 11-25-2025 compliant Auth ([#651](https://github.com/modelcontextprotocol/rust-sdk/pull/651)) +- add rudof-mcp to MCP servers list ([#645](https://github.com/modelcontextprotocol/rust-sdk/pull/645)) + ## [0.15.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.14.0...rmcp-v0.15.0) - 2026-02-10 ### Added From 92b14596470377b2a944668ae3b212bd9d188a45 Mon Sep 17 00:00:00 2001 From: Mark Wotton Date: Thu, 19 Feb 2026 23:19:45 +0700 Subject: [PATCH 047/333] fix(schema): remove AddNullable from draft2020_12 settings (#664) * fix(schema): remove AddNullable from draft2020_12 settings The `nullable` keyword is an OpenAPI 3.0 extension, not part of JSON Schema 2020-12. Using AddNullable with draft2020_12 settings causes validation failures with strict JSON Schema validators. JSON Schema 2020-12 represents nullable types using: - {"type": ["string", "null"]} (type array with null) - {"anyOf": [{"type": "string"}, {"type": "null"}]} Fixes #663 * test(schema): update complex schema nullable expectation * test(schema): align macro optional-field expectations with draft2020 --- crates/rmcp/src/handler/server/common.rs | 6 +++-- crates/rmcp/tests/test_complex_schema.rs | 6 +++-- crates/rmcp/tests/test_tool_macros.rs | 33 ++++++++++++------------ 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/crates/rmcp/src/handler/server/common.rs b/crates/rmcp/src/handler/server/common.rs index 4f344a86f..fc3219e76 100644 --- a/crates/rmcp/src/handler/server/common.rs +++ b/crates/rmcp/src/handler/server/common.rs @@ -23,8 +23,10 @@ pub fn schema_for_type() -> Arc { } else { // explicitly to align json schema version to official specifications. // refer to https://github.com/modelcontextprotocol/modelcontextprotocol/pull/655 for details. - let mut settings = SchemaSettings::draft2020_12(); - settings.transforms = vec![Box::new(schemars::transform::AddNullable::default())]; + let settings = SchemaSettings::draft2020_12(); + // Note: AddNullable is intentionally NOT used here because the `nullable` keyword + // is an OpenAPI 3.0 extension, not part of JSON Schema 2020-12. Using it would + // cause validation failures with strict JSON Schema validators. let generator = settings.into_generator(); let schema = generator.into_root_schema_for::(); let object = serde_json::to_value(schema).expect("failed to serialize schema"); diff --git a/crates/rmcp/tests/test_complex_schema.rs b/crates/rmcp/tests/test_complex_schema.rs index 1bc9051ad..a9c41a3c5 100644 --- a/crates/rmcp/tests/test_complex_schema.rs +++ b/crates/rmcp/tests/test_complex_schema.rs @@ -80,8 +80,10 @@ fn expected_schema() -> serde_json::Value { "type": "array" }, "system": { - "nullable": true, - "type": "string" + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index a7609eecb..837198cbb 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -238,7 +238,7 @@ fn test_optional_field_schema_generation_via_macro() { ); // Verify the schema generated for the aggregated OptionalFieldTestSchema - // by the macro infrastructure (which should now use OpenAPI 3 settings) + // by the macro infrastructure using JSON Schema 2020-12 settings. let input_schema_map = &*tool_attr.input_schema; // Dereference Arc // Check the schema for the 'description' property within the input schema @@ -253,18 +253,21 @@ fn test_optional_field_schema_generation_via_macro() { .as_object() .unwrap(); - // Assert that the format is now `type: "string", nullable: true` + // Assert nullable Option is represented in JSON Schema 2020-12 form. + let type_value = description_schema + .get("type") + .expect("Schema for Option should include a type field"); + let type_array = type_value + .as_array() + .expect("Schema for Option should use a type array [T, null]"); assert_eq!( - description_schema.get("type").map(|v| v.as_str().unwrap()), - Some("string"), - "Schema for Option generated by macro should be type: \"string\"" + type_array, + &vec![serde_json::json!("string"), serde_json::json!("null")], + "Schema for Option should be type: [\"string\", \"null\"]" ); - assert_eq!( - description_schema - .get("nullable") - .map(|v| v.as_bool().unwrap()), - Some(true), - "Schema for Option generated by macro should have nullable: true" + assert!( + description_schema.get("nullable").is_none(), + "Schema for Option should not use OpenAPI nullable in JSON Schema 2020-12" ); // We still check the description is correct assert_eq!( @@ -274,12 +277,8 @@ fn test_optional_field_schema_generation_via_macro() { Some("An optional description field") ); - // Ensure the old 'type: [T, null]' format is NOT used - let type_value = description_schema.get("type").unwrap(); - assert!( - !type_value.is_array(), - "Schema type should not be an array [T, null]" - ); + // Ensure no OpenAPI-only nullable extension was emitted. + assert!(description_schema.get("nullable").is_none()); } // Define a dummy client handler From 0967d714d261afe02f7d07bcf12904588581b78c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:03:38 -0500 Subject: [PATCH 048/333] chore: add CODEOWNERS (#673) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..753e4e4d7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @modelcontextprotocol/rust-sdk From 085470025f690050e8776ffa939e7ba71d3abc01 Mon Sep 17 00:00:00 2001 From: Den Delimarsky <53200638+localden@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:13:46 -0800 Subject: [PATCH 049/333] feat: add SECURITY.md with GitHub Security Advisories guidance (#670) --- SECURITY.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..502924200 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +Thank you for helping keep the Model Context Protocol and its ecosystem secure. + +## Reporting Security Issues + +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. + +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. + +## What to Include + +To help us triage and respond quickly, please include: + +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) From 98eef440c6e3d36adc7ac51fbb47f5fde9b27717 Mon Sep 17 00:00:00 2001 From: Anish Athalye Date: Mon, 23 Feb 2026 19:09:57 -0800 Subject: [PATCH 050/333] fix: allow empty content in CallToolResult (#681) Per the MCP spec [1] and the TypeScript schema [2], `CallToolResult.content` is typed as `ContentBlock[]`, so it is a required array with no minimum length constraint. MCP server libraries use such a representation in practice: for example, FastMCP returns responses with no `structuredContent` and an empty `content` array when tools return `None`. [1]: https://modelcontextprotocol.io/specification/2025-11-25/server/tools [2]: https://github.com/modelcontextprotocol/specification/blob/main/schema/2025-11-25/schema.ts --- crates/rmcp/src/model.rs | 41 +------------- crates/rmcp/tests/test_structured_output.rs | 60 ++++++++++++++++++++- 2 files changed, 60 insertions(+), 41 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index a93f98d10..be951feff 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2196,7 +2196,7 @@ pub type ElicitationCompletionNotification = /// /// Contains the content returned by the tool execution and an optional /// flag indicating whether the operation resulted in an error. -#[derive(Debug, Serialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct CallToolResult { @@ -2310,45 +2310,6 @@ impl CallToolResult { } } -// Custom deserialize implementation to validate mutual exclusivity -impl<'de> Deserialize<'de> for CallToolResult { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct CallToolResultHelper { - #[serde(skip_serializing_if = "Option::is_none")] - content: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - structured_content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - is_error: Option, - /// Accept `_meta` during deserialization - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - meta: Option, - } - - let helper = CallToolResultHelper::deserialize(deserializer)?; - let result = CallToolResult { - content: helper.content.unwrap_or_default(), - structured_content: helper.structured_content, - is_error: helper.is_error, - meta: helper.meta, - }; - - // Validate mutual exclusivity - if result.content.is_empty() && result.structured_content.is_none() { - return Err(serde::de::Error::custom( - "CallToolResult must have either content or structured_content", - )); - } - - Ok(result) - } -} - const_string!(ListToolsRequestMethod = "tools/list"); /// Request to list all available tools from a server pub type ListToolsRequest = RequestOptionalParam; diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index cb9a11b9f..0edb8bce8 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -2,7 +2,7 @@ use rmcp::{ Json, ServerHandler, handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters}, - model::{CallToolResult, Content, Tool}, + model::{CallToolResult, Content, ServerResult, Tool}, tool, tool_handler, tool_router, }; use schemars::JsonSchema; @@ -280,3 +280,61 @@ async fn test_output_schema_requires_structured_content() { assert!(call_result.structured_content.is_some()); assert!(!call_result.content.is_empty()); } + +#[tokio::test] +async fn test_empty_content_array_deserializes() { + let raw = json!({ "content": [] }); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert!(result.content.is_empty()); + assert!(result.structured_content.is_none()); + assert!(result.is_error.is_none()); +} + +#[tokio::test] +async fn test_empty_content_array_with_is_error() { + let raw = json!({ "content": [], "isError": false }); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert!(result.content.is_empty()); + assert_eq!(result.is_error, Some(false)); +} + +#[tokio::test] +async fn test_missing_content_is_rejected() { + let raw = json!({ "isError": false }); + let result: Result = serde_json::from_value(raw); + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_missing_content_with_structured_content_is_rejected() { + let raw = json!({ "structuredContent": {"key": "value"}, "isError": false }); + let result: Result = serde_json::from_value(raw); + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_empty_content_deserializes_as_call_tool_result_variant() { + let raw = json!({ "content": [] }); + let result: ServerResult = serde_json::from_value(raw).unwrap(); + match result { + ServerResult::CallToolResult(call_result) => { + assert!(call_result.content.is_empty()); + assert!(call_result.structured_content.is_none()); + } + other => panic!("Expected CallToolResult, got {:?}", other), + } +} + +#[tokio::test] +async fn test_empty_content_roundtrip() { + let result = CallToolResult { + content: vec![], + structured_content: None, + is_error: Some(false), + meta: None, + }; + let v = serde_json::to_value(&result).unwrap(); + assert_eq!(v["content"], json!([])); + let deserialized: CallToolResult = serde_json::from_value(v).unwrap(); + assert_eq!(deserialized, result); +} From 91e208efb7181fc46e49bf7e0a77fa9dbb78903d Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:02:28 -0500 Subject: [PATCH 051/333] fix: gate optional dependencies behind feature flags (#672) --- crates/rmcp/src/lib.rs | 4 +++- crates/rmcp/src/model/capabilities.rs | 16 ++++++++++++++-- crates/rmcp/src/model/tool.rs | 3 +++ crates/rmcp/src/service.rs | 4 +++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 1050e6aa7..9ae3f9586 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -24,7 +24,9 @@ pub use service::{RoleClient, serve_client}; pub use service::{RoleServer, serve_server}; pub mod handler; +#[cfg(feature = "server")] pub mod task_manager; +#[cfg(any(feature = "client", feature = "server"))] pub mod transport; // re-export @@ -32,7 +34,7 @@ pub mod transport; pub use pastey::paste; #[cfg(all(feature = "macros", feature = "server"))] pub use rmcp_macros::*; -#[cfg(any(feature = "macros", feature = "server"))] +#[cfg(any(feature = "server", feature = "schemars"))] pub use schemars; #[cfg(feature = "macros")] pub use serde; diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index d0f8e1b2e..e5716acca 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -1,5 +1,8 @@ -use std::{collections::BTreeMap, marker::PhantomData}; +use std::collections::BTreeMap; +#[cfg(any(feature = "server", feature = "macros"))] +use std::marker::PhantomData; +#[cfg(any(feature = "server", feature = "macros"))] use pastey::paste; use serde::{Deserialize, Serialize}; @@ -300,6 +303,7 @@ pub struct ServerCapabilities { pub tasks: Option, } +#[cfg(any(feature = "server", feature = "macros"))] macro_rules! builder { ($Target: ident {$($f: ident: $T: ty),* $(,)?}) => { paste! { @@ -405,6 +409,7 @@ macro_rules! builder { } } +#[cfg(any(feature = "server", feature = "macros"))] builder! { ServerCapabilities { experimental: ExperimentalCapabilities, @@ -418,6 +423,7 @@ builder! { } } +#[cfg(any(feature = "server", feature = "macros"))] impl< const E: bool, const EXT: bool, @@ -436,6 +442,7 @@ impl< } } +#[cfg(any(feature = "server", feature = "macros"))] impl< const E: bool, const EXT: bool, @@ -454,6 +461,7 @@ impl< } } +#[cfg(any(feature = "server", feature = "macros"))] impl< const E: bool, const EXT: bool, @@ -479,6 +487,7 @@ impl< } } +#[cfg(any(feature = "server", feature = "macros"))] builder! { ClientCapabilities{ experimental: ExperimentalCapabilities, @@ -490,6 +499,7 @@ builder! { } } +#[cfg(any(feature = "server", feature = "macros"))] impl ClientCapabilitiesBuilder> { @@ -501,6 +511,7 @@ impl ClientCapabilitiesBuilder> { @@ -521,7 +532,7 @@ impl ClientCapabilitiesBuilder> { @@ -539,6 +550,7 @@ impl(mut self) -> Self { let schema = crate::handler::server::tool::schema_for_output::() .unwrap_or_else(|e| panic!("Invalid output schema for tool '{}': {}", self.name, e)); @@ -248,6 +250,7 @@ impl Tool { } /// Set the input schema using a type that implements JsonSchema + #[cfg(feature = "server")] pub fn with_input_schema(mut self) -> Self { self.input_schema = crate::handler::server::tool::schema_for_type::(); self diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 3ad28b788..b12839c6f 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -1,12 +1,14 @@ use futures::{FutureExt, future::BoxFuture}; use thiserror::Error; +#[cfg(feature = "server")] +use crate::model::ServerJsonRpcMessage; use crate::{ error::ErrorData as McpError, model::{ CancelledNotification, CancelledNotificationParam, Extensions, GetExtensions, GetMeta, JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Meta, - NumberOrString, ProgressToken, RequestId, ServerJsonRpcMessage, + NumberOrString, ProgressToken, RequestId, }, transport::{DynamicTransportError, IntoTransport, Transport}, }; From 5fa012d16362064d5afec314e465edd1590800b3 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:08:49 -0500 Subject: [PATCH 052/333] feat: send and validate MCP-Protocol-Version header (#675) --- crates/rmcp/src/model.rs | 9 + .../common/auth/streamable_http_client.rs | 6 +- .../common/reqwest/streamable_http_client.rs | 67 ++-- .../src/transport/streamable_http_client.rs | 86 +++- .../transport/streamable_http_server/tower.rs | 60 ++- crates/rmcp/tests/test_custom_headers.rs | 373 +++++++++++++++++- 6 files changed, 541 insertions(+), 60 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index be951feff..a72301b09 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -155,6 +155,15 @@ impl ProtocolVersion { pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); // Keep LATEST at 2025-03-26 until full 2025-06-18 compliance and automated testing are in place. pub const LATEST: Self = Self::V_2025_03_26; + + /// All protocol versions known to this SDK. + pub const KNOWN_VERSIONS: &[Self] = + &[Self::V_2024_11_05, Self::V_2025_03_26, Self::V_2025_06_18]; + + /// Returns the string representation of this protocol version. + pub fn as_str(&self) -> &str { + &self.0 + } } impl Serialize for ProtocolVersion { diff --git a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs index 35e3ed5a0..47f08f13e 100644 --- a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs @@ -17,13 +17,14 @@ where uri: std::sync::Arc, session_id: std::sync::Arc, mut auth_token: Option, + custom_headers: HashMap, ) -> Result<(), crate::transport::streamable_http_client::StreamableHttpError> { if auth_token.is_none() { auth_token = Some(self.get_access_token().await?); } self.http_client - .delete_session(uri, session_id, auth_token) + .delete_session(uri, session_id, auth_token, custom_headers) .await } @@ -33,6 +34,7 @@ where session_id: std::sync::Arc, last_event_id: Option, mut auth_token: Option, + custom_headers: HashMap, ) -> Result< futures::stream::BoxStream<'static, Result>, crate::transport::streamable_http_client::StreamableHttpError, @@ -41,7 +43,7 @@ where auth_token = Some(self.get_access_token().await?); } self.http_client - .get_stream(uri, session_id, last_event_id, auth_token) + .get_stream(uri, session_id, last_event_id, auth_token, custom_headers) .await } diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index b4cdafd14..5a39b4a4a 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -22,6 +22,43 @@ impl From for StreamableHttpError { } } +/// Reserved headers that must not be overridden by user-supplied custom headers. +/// `MCP-Protocol-Version` is in this list but is allowed through because the worker +/// injects it after initialization. +const RESERVED_HEADERS: &[&str] = &[ + "accept", + HEADER_SESSION_ID, + HEADER_MCP_PROTOCOL_VERSION, + HEADER_LAST_EVENT_ID, +]; + +/// Applies custom headers to a request builder, rejecting reserved headers +/// except `MCP-Protocol-Version` (which the worker injects after init). +fn apply_custom_headers( + mut builder: reqwest::RequestBuilder, + custom_headers: HashMap, +) -> Result> { + for (name, value) in custom_headers { + if RESERVED_HEADERS + .iter() + .any(|&r| name.as_str().eq_ignore_ascii_case(r)) + { + if name + .as_str() + .eq_ignore_ascii_case(HEADER_MCP_PROTOCOL_VERSION) + { + builder = builder.header(name, value); + continue; + } + return Err(StreamableHttpError::ReservedHeaderConflict( + name.to_string(), + )); + } + builder = builder.header(name, value); + } + Ok(builder) +} + impl StreamableHttpClient for reqwest::Client { type Error = reqwest::Error; @@ -31,6 +68,7 @@ impl StreamableHttpClient for reqwest::Client { session_id: Arc, last_event_id: Option, auth_token: Option, + custom_headers: HashMap, ) -> Result>, StreamableHttpError> { let mut request_builder = self .get(uri.as_ref()) @@ -42,6 +80,7 @@ impl StreamableHttpClient for reqwest::Client { if let Some(auth_header) = auth_token { request_builder = request_builder.bearer_auth(auth_header); } + request_builder = apply_custom_headers(request_builder, custom_headers)?; let response = request_builder.send().await?; if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { return Err(StreamableHttpError::ServerDoesNotSupportSse); @@ -70,15 +109,15 @@ impl StreamableHttpClient for reqwest::Client { uri: Arc, session: Arc, auth_token: Option, + custom_headers: HashMap, ) -> Result<(), StreamableHttpError> { let mut request_builder = self.delete(uri.as_ref()); if let Some(auth_header) = auth_token { request_builder = request_builder.bearer_auth(auth_header); } - let response = request_builder - .header(HEADER_SESSION_ID, session.as_ref()) - .send() - .await?; + request_builder = request_builder.header(HEADER_SESSION_ID, session.as_ref()); + request_builder = apply_custom_headers(request_builder, custom_headers)?; + let response = request_builder.send().await?; // if method no allowed if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { @@ -104,25 +143,7 @@ impl StreamableHttpClient for reqwest::Client { request = request.bearer_auth(auth_header); } - // Apply custom headers - let reserved_headers = [ - ACCEPT.as_str(), - HEADER_SESSION_ID, - HEADER_MCP_PROTOCOL_VERSION, - HEADER_LAST_EVENT_ID, - ]; - for (name, value) in custom_headers { - if reserved_headers - .iter() - .any(|&r| name.as_str().eq_ignore_ascii_case(r)) - { - return Err(StreamableHttpError::ReservedHeaderConflict( - name.to_string(), - )); - } - - request = request.header(name, value); - } + request = apply_custom_headers(request, custom_headers)?; if let Some(session_id) = session_id { request = request.header(HEADER_SESSION_ID, session_id.as_ref()); } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index d45613bb5..1c388e503 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -11,7 +11,7 @@ use tracing::debug; use super::common::client_side_sse::{ExponentialBackoff, SseRetryPolicy, SseStreamReconnect}; use crate::{ RoleClient, - model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + model::{ClientJsonRpcMessage, ServerJsonRpcMessage, ServerResult}, transport::{ common::client_side_sse::SseAutoReconnectStream, worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport}, @@ -184,6 +184,7 @@ pub trait StreamableHttpClient: Clone + Send + 'static { uri: Arc, session_id: Arc, auth_header: Option, + custom_headers: HashMap, ) -> impl Future>> + Send + '_; fn get_stream( &self, @@ -191,6 +192,7 @@ pub trait StreamableHttpClient: Clone + Send + 'static { session_id: Arc, last_event_id: Option, auth_header: Option, + custom_headers: HashMap, ) -> impl Future< Output = Result< BoxStream<'static, Result>, @@ -210,6 +212,7 @@ struct StreamableHttpClientReconnect { pub session_id: Arc, pub uri: Arc, pub auth_header: Option, + pub custom_headers: HashMap, } impl SseStreamReconnect for StreamableHttpClientReconnect { @@ -220,15 +223,25 @@ impl SseStreamReconnect for StreamableHttpClientReconne let uri = self.uri.clone(); let session_id = self.session_id.clone(); let auth_header = self.auth_header.clone(); + let custom_headers = self.custom_headers.clone(); let last_event_id = last_event_id.map(|s| s.to_owned()); Box::pin(async move { client - .get_stream(uri, session_id, last_event_id, auth_header) + .get_stream(uri, session_id, last_event_id, auth_header, custom_headers) .await }) } } +/// Info retained for cleaning up the session when the worker exits. +struct SessionCleanupInfo { + client: C, + uri: Arc, + session_id: Arc, + auth_header: Option, + protocol_headers: HashMap, +} + #[derive(Debug, Clone, Default)] pub struct StreamableHttpClientWorker { pub client: C, @@ -357,14 +370,29 @@ impl Worker for StreamableHttpClientWorker { } None }; + // Extract the negotiated protocol version from the init response + // and build a custom headers map that includes MCP-Protocol-Version + // for all subsequent HTTP requests (per MCP 2025-06-18 spec). + let protocol_headers = { + let mut headers = config.custom_headers.clone(); + if let ServerJsonRpcMessage::Response(response) = &message { + if let ServerResult::InitializeResult(init_result) = &response.result { + if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { + // HeaderName::from_static requires lowercase + headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); + } + } + } + headers + }; + // Store session info for cleanup when run() exits (not spawned, so cleanup completes before close() returns) - let session_cleanup_info = session_id.as_ref().map(|sid| { - ( - self.client.clone(), - config.uri.clone(), - sid.clone(), - config.auth_header.clone(), - ) + let session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { + client: self.client.clone(), + uri: config.uri.clone(), + session_id: sid.clone(), + auth_header: config.auth_header.clone(), + protocol_headers: protocol_headers.clone(), }); context.send_to_handler(message).await?; @@ -376,7 +404,7 @@ impl Worker for StreamableHttpClientWorker { initialized_notification.message, session_id.clone(), config.auth_header.clone(), - config.custom_headers.clone(), + protocol_headers.clone(), ) .await .map_err(WorkerQuitReason::fatal_context( @@ -404,10 +432,17 @@ impl Worker for StreamableHttpClientWorker { let transport_task_ct = transport_task_ct.clone(); let config_uri = config.uri.clone(); let config_auth_header = config.auth_header.clone(); + let spawn_headers = protocol_headers.clone(); streams.spawn(async move { match client - .get_stream(uri.clone(), session_id.clone(), None, auth_header.clone()) + .get_stream( + uri.clone(), + session_id.clone(), + None, + auth_header.clone(), + spawn_headers.clone(), + ) .await { Ok(stream) => { @@ -418,6 +453,7 @@ impl Worker for StreamableHttpClientWorker { session_id: session_id.clone(), uri: config_uri, auth_header: config_auth_header, + custom_headers: spawn_headers, }, retry_config, ); @@ -482,7 +518,7 @@ impl Worker for StreamableHttpClientWorker { message, session_id.clone(), config.auth_header.clone(), - config.custom_headers.clone(), + protocol_headers.clone(), ) .await; let send_result = match response { @@ -504,6 +540,7 @@ impl Worker for StreamableHttpClientWorker { session_id: session_id.clone(), uri: config.uri.clone(), auth_header: config.auth_header.clone(), + custom_headers: protocol_headers.clone(), }, self.config.retry_config.clone(), ); @@ -550,32 +587,41 @@ impl Worker for StreamableHttpClientWorker { // Cleanup session before returning (ensures close() waits for session deletion) // Use a timeout to prevent indefinite hangs if the server is unresponsive - if let Some((client, url, session_id, auth_header)) = session_cleanup_info { + if let Some(cleanup) = session_cleanup_info { const SESSION_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + let cleanup_session_id = cleanup.session_id.clone(); match tokio::time::timeout( SESSION_CLEANUP_TIMEOUT, - client.delete_session(url, session_id.clone(), auth_header), + cleanup.client.delete_session( + cleanup.uri, + cleanup.session_id, + cleanup.auth_header, + cleanup.protocol_headers, + ), ) .await { Ok(Ok(_)) => { - tracing::info!(session_id = session_id.as_ref(), "delete session success") + tracing::info!( + session_id = cleanup_session_id.as_ref(), + "delete session success" + ) } Ok(Err(StreamableHttpError::ServerDoesNotSupportDeleteSession)) => { tracing::info!( - session_id = session_id.as_ref(), + session_id = cleanup_session_id.as_ref(), "server doesn't support delete session" ) } Ok(Err(e)) => { tracing::error!( - session_id = session_id.as_ref(), + session_id = cleanup_session_id.as_ref(), "fail to delete session: {e}" ); } Err(_elapsed) => { tracing::warn!( - session_id = session_id.as_ref(), + session_id = cleanup_session_id.as_ref(), "session cleanup timed out after {:?}", SESSION_CLEANUP_TIMEOUT ); @@ -652,6 +698,7 @@ impl Worker for StreamableHttpClientWorker { /// _uri: Arc, /// _session_id: Arc, /// _auth_header: Option, +/// _custom_headers: HashMap, /// ) -> Result<(), rmcp::transport::streamable_http_client::StreamableHttpError> { /// todo!() /// } @@ -662,6 +709,7 @@ impl Worker for StreamableHttpClientWorker { /// _session_id: Arc, /// _last_event_id: Option, /// _auth_header: Option, +/// _custom_headers: HashMap, /// ) -> Result>, rmcp::transport::streamable_http_client::StreamableHttpError> { /// todo!() /// } @@ -737,6 +785,7 @@ impl StreamableHttpClientTransport { /// _uri: Arc, /// _session_id: Arc, /// _auth_header: Option, + /// _custom_headers: HashMap, /// ) -> Result<(), rmcp::transport::streamable_http_client::StreamableHttpError> { /// todo!() /// } @@ -747,6 +796,7 @@ impl StreamableHttpClientTransport { /// _session_id: Arc, /// _last_event_id: Option, /// _auth_header: Option, + /// _custom_headers: HashMap, /// ) -> Result>, rmcp::transport::streamable_http_client::StreamableHttpError> { /// todo!() /// } diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 37d4a008c..4dffb4ea7 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -11,14 +11,15 @@ use tokio_util::sync::CancellationToken; use super::session::SessionManager; use crate::{ RoleServer, - model::{ClientJsonRpcMessage, ClientRequest, GetExtensions}, + model::{ClientJsonRpcMessage, ClientRequest, GetExtensions, ProtocolVersion}, serve_server, service::serve_directly, transport::{ OneshotTransport, TransportAdapterIdentity, common::{ http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION, + HEADER_SESSION_ID, JSON_MIME_TYPE, }, server_side_http::{ BoxResponse, ServerSseMessage, accepted_response, expect_json, @@ -55,6 +56,46 @@ impl Default for StreamableHttpServerConfig { } } +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +/// Validates the `MCP-Protocol-Version` header on incoming HTTP requests. +/// +/// Per the MCP 2025-06-18 spec: +/// - If the header is present but contains an unsupported version, return 400 Bad Request. +/// - If the header is absent, assume `2025-03-26` for backwards compatibility (no error). +fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), BoxResponse> { + if let Some(value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) { + let version_str = value.to_str().map_err(|_| { + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body( + Full::new(Bytes::from( + "Bad Request: Invalid MCP-Protocol-Version header encoding", + )) + .boxed(), + ) + .expect("valid response") + })?; + let is_known = ProtocolVersion::KNOWN_VERSIONS + .iter() + .any(|v| v.as_str() == version_str); + if !is_known { + return Err(Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .body( + Full::new(Bytes::from(format!( + "Bad Request: Unsupported MCP-Protocol-Version: {version_str}" + ))) + .boxed(), + ) + .expect("valid response")); + } + } + Ok(()) +} + /// # Streamable Http Server /// /// ## Extract information from raw http request @@ -207,6 +248,8 @@ where .body(Full::new(Bytes::from("Unauthorized: Session not found")).boxed()) .expect("valid response")); } + // Validate MCP-Protocol-Version header (per 2025-06-18 spec) + validate_protocol_version_header(request.headers())?; // check if last event id is provided let last_event_id = request .headers() @@ -320,6 +363,9 @@ where .expect("valid response")); } + // Validate MCP-Protocol-Version header (per 2025-06-18 spec) + validate_protocol_version_header(&part.headers)?; + // inject request part to extensions match &mut message { ClientJsonRpcMessage::Request(req) => { @@ -455,6 +501,14 @@ where Ok(response) } } else { + // Stateless mode: validate MCP-Protocol-Version on non-init requests + let is_init = matches!( + &message, + ClientJsonRpcMessage::Request(req) if matches!(req.request, ClientRequest::InitializeRequest(_)) + ); + if !is_init { + validate_protocol_version_header(&part.headers)?; + } let service = self .get_service() .map_err(internal_error_response("get service"))?; @@ -511,6 +565,8 @@ where .body(Full::new(Bytes::from("Unauthorized: Session ID is required")).boxed()) .expect("valid response")); }; + // Validate MCP-Protocol-Version header (per 2025-06-18 spec) + validate_protocol_version_header(request.headers())?; // close session self.session_manager .close_session(&session_id) diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index c9307109f..82537a80c 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -190,22 +190,23 @@ async fn test_post_message_rejects_mcp_session_id() { } } -/// Unit test: post_message should reject reserved header "mcp-protocol-version" +/// Unit test: post_message should allow the mcp-protocol-version header through +/// (it is injected by the worker after initialization, not a user-settable custom header) #[tokio::test] #[cfg(feature = "transport-streamable-http-client-reqwest")] -async fn test_post_message_rejects_mcp_protocol_version() { +async fn test_post_message_allows_mcp_protocol_version() { use std::sync::Arc; use rmcp::{ model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, - transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + transport::streamable_http_client::StreamableHttpClient, }; let client = reqwest::Client::new(); let mut custom_headers = HashMap::new(); custom_headers.insert( HeaderName::from_static("mcp-protocol-version"), - HeaderValue::from_static("1.0"), + HeaderValue::from_static("2025-03-26"), ); let message = ClientJsonRpcMessage::request( @@ -223,19 +224,20 @@ async fn test_post_message_rejects_mcp_protocol_version() { ) .await; + // The header should be allowed through (not rejected as reserved). + // The error should be a connection error (no server at localhost:9999), + // not a ReservedHeaderConflict. + assert!(result.is_err(), "Should fail due to connection error"); assert!( - result.is_err(), - "Should reject 'mcp-protocol-version' header" + !matches!( + &result, + Err(rmcp::transport::streamable_http_client::StreamableHttpError::ReservedHeaderConflict( + _ + )) + ), + "MCP-Protocol-Version should not be rejected as reserved, got: {:?}", + result ); - match result { - Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => { - assert_eq!( - header_name, "mcp-protocol-version", - "Error should indicate 'mcp-protocol-version' header" - ); - } - other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other), - } } /// Unit test: post_message should reject reserved header "last-event-id" @@ -529,3 +531,344 @@ async fn test_mcp_custom_headers_sent_to_server() -> anyhow::Result<()> { Ok(()) } + +/// Integration test: Verify that MCP-Protocol-Version header is sent on post-init requests +#[tokio::test] +#[cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest" +))] +async fn test_mcp_protocol_version_header_sent_after_init() -> anyhow::Result<()> { + use std::{net::SocketAddr, sync::Arc}; + + use axum::{ + Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, + routing::post, + }; + use rmcp::{ + ServiceExt, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + }, + }; + use serde_json::json; + use tokio::sync::Mutex; + + type CapturedRequests = Vec<(String, Option)>; + + #[derive(Clone)] + struct ServerState { + /// Captures the MCP-Protocol-Version header value for each request method + protocol_version_by_method: Arc>, + initialized_called: Arc, + } + + async fn mcp_handler( + State(state): State, + headers: http::HeaderMap, + body: Bytes, + ) -> impl IntoResponse { + let protocol_version = headers + .get("mcp-protocol-version") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + if let Ok(json_body) = serde_json::from_slice::(&body) { + let method = json_body + .get("method") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + state + .protocol_version_by_method + .lock() + .await + .push((method.clone(), protocol_version)); + + if method == "initialize" { + let response = json!({ + "jsonrpc": "2.0", + "id": json_body.get("id"), + "result": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + } + }); + return ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-456", + ), + ], + response.to_string(), + ); + } else if method == "notifications/initialized" { + state.initialized_called.notify_one(); + return ( + StatusCode::ACCEPTED, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-456", + ), + ], + String::new(), + ); + } + } + + let response = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {} + }); + ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session-456", + ), + ], + response.to_string(), + ) + } + + let state = ServerState { + protocol_version_by_method: Arc::new(Mutex::new(Vec::new())), + initialized_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let addr = SocketAddr::from(([127, 0, 0, 1], 0)); + let listener = tokio::net::TcpListener::bind(addr).await?; + let port = listener.local_addr()?.port(); + + let server_handle = tokio::spawn(async move { axum::serve(listener, app).await }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let config = + StreamableHttpClientTransportConfig::with_uri(format!("http://127.0.0.1:{}/mcp", port)); + + let transport = StreamableHttpClientTransport::from_config(config); + let client = ().serve(transport).await.expect("Failed to start client"); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialized_called.notified(), + ) + .await + .expect("Initialized notification should be received"); + + // Give time for the initialized notification to be fully processed + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let captured = state.protocol_version_by_method.lock().await; + + // The initialize request should NOT have MCP-Protocol-Version + // (the version isn't known yet) + let init_entry = captured + .iter() + .find(|(m, _)| m == "initialize") + .expect("Should have captured initialize request"); + assert_eq!( + init_entry.1, None, + "Initialize request should not have MCP-Protocol-Version header" + ); + + // The initialized notification should HAVE MCP-Protocol-Version + let initialized_entry = captured + .iter() + .find(|(m, _)| m == "notifications/initialized") + .expect("Should have captured initialized notification"); + assert_eq!( + initialized_entry.1, + Some("2025-03-26".to_string()), + "Initialized notification should include MCP-Protocol-Version: 2025-03-26" + ); + + drop(client); + server_handle.abort(); + + Ok(()) +} + +/// Integration test: Verify server rejects unsupported MCP-Protocol-Version with 400 +#[tokio::test] +#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] +async fn test_server_rejects_unsupported_protocol_version() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().build(), + ..Default::default() + } + } + } + + let session_manager = Arc::new(LocalSessionManager::default()); + let service = StreamableHttpService::new( + || Ok(TestHandler), + session_manager, + StreamableHttpServerConfig::default(), + ); + + // First, send an initialize request to create a session + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + let init_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(init_request).await; + assert_eq!(response.status(), http::StatusCode::OK); + + // Extract session id from response + let session_id = response + .headers() + .get("mcp-session-id") + .expect("Should have session id") + .to_str() + .unwrap() + .to_string(); + + // Send initialized notification to complete handshake + let initialized_body = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let initialized_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("mcp-session-id", &session_id) + .header("mcp-protocol-version", "2025-03-26") + .body(Full::new(Bytes::from(initialized_body.to_string()))) + .unwrap(); + + let response = service.handle(initialized_request).await; + assert_eq!(response.status(), http::StatusCode::ACCEPTED); + + // Test 1: Valid protocol version should succeed + let valid_body = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let valid_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("mcp-session-id", &session_id) + .header("mcp-protocol-version", "2025-03-26") + .body(Full::new(Bytes::from(valid_body.to_string()))) + .unwrap(); + + let response = service.handle(valid_request).await; + assert_eq!( + response.status(), + http::StatusCode::ACCEPTED, + "Valid MCP-Protocol-Version should be accepted" + ); + + // Test 2: Unsupported protocol version should return 400 + let invalid_body = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let invalid_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("mcp-session-id", &session_id) + .header("mcp-protocol-version", "9999-01-01") + .body(Full::new(Bytes::from(invalid_body.to_string()))) + .unwrap(); + + let response = service.handle(invalid_request).await; + assert_eq!( + response.status(), + http::StatusCode::BAD_REQUEST, + "Unsupported MCP-Protocol-Version should return 400" + ); + + // Test 3: Missing protocol version should succeed (backwards compat) + let no_version_body = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + let no_version_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("mcp-session-id", &session_id) + .body(Full::new(Bytes::from(no_version_body.to_string()))) + .unwrap(); + + let response = service.handle(no_version_request).await; + assert_eq!( + response.status(), + http::StatusCode::ACCEPTED, + "Missing MCP-Protocol-Version should be accepted (backwards compat)" + ); +} + +/// Unit test: ProtocolVersion::as_str and KNOWN_VERSIONS +#[test] +fn test_protocol_version_utilities() { + use rmcp::model::ProtocolVersion; + + assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18"); + assert_eq!(ProtocolVersion::V_2025_03_26.as_str(), "2025-03-26"); + assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05"); + + assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 3); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18)); +} From 66c70006267e45192769efb9dd0d93f0e5a74437 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:58:55 -0500 Subject: [PATCH 053/333] docs: document session management for streamable HTTP transport (#674) --- .../streamable_http_server/session.rs | 50 +++++++++++- .../transport/streamable_http_server/tower.rs | 78 ++++++++++++++++++- examples/servers/src/common/counter.rs | 17 ++++ 3 files changed, 140 insertions(+), 5 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/session.rs b/crates/rmcp/src/transport/streamable_http_server/session.rs index d9c5c7f49..9cf4d0dbc 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session.rs @@ -1,3 +1,25 @@ +//! Session management for the Streamable HTTP transport. +//! +//! A *session* groups the logically related interactions between a single MCP +//! client and the server, starting from the `initialize` handshake. The server +//! assigns each session a unique [`SessionId`] (returned to the client via the +//! `Mcp-Session-Id` response header) and the client includes that ID on every +//! subsequent request. +//! +//! Two tool calls carrying the same session ID come from the same logical +//! session; different IDs mean different clients or conversations. +//! +//! # Implementations +//! +//! * [`local::LocalSessionManager`] — in-memory session store (default). +//! * [`never::NeverSessionManager`] — rejects all session operations, used +//! when stateful mode is disabled. +//! +//! # Custom session managers +//! +//! Implement the [`SessionManager`] trait to back sessions with a database, +//! Redis, or any other external store. + use futures::Stream; pub use crate::transport::common::server_side_http::{ServerSseMessage, SessionId}; @@ -9,22 +31,40 @@ use crate::{ pub mod local; pub mod never; +/// Controls how MCP sessions are created, validated, and closed. +/// +/// The [`StreamableHttpService`](super::StreamableHttpService) calls into this +/// trait for every HTTP request that carries (or should carry) a session ID. +/// +/// See the [module-level docs](self) for background on sessions. pub trait SessionManager: Send + Sync + 'static { type Error: std::error::Error + Send + 'static; type Transport: crate::transport::Transport; - /// Create a new session with the given id and configuration. + + /// Create a new session and return its ID together with the transport + /// that will be used to exchange MCP messages within this session. fn create_session( &self, ) -> impl Future> + Send; + + /// Forward the first message (the `initialize` request) to the session. fn initialize_session( &self, id: &SessionId, message: ClientJsonRpcMessage, ) -> impl Future> + Send; + + /// Return `true` if a session with the given ID exists and is active. fn has_session(&self, id: &SessionId) -> impl Future> + Send; + + /// Close and remove the session. Corresponds to an HTTP DELETE request + /// with `Mcp-Session-Id`. fn close_session(&self, id: &SessionId) -> impl Future> + Send; + + /// Route a client request into the session and return an SSE stream + /// carrying the server's response(s). fn create_stream( &self, id: &SessionId, @@ -32,17 +72,25 @@ pub trait SessionManager: Send + Sync + 'static { ) -> impl Future< Output = Result + Send + Sync + 'static, Self::Error>, > + Send; + + /// Accept a notification, response, or error message from the client + /// without producing a response stream. fn accept_message( &self, id: &SessionId, message: ClientJsonRpcMessage, ) -> impl Future> + Send; + + /// Create an SSE stream not tied to a specific client request (HTTP GET). fn create_standalone_stream( &self, id: &SessionId, ) -> impl Future< Output = Result + Send + Sync + 'static, Self::Error>, > + Send; + + /// Resume an SSE stream from the given `Last-Event-ID`, replaying any + /// events the client missed. fn resume( &self, id: &SessionId, diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 4dffb4ea7..c3ac407f5 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -96,12 +96,38 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box Ok(()) } -/// # Streamable Http Server +/// # Streamable HTTP server /// -/// ## Extract information from raw http request +/// An HTTP service that implements the +/// [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) +/// for MCP servers. +/// +/// ## Session management +/// +/// When [`StreamableHttpServerConfig::stateful_mode`] is `true` (the default), +/// the server creates a session for each client that sends an `initialize` +/// request. The session ID is returned in the `Mcp-Session-Id` response header +/// and the client must include it on all subsequent requests. +/// +/// Two tool calls carrying the same `Mcp-Session-Id` come from the same logical +/// session (typically one conversation in an LLM client). Different session IDs +/// mean different sessions. +/// +/// The [`SessionManager`] trait controls how sessions are stored and routed: +/// +/// * [`LocalSessionManager`](super::session::local::LocalSessionManager) — +/// in-memory session store (default). +/// * [`NeverSessionManager`](super::session::never::NeverSessionManager) — +/// disables sessions entirely (stateless mode). +/// +/// ## Accessing HTTP request data from tool handlers +/// +/// The service consumes the request body but injects the remaining +/// [`http::request::Parts`] into [`crate::model::Extensions`], which is +/// accessible through [`crate::service::RequestContext`]. +/// +/// ### Reading the raw HTTP parts /// -/// The http service will consume the request body, however the rest part will be remain and injected into [`crate::model::Extensions`], -/// which you can get from [`crate::service::RequestContext`]. /// ```rust /// use rmcp::handler::server::tool::Extension; /// use http::request::Parts; @@ -109,6 +135,50 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box /// tracing::info!("http parts:{parts:?}") /// } /// ``` +/// +/// ### Reading the session ID inside a tool handler +/// +/// ```rust,ignore +/// use rmcp::handler::server::tool::Extension; +/// use rmcp::service::RequestContext; +/// use rmcp::model::RoleServer; +/// +/// #[tool(description = "session-aware tool")] +/// async fn my_tool( +/// &self, +/// Extension(parts): Extension, +/// ) -> Result { +/// if let Some(session_id) = parts.headers.get("mcp-session-id") { +/// tracing::info!(?session_id, "called from session"); +/// } +/// // ... +/// # todo!() +/// } +/// ``` +/// +/// ### Accessing custom axum/tower extension state +/// +/// State added via axum's `Extension` layer is available inside +/// `Parts.extensions`: +/// +/// ```rust,ignore +/// use rmcp::service::RequestContext; +/// use rmcp::model::RoleServer; +/// +/// #[derive(Clone)] +/// struct AppState { /* ... */ } +/// +/// #[tool(description = "example")] +/// async fn my_tool( +/// &self, +/// ctx: RequestContext, +/// ) -> Result { +/// let parts = ctx.extensions.get::().unwrap(); +/// let state = parts.extensions.get::().unwrap(); +/// // use state... +/// # todo!() +/// } +/// ``` pub struct StreamableHttpService { pub config: StreamableHttpServerConfig, session_manager: Arc, diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index 9ca043f88..e92b142af 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -136,6 +136,23 @@ impl Counter { (a + b).to_string(), )])) } + + /// Returns the `Mcp-Session-Id` of the current session (streamable HTTP only). + #[tool(description = "Get the session ID for this connection")] + fn get_session_id(&self, ctx: RequestContext) -> Result { + let session_id = ctx + .extensions + .get::() + .and_then(|parts| parts.headers.get("mcp-session-id")) + .map(|v| v.to_str().unwrap_or("(non-ascii)").to_owned()); + + match session_id { + Some(id) => Ok(CallToolResult::success(vec![Content::text(id)])), + None => Ok(CallToolResult::success(vec![Content::text( + "no session (not running over streamable HTTP?)", + )])), + } + } } #[prompt_router] From 83808d311496ab63eaa457769667844009d5f2e5 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Tue, 24 Feb 2026 09:02:50 -0800 Subject: [PATCH 054/333] fix: refresh token expiry (#680) --- crates/rmcp/src/transport/auth.rs | 197 ++++++++++++++++++++++++++---- 1 file changed, 175 insertions(+), 22 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 1ff2ddd72..1ee1ceeb4 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use async_trait::async_trait; use oauth2::{ @@ -61,6 +65,8 @@ pub struct StoredCredentials { pub token_response: Option, #[serde(default)] pub granted_scopes: Vec, + #[serde(default)] + pub token_received_at: Option, } /// Trait for storing and retrieving OAuth2 credentials @@ -943,34 +949,67 @@ impl AuthorizationManager { client_id, token_response: Some(token_result.clone()), granted_scopes, + token_received_at: Some(Self::now_epoch_secs()), }; self.credential_store.save(stored).await?; Ok(token_result) } + fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + /// Proactive refresh buffer: refresh tokens this many seconds before they expire + /// to avoid races between token retrieval and the actual HTTP request. + const REFRESH_BUFFER_SECS: u64 = 30; + /// get access token, if expired, refresh it automatically pub async fn get_access_token(&self) -> Result { - // Load credentials from store let stored = self.credential_store.load().await?; - let credentials = stored.and_then(|s| s.token_response); - - if let Some(creds) = credentials.as_ref() { - // check token expiry if we have a refresh token or an expiry time - if creds.refresh_token().is_some() || creds.expires_in().is_some() { - let expires_in = creds.expires_in().unwrap_or(Duration::from_secs(0)); - if expires_in <= Duration::from_secs(0) { - tracing::info!("Access token expired, refreshing."); - - let new_creds = self.refresh_token().await?; - tracing::info!("Refreshed access token."); - return Ok(new_creds.access_token().secret().to_string()); - } + let Some(stored_creds) = stored else { + return Err(AuthError::AuthorizationRequired); + }; + let Some(creds) = stored_creds.token_response.as_ref() else { + return Err(AuthError::AuthorizationRequired); + }; + + if let (Some(expires_in), Some(received_at)) = + (creds.expires_in(), stored_creds.token_received_at) + { + let elapsed = Self::now_epoch_secs().saturating_sub(received_at); + let remaining = expires_in.as_secs().saturating_sub(elapsed); + + if remaining < Self::REFRESH_BUFFER_SECS { + tracing::info!( + remaining_secs = remaining, + "Access token expired or nearly expired, refreshing." + ); + return self.try_refresh_or_reauth().await; } + } - Ok(creds.access_token().secret().to_string()) - } else { - Err(AuthError::AuthorizationRequired) + Ok(creds.access_token().secret().to_string()) + } + + /// Attempt to refresh the token. If refresh fails because there is no + /// refresh token or the server rejected it, return `AuthorizationRequired` + /// so the caller can re-prompt the user. Infrastructure errors (e.g. store + /// I/O failures, misconfigured client) are propagated as-is. + async fn try_refresh_or_reauth(&self) -> Result { + match self.refresh_token().await { + Ok(new_creds) => { + tracing::info!("Refreshed access token."); + Ok(new_creds.access_token().secret().to_string()) + } + Err(AuthError::AuthorizationRequired | AuthError::TokenRefreshFailed(_)) => { + tracing::warn!("Token refresh not possible, re-authorization required."); + Err(AuthError::AuthorizationRequired) + } + Err(e) => Err(e), } } @@ -999,10 +1038,10 @@ impl AuthorizationManager { .await .map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?; - let granted_scopes: Vec = token_result - .scopes() - .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) - .unwrap_or_else(|| self.current_scopes.blocking_read().clone()); + let granted_scopes: Vec = match token_result.scopes() { + Some(scopes) => scopes.iter().map(|s| s.to_string()).collect(), + None => self.current_scopes.read().await.clone(), + }; *self.current_scopes.write().await = granted_scopes.clone(); @@ -1011,6 +1050,7 @@ impl AuthorizationManager { client_id, token_response: Some(token_result.clone()), granted_scopes, + token_received_at: Some(Self::now_epoch_secs()), }; self.credential_store.save(stored).await?; @@ -1618,6 +1658,7 @@ impl OAuthState { client_id: client_id.to_string(), token_response: Some(credentials), granted_scopes, + token_received_at: Some(AuthorizationManager::now_epoch_secs()), }; manager.credential_store.save(stored).await?; @@ -2636,4 +2677,116 @@ mod tests { *manager.scope_upgrade_attempts.write().await = 1; assert!(manager.can_attempt_scope_upgrade().await); } + + // -- get_access_token -- + + fn make_token_response(access_token: &str, expires_in_secs: Option) -> OAuthTokenResponse { + use oauth2::{AccessToken, EmptyExtraTokenFields, basic::BasicTokenType}; + let mut resp = OAuthTokenResponse::new( + AccessToken::new(access_token.to_string()), + BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + if let Some(secs) = expires_in_secs { + resp.set_expires_in(Some(&std::time::Duration::from_secs(secs))); + } + resp + } + + use super::{OAuthTokenResponse, StoredCredentials}; + + #[tokio::test] + async fn get_access_token_returns_error_when_no_credentials() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + let err = manager.get_access_token().await.unwrap_err(); + assert!(matches!(err, AuthError::AuthorizationRequired)); + } + + #[tokio::test] + async fn get_access_token_returns_token_when_not_expired() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + let stored = StoredCredentials { + client_id: "test".to_string(), + token_response: Some(make_token_response("my-access-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }; + manager.credential_store.save(stored).await.unwrap(); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "my-access-token"); + } + + #[tokio::test] + async fn get_access_token_requires_reauth_when_expired_and_no_refresh_token() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response("stale-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.get_access_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when token is expired and refresh is impossible, got: {err:?}" + ); + } + + #[tokio::test] + async fn get_access_token_returns_token_without_expiry_info() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + let stored = StoredCredentials { + client_id: "test".to_string(), + token_response: Some(make_token_response("no-expiry-token", None)), + granted_scopes: vec![], + token_received_at: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "no-expiry-token"); + } + + #[tokio::test] + async fn get_access_token_requires_reauth_when_within_refresh_buffer() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response("almost-expired", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs() - 3590), + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.get_access_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when token is within refresh buffer, got: {err:?}" + ); + } + + #[tokio::test] + async fn get_access_token_propagates_internal_errors() { + let manager = AuthorizationManager::new("http://localhost").await.unwrap(); + let stored = StoredCredentials { + client_id: "test".to_string(), + token_response: Some(make_token_response("stale-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.get_access_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::InternalError(_)), + "expected InternalError when OAuth client is not configured, got: {err:?}" + ); + } } From fd6460bdc5d770e098d6c22b008f287d0c4f9f96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:03:18 -0500 Subject: [PATCH 055/333] chore(deps): update rig-core requirement from 0.29.0 to 0.31.0 (#679) * chore(deps): update rig-core requirement from 0.29.0 to 0.31.0 Updates the requirements on [rig-core](https://github.com/0xPlaygrounds/rig) to permit the latest version. - [Release notes](https://github.com/0xPlaygrounds/rig/releases) - [Commits](https://github.com/0xPlaygrounds/rig/compare/rig-core-v0.29.0...rig-core-v0.31.0) --- updated-dependencies: - dependency-name: rig-core dependency-version: 0.31.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * fix: address breaking changes --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- examples/rig-integration/Cargo.toml | 2 +- examples/rig-integration/src/chat.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/rig-integration/Cargo.toml b/examples/rig-integration/Cargo.toml index 2d7a69dce..9429975eb 100644 --- a/examples/rig-integration/Cargo.toml +++ b/examples/rig-integration/Cargo.toml @@ -13,7 +13,7 @@ readme = { workspace = true } publish = false [dependencies] -rig-core = "0.29.0" +rig-core = "0.31.0" tokio = { version = "1", features = ["full"] } rmcp = { workspace = true, features = [ "client", diff --git a/examples/rig-integration/src/chat.rs b/examples/rig-integration/src/chat.rs index bc50be1ac..13d28ab56 100644 --- a/examples/rig-integration/src/chat.rs +++ b/examples/rig-integration/src/chat.rs @@ -46,7 +46,7 @@ where output_agent(&text, &mut output).await?; } Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::ToolCall(tool_call), + StreamedAssistantContent::ToolCall { tool_call, .. }, )) => { let name = &tool_call.function.name; let arguments = &tool_call.function.arguments; From 3cb855bcf8a2db07cdfbe89ae1cc50c84f8496d4 Mon Sep 17 00:00:00 2001 From: Guy Lichtman <1395797+glicht@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:04:10 +0200 Subject: [PATCH 056/333] fix(auth): current_scopes read to async (#678) From 332fcbfb916a6775d0ed8f7d930384293ee73e02 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 24 Feb 2026 17:16:42 -0500 Subject: [PATCH 057/333] Fix/sse channel replacement conflict (#682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(streamable-http): return 409 Conflict when standalone SSE stream already active LocalSessionWorker::resume() unconditionally replaced self.common.tx on every GET request, orphaning the receiver the first SSE stream was reading from. All subsequent server-to-client notifications were sent to the new sender while the original client was still listening on the old, now-dead receiver. notify_tool_list_changed().await returned Ok(()) silently. This is triggered by VS Code's MCP extension which reconnects SSE every ~5 minutes with the same session ID. Fix: Check tx.is_closed() before replacing the common channel sender. If an active stream exists, return SessionError::Conflict which is propagated as HTTP 409 Conflict. This matches the TypeScript SDK behavior (streamableHttp.ts:423). Signed-off-by: Mohammod Al Amin Ashik * fix(streamable-http): handle resume with completed request-wise channel When a client sends GET with Last-Event-ID from a completed POST SSE response, the request-wise channel no longer exists in tx_router. Previously this returned ChannelClosed -> 500, causing clients like Cursor to enter an infinite re-initialization loop. Now falls back to the common channel when the request-wise channel is completed, per MCP spec: "Resumption applies regardless of how the original stream was initiated (POST or GET)." * fix: allow SSE channel replacement instead of 409 Conflict Per MCP spec §Streamable HTTP, "The client MAY remain connected to multiple SSE streams simultaneously." Returning 409 Conflict when a second GET arrives causes Cursor to enter an infinite re-initialization loop (~3s cycle). Instead of rejecting, replace the old common channel sender. Dropping the old sender closes the old receiver, cleanly terminating the previous SSE stream so the client can reconnect on the new stream. This fixes both code paths: - GET with Last-Event-ID from a completed POST SSE response - GET without Last-Event-ID (standalone stream reconnection) * fix: skip cache replay when replacing active SSE stream When a client opens a new GET SSE stream while a previous one is still active, the old sender is dropped (terminating the old stream) and a new channel is created. Previously, sync() replayed all cached events to the new stream, but the client already received those events on the old stream. This caused an infinite notification loop: 1. Client receives notifications (e.g. ResourceListChanged) 2. Old SSE stream dies (sender replaced) 3. Client reconnects after sse_retry (3s) 4. sync() replays cached notifications the client already handled 5. Client processes them again → goto 2 Fix: check tx.is_closed() BEFORE replacing the sender. If the old stream was still alive, skip replay entirely — the client already has those events. Only replay when the old stream was genuinely dead (network failure, timeout) so the client catches up on missed events. * fix: use shadow channels to prevent SSE reconnect loops When POST SSE responses include a `retry` field, the browser's EventSource automatically reconnects via GET after the stream ends. This creates multiple competing EventSource connections that each replace the common channel sender, killing the other stream's receiver. Both reconnect every sse_retry seconds, creating an infinite loop. Instead of always replacing the common channel, check if the primary is still active. If so, create a "shadow" stream — an idle SSE connection kept alive by keep-alive pings that doesn't receive notifications or interfere with the primary channel. Also removes cache replay (sync) on common channel resume, as replaying server-initiated list_changed notifications causes clients to re-process old signals. Signed-off-by: Myko Ash Signed-off-by: Mohammod Al Amin Ashik * test: comprehensive shadow channel tests (15 cases) Rewrite test suite for SSE channel replacement fix: - Shadow creation: standalone GET returns 200, multiple GETs coexist - Dead primary: replacement, notification delivery, repeated cycles - Notification routing: primary receives, shadow does not - Resume paths: completed request-wise, common alive/dead - Real scenarios: Cursor leapfrog, VS Code reconnect - Edge cases: invalid session, missing header, shadow cleanup Fix Accept header bug (was missing text/event-stream for notifications/initialized POST, causing 406 rejection). * fix: use correct HTTP status codes for session errors per MCP spec MCP spec (2025-11-25) section "Session Management" requires: - Missing session ID header → 400 Bad Request (not 401) - Unknown/terminated session → 404 Not Found (not 401) Using 401 Unauthorized caused MCP clients (e.g. VS Code) to trigger full OAuth re-authentication on server restart, instead of simply re-initializing the session. Signed-off-by: Mohammod Al Amin Ashik * fix: address review feedback — remove dead Conflict variant, restore sync on resume, rename test - Remove unused SessionError::Conflict and dead string-matching in tower.rs (leftover from abandoned 409 approach) - Restore sync() replay when replacing a dead primary common channel so server-initiated requests and cached notifications are not lost on reconnect - Rename test from test_sse_channel_replacement_bug to test_sse_concurrent_streams per reviewer suggestion (describe what tests verify, not what triggered them) - Add test for cache replay on dead primary replacement - Use generic "MCP clients" in comments instead of specific client names Signed-off-by: Mohammod Al Amin Ashik * fix: use minimal buffer for shadow streams and cap at 32 - Shadow streams only receive SSE keep-alive pings, so use capacity 1 instead of full channel_capacity - Cap shadow_txs at 32 to prevent unbounded growth from misbehaving clients, dropping the oldest shadow when the limit is reached - Add test verifying primary works after exceeding shadow limit Signed-off-by: Mohammod Al Amin Ashik * fix: remove redundant single-component `use reqwest` import Fixes clippy::single_component_path_imports lint error in test_sse_concurrent_streams.rs. --------- Signed-off-by: Mohammod Al Amin Ashik Signed-off-by: Myko Ash Co-authored-by: Mohammod Al Amin Ashik --- crates/rmcp/Cargo.toml | 5 + .../streamable_http_server/session/local.rs | 116 ++- .../transport/streamable_http_server/tower.rs | 24 +- .../rmcp/tests/test_sse_concurrent_streams.rs | 783 ++++++++++++++++++ 4 files changed, 891 insertions(+), 37 deletions(-) create mode 100644 crates/rmcp/tests/test_sse_concurrent_streams.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index e62caaf4c..ea2a3f264 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -241,3 +241,8 @@ required-features = [ "transport-streamable-http-server", ] path = "tests/test_custom_headers.rs" + +[[test]] +name = "test_sse_concurrent_streams" +required-features = ["server", "client", "transport-streamable-http-server", "transport-streamable-http-client", "reqwest"] +path = "tests/test_sse_concurrent_streams.rs" diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index d68d63e1a..6e197b5b8 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -293,6 +293,12 @@ pub struct LocalSessionWorker { tx_router: HashMap, resource_router: HashMap, common: CachedTx, + /// Shadow senders for secondary SSE streams (e.g. from POST EventSource + /// reconnections). These keep the HTTP connections alive via SSE keep-alive + /// without receiving notifications, preventing MCP clients from entering + /// infinite reconnect loops when multiple EventSource connections compete + /// to replace the common channel. + shadow_txs: Vec>, event_rx: Receiver, session_config: SessionConfig, } @@ -513,36 +519,92 @@ impl LocalSessionWorker { &mut self, last_event_id: EventId, ) -> Result { + // Clean up closed shadow senders before processing + self.shadow_txs.retain(|tx| !tx.is_closed()); + match last_event_id.http_request_id { Some(http_request_id) => { - let request_wise = self - .tx_router - .get_mut(&http_request_id) - .ok_or(SessionError::ChannelClosed(Some(http_request_id)))?; - let channel = tokio::sync::mpsc::channel(self.session_config.channel_capacity); - let (tx, rx) = channel; - request_wise.tx.tx = tx; - let index = last_event_id.index; - // sync messages after index - request_wise.tx.sync(index).await?; - Ok(StreamableHttpMessageReceiver { - http_request_id: Some(http_request_id), - inner: rx, - }) + if let Some(request_wise) = self.tx_router.get_mut(&http_request_id) { + // Resume existing request-wise channel + let channel = tokio::sync::mpsc::channel(self.session_config.channel_capacity); + let (tx, rx) = channel; + request_wise.tx.tx = tx; + let index = last_event_id.index; + // sync messages after index + request_wise.tx.sync(index).await?; + Ok(StreamableHttpMessageReceiver { + http_request_id: Some(http_request_id), + inner: rx, + }) + } else { + // Request-wise channel completed (POST response already delivered). + // The client's EventSource is reconnecting after the POST SSE stream + // ended. Fall through to common channel handling below. + tracing::debug!( + http_request_id, + "Request-wise channel completed, falling back to common channel" + ); + self.resume_or_shadow_common(last_event_id.index).await + } } - None => { - let channel = tokio::sync::mpsc::channel(self.session_config.channel_capacity); - let (tx, rx) = channel; - self.common.tx = tx; - let index = last_event_id.index; - // sync messages after index - self.common.sync(index).await?; - Ok(StreamableHttpMessageReceiver { - http_request_id: None, - inner: rx, - }) + None => self.resume_or_shadow_common(last_event_id.index).await, + } + } + + /// Resume the common channel, or create a shadow stream if the primary is + /// still active. + /// + /// When the primary common channel is dead (receiver dropped), replace it + /// so this stream becomes the new primary notification channel. Cached + /// messages are replayed from `last_event_index` so the client receives + /// any events it missed (including server-initiated requests). + /// + /// When the primary is still active, create a "shadow" stream — an idle SSE + /// connection kept alive by keep-alive pings. This prevents multiple + /// EventSource connections (e.g. from POST response reconnections) from + /// killing each other by repeatedly replacing the common channel sender. + async fn resume_or_shadow_common( + &mut self, + last_event_index: usize, + ) -> Result { + let is_replacing_dead_primary = self.common.tx.is_closed(); + let capacity = if is_replacing_dead_primary { + self.session_config.channel_capacity + } else { + 1 // Shadow streams only need keep-alive pings + }; + let (tx, rx) = tokio::sync::mpsc::channel(capacity); + if is_replacing_dead_primary { + // Primary common channel is dead — replace it. + tracing::debug!("Replacing dead common channel with new primary"); + self.common.tx = tx; + // Replay cached messages from where the client left off so + // server-initiated requests and notifications are not lost. + self.common.sync(last_event_index).await?; + } else { + // Primary common channel is still active. Create a shadow stream + // that stays alive via SSE keep-alive but doesn't receive + // notifications. This prevents competing EventSource connections + // from killing each other's channels. + const MAX_SHADOW_STREAMS: usize = 32; + + if self.shadow_txs.len() >= MAX_SHADOW_STREAMS { + tracing::warn!( + shadow_count = self.shadow_txs.len(), + "Shadow stream limit reached, dropping oldest" + ); + self.shadow_txs.remove(0); } + tracing::debug!( + shadow_count = self.shadow_txs.len(), + "Common channel active, creating shadow stream" + ); + self.shadow_txs.push(tx); } + Ok(StreamableHttpMessageReceiver { + http_request_id: None, + inner: rx, + }) } async fn close_sse_stream( @@ -584,6 +646,9 @@ impl LocalSessionWorker { let (tx, _rx) = tokio::sync::mpsc::channel(1); self.common.tx = tx; + // Also close all shadow streams + self.shadow_txs.clear(); + tracing::debug!("closed standalone SSE stream for server-initiated disconnection"); Ok(()) } @@ -1036,6 +1101,7 @@ pub fn create_local_session( tx_router: HashMap::new(), resource_router: HashMap::new(), common, + shadow_txs: Vec::new(), event_rx, session_config: config.clone(), }; diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index c3ac407f5..0fbe98769 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -299,10 +299,10 @@ where .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned().into()); let Some(session_id) = session_id else { - // unauthorized + // MCP spec: servers that require a session ID SHOULD respond with 400 Bad Request return Ok(Response::builder() - .status(http::StatusCode::UNAUTHORIZED) - .body(Full::new(Bytes::from("Unauthorized: Session ID is required")).boxed()) + .status(http::StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) .expect("valid response")); }; // check if session exists @@ -312,10 +312,10 @@ where .await .map_err(internal_error_response("check session"))?; if !has_session { - // unauthorized + // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions return Ok(Response::builder() - .status(http::StatusCode::UNAUTHORIZED) - .body(Full::new(Bytes::from("Unauthorized: Session not found")).boxed()) + .status(http::StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) .expect("valid response")); } // Validate MCP-Protocol-Version header (per 2025-06-18 spec) @@ -426,10 +426,10 @@ where .await .map_err(internal_error_response("check session"))?; if !has_session { - // unauthorized + // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions return Ok(Response::builder() - .status(http::StatusCode::UNAUTHORIZED) - .body(Full::new(Bytes::from("Unauthorized: Session not found")).boxed()) + .status(http::StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) .expect("valid response")); } @@ -629,10 +629,10 @@ where .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned().into()); let Some(session_id) = session_id else { - // unauthorized + // MCP spec: servers that require a session ID SHOULD respond with 400 Bad Request return Ok(Response::builder() - .status(http::StatusCode::UNAUTHORIZED) - .body(Full::new(Bytes::from("Unauthorized: Session ID is required")).boxed()) + .status(http::StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) .expect("valid response")); }; // Validate MCP-Protocol-Version header (per 2025-06-18 spec) diff --git a/crates/rmcp/tests/test_sse_concurrent_streams.rs b/crates/rmcp/tests/test_sse_concurrent_streams.rs new file mode 100644 index 000000000..9a7204ab9 --- /dev/null +++ b/crates/rmcp/tests/test_sse_concurrent_streams.rs @@ -0,0 +1,783 @@ +/// Tests for concurrent SSE stream handling (shadow channels) +/// +/// These tests verify that multiple GET SSE streams on the same session +/// don't kill each other by replacing the common channel sender. +/// +/// Root cause: When POST SSE responses include `retry`, the EventSource API +/// reconnects via GET after the stream ends. Each GET was unconditionally +/// replacing `self.common.tx`, killing the other stream's receiver — causing +/// an infinite reconnect loop every `sse_retry` seconds. +/// +/// Fix: `resume_or_shadow_common()` checks if the primary common channel is +/// still active. If so, it creates a "shadow" stream (idle, keep-alive only) +/// instead of replacing the primary. +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; +use rmcp::{ + RoleServer, ServerHandler, + model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo, ToolsCapability}, + service::NotificationContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use serde_json::json; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +const ACCEPT_SSE: &str = "text/event-stream"; +const ACCEPT_BOTH: &str = "text/event-stream, application/json"; + +// ─── Test server ──────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct TestServer { + trigger: Arc, +} + +impl TestServer { + fn new(trigger: Arc) -> Self { + Self { trigger } + } +} + +impl ServerHandler for TestServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::LATEST, + capabilities: ServerCapabilities::builder() + .enable_tools_with(ToolsCapability { + list_changed: Some(true), + }) + .build(), + server_info: Implementation { + name: "test-server".to_string(), + version: "1.0.0".to_string(), + ..Default::default() + }, + instructions: None, + } + } + + async fn on_initialized(&self, context: NotificationContext) { + let peer = context.peer.clone(); + let trigger = self.trigger.clone(); + + tokio::spawn(async move { + trigger.notified().await; + let _ = peer.notify_tool_list_changed().await; + }); + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async fn start_test_server(ct: CancellationToken, trigger: Arc) -> String { + let server = TestServer::new(trigger); + let service = StreamableHttpService::new( + move || Ok(server.clone()), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig { + stateful_mode: true, + sse_keep_alive: Some(Duration::from_secs(15)), + sse_retry: Some(Duration::from_secs(3)), + cancellation_token: ct.child_token(), + }, + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}/mcp", addr.port()); + + let ct_clone = ct.clone(); + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { ct_clone.cancelled().await }) + .await + .unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + url +} + +/// POST initialize and return session ID. +async fn initialize_session(client: &reqwest::Client, url: &str) -> String { + let resp = client + .post(url) + .header("Accept", ACCEPT_BOTH) + .header("Content-Type", "application/json") + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + })) + .timeout(Duration::from_millis(500)) + .send() + .await + .expect("POST initialize"); + + assert!(resp.status().is_success(), "initialize should succeed"); + + resp.headers() + .get("Mcp-Session-Id") + .expect("session ID header") + .to_str() + .unwrap() + .to_string() +} + +/// POST `notifications/initialized` to complete the MCP handshake. +/// This triggers the server's `on_initialized` handler. +async fn send_initialized_notification(client: &reqwest::Client, url: &str, session_id: &str) { + let resp = client + .post(url) + .header("Accept", ACCEPT_BOTH) + .header("Content-Type", "application/json") + .header("Mcp-Session-Id", session_id) + .json(&json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + })) + .send() + .await + .expect("POST notifications/initialized"); + + assert_eq!( + resp.status().as_u16(), + 202, + "notifications/initialized should return 202 Accepted" + ); +} + +/// Open a standalone GET SSE stream (no Last-Event-ID). +async fn open_standalone_get( + client: &reqwest::Client, + url: &str, + session_id: &str, +) -> reqwest::Response { + client + .get(url) + .header("Accept", ACCEPT_SSE) + .header("Mcp-Session-Id", session_id) + .send() + .await + .expect("GET SSE stream") +} + +/// Open a GET SSE stream with Last-Event-ID (resume). +async fn open_resume_get( + client: &reqwest::Client, + url: &str, + session_id: &str, + last_event_id: &str, +) -> reqwest::Response { + client + .get(url) + .header("Accept", ACCEPT_SSE) + .header("Mcp-Session-Id", session_id) + .header("Last-Event-ID", last_event_id) + .send() + .await + .expect("GET SSE stream with Last-Event-ID") +} + +/// Read from an SSE byte stream until we find a specific text or timeout. +async fn wait_for_sse_event(resp: reqwest::Response, needle: &str, timeout: Duration) -> bool { + let mut stream = resp.bytes_stream(); + let result = tokio::time::timeout(timeout, async { + while let Some(Ok(chunk)) = stream.next().await { + let text = String::from_utf8_lossy(&chunk); + if text.contains(needle) { + return true; + } + } + false + }) + .await; + + matches!(result, Ok(true)) +} + +// ─── Tests: Shadow stream creation ────────────────────────────────────────── + +/// Second standalone GET with same session ID should return 200 OK +/// (shadow stream), NOT 409 Conflict. +#[tokio::test] +async fn shadow_second_standalone_get_returns_200() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // First GET — becomes primary common channel + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200, "First GET should succeed"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — should get 200 (shadow), NOT 409 + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!( + get2.status(), + 200, + "Second GET should return 200 (shadow stream), not 409 Conflict" + ); + + ct.cancel(); +} + +/// Multiple standalone GETs should all return 200 — the server can handle +/// many shadow streams concurrently. +#[tokio::test] +async fn shadow_multiple_standalone_gets_all_succeed() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // Open 5 concurrent standalone GETs + let mut responses = Vec::new(); + for i in 0..5 { + let resp = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(resp.status(), 200, "GET #{i} should succeed"); + responses.push(resp); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // All 5 should be alive (first is primary, rest are shadows) + assert_eq!(responses.len(), 5); + + ct.cancel(); +} + +// ─── Tests: Dead primary replacement ──────────────────────────────────────── + +/// When the primary common channel is dead (first GET dropped), the next GET +/// should replace it and become the new primary. +#[tokio::test] +async fn dead_primary_gets_replaced_by_next_get() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // First GET — becomes primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + + // Drop primary — kills receiver, making sender closed + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — primary is dead, should replace it + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!( + get2.status(), + 200, + "GET should succeed as new primary after old primary was dropped" + ); + + ct.cancel(); +} + +/// After primary dies, the replacement primary should be able to receive +/// notifications (verifies the channel was actually replaced, not shadowed). +#[tokio::test] +async fn dead_primary_replacement_receives_notifications() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(100)).await; + + // First GET — becomes primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + + // Drop primary + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — becomes new primary (replacement) + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get2.status(), 200); + + // Trigger notification — should arrive on get2 (the new primary) + trigger.notify_one(); + + assert!( + wait_for_sse_event(get2, "tools/list_changed", Duration::from_secs(3)).await, + "Replacement primary should receive notifications" + ); + + ct.cancel(); +} + +/// Multiple drops and replacements should work: primary can be replaced +/// more than once. +#[tokio::test] +async fn dead_primary_can_be_replaced_multiple_times() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + for i in 0..3 { + let get = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get.status(), 200, "GET #{i} should succeed"); + drop(get); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Final GET should still work + let final_get = open_standalone_get(&client, &url, &session_id).await; + assert_eq!( + final_get.status(), + 200, + "GET after multiple replacements should succeed" + ); + + ct.cancel(); +} + +// ─── Tests: Notification routing ──────────────────────────────────────────── + +/// Notification should arrive on the primary stream even after shadow streams +/// are created by subsequent GETs. +#[tokio::test] +async fn notification_reaches_primary_not_shadow() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — primary common channel + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — shadow stream (should NOT steal notifications) + let _get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_get2.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Trigger notification + trigger.notify_one(); + + // Primary stream should receive the notification + assert!( + wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, + "Primary stream should receive notification even after shadow was created" + ); + + ct.cancel(); +} + +// ─── Tests: Resume with Last-Event-ID ─────────────────────────────────────── + +/// GET with Last-Event-ID referencing a completed request-wise channel should +/// fall through to shadow (not crash or return 500). +/// +/// This simulates the real-world scenario: POST SSE response ends, the +/// EventSource reconnects via GET with the last event ID from the POST stream. +/// The request-wise channel no longer exists, so the server should create a +/// shadow stream. +#[tokio::test] +async fn resume_completed_request_wise_creates_shadow() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // First GET — establish primary + let _get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // GET with Last-Event-ID for non-existent request-wise channel + let get_resume = open_resume_get(&client, &url, &session_id, "0/999").await; + assert_eq!( + get_resume.status(), + 200, + "Resume of completed request-wise channel should return 200 (shadow)" + ); + + ct.cancel(); +} + +/// GET with Last-Event-ID "0" (common channel resume) while primary is alive +/// should create a shadow. +#[tokio::test] +async fn resume_common_while_primary_alive_creates_shadow() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // First GET — establish primary + let _get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // GET with Last-Event-ID "0" — resume common while primary alive → shadow + let get_resume = open_resume_get(&client, &url, &session_id, "0").await; + assert_eq!( + get_resume.status(), + 200, + "Common channel resume while primary alive should return 200 (shadow)" + ); + + ct.cancel(); +} + +/// GET with Last-Event-ID "0" (common channel resume) while primary is dead +/// should become the new primary. +#[tokio::test] +async fn resume_common_while_primary_dead_becomes_primary() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — establish primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + + // Drop primary + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // GET with Last-Event-ID "0" — primary dead → becomes new primary + let get_resume = open_resume_get(&client, &url, &session_id, "0").await; + assert_eq!(get_resume.status(), 200); + + // New primary should receive notifications + trigger.notify_one(); + + assert!( + wait_for_sse_event(get_resume, "tools/list_changed", Duration::from_secs(3)).await, + "Resumed stream that replaced dead primary should receive notifications" + ); + + ct.cancel(); +} + +// ─── Tests: Mixed scenarios ───────────────────────────────────────────────── + +/// POST SSE reconnections and standalone GET should coexist: POST initialize +/// creates a request-wise channel, its EventSource reconnects via GET after +/// the stream ends, while a standalone GET is also active. +#[tokio::test] +async fn post_reconnect_and_standalone_coexist() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + + // Standalone GET — becomes primary + let _standalone = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_standalone.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Simulate POST SSE response reconnection (EventSource reconnects with + // Last-Event-ID from the initialize POST stream). The request-wise channel + // for the initialize request is already completed. + let reconnect1 = open_resume_get(&client, &url, &session_id, "0/0").await; + assert_eq!( + reconnect1.status(), + 200, + "POST reconnection should get shadow, not replace primary" + ); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // Another POST reconnection (e.g. from tools/list response) + let reconnect2 = open_resume_get(&client, &url, &session_id, "0/1").await; + assert_eq!( + reconnect2.status(), + 200, + "Second POST reconnection should also succeed" + ); + + ct.cancel(); +} + +/// Standalone GET is dropped (e.g. client timeout), a new standalone GET +/// connects. The new one should become the primary and receive notifications. +#[tokio::test] +async fn reconnect_after_stream_timeout() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First standalone GET — primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + + // Client drops the stream (e.g. timeout or reconnection) + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Client reconnects with a new standalone GET + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get2.status(), 200); + + // Notification should reach the new primary + trigger.notify_one(); + + assert!( + wait_for_sse_event(get2, "tools/list_changed", Duration::from_secs(3)).await, + "Reconnected stream should receive notifications" + ); + + ct.cancel(); +} + +// ─── Tests: Edge cases ────────────────────────────────────────────────────── + +/// GET with an unknown session ID should return 404 Not Found per MCP spec. +/// This signals the client to re-initialize (not re-authenticate). +#[tokio::test] +async fn get_without_valid_session_returns_404() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let resp = client + .get(&url) + .header("Accept", ACCEPT_SSE) + .header("Mcp-Session-Id", "nonexistent-session-id") + .send() + .await + .expect("GET with invalid session"); + + assert_eq!( + resp.status().as_u16(), + 404, + "GET with unknown session ID should return 404 Not Found per MCP spec" + ); + + ct.cancel(); +} + +/// GET without session ID header should return 400 Bad Request per MCP spec. +#[tokio::test] +async fn get_without_session_id_header_returns_400() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger).await; + let client = reqwest::Client::new(); + + let resp = client + .get(&url) + .header("Accept", ACCEPT_SSE) + .send() + .await + .expect("GET without session ID"); + + assert_eq!( + resp.status().as_u16(), + 400, + "GET without session ID should return 400 Bad Request per MCP spec" + ); + + ct.cancel(); +} + +/// Shadow streams should be idle — they should NOT receive notifications. +/// Only the primary receives them. +#[tokio::test] +async fn shadow_stream_does_not_receive_notifications() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — primary + let _get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(_get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Second GET — shadow + let get2 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get2.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Trigger notification + trigger.notify_one(); + + // Shadow stream should NOT receive the notification (timeout expected) + let shadow_received = + wait_for_sse_event(get2, "tools/list_changed", Duration::from_millis(500)).await; + assert!( + !shadow_received, + "Shadow stream should NOT receive notifications" + ); + + ct.cancel(); +} + +/// Dropping all shadow streams should not affect the primary channel. +/// Primary should still receive notifications after all shadows are dropped. +#[tokio::test] +async fn dropping_shadows_does_not_affect_primary() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // Primary GET + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Create and drop several shadows + for _ in 0..3 { + let shadow = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(shadow.status(), 200); + drop(shadow); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Trigger notification — primary should still receive it + trigger.notify_one(); + + assert!( + wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, + "Primary should still work after all shadows are dropped" + ); + + ct.cancel(); +} + +// ─── Tests: Cache replay on dead primary replacement ───────────────────────── + +/// When a notification is sent while the primary is alive, then the primary +/// dies and a new GET resumes with Last-Event-ID "0", the replacement primary +/// should receive the cached notification via sync() replay. +#[tokio::test] +async fn dead_primary_replacement_replays_cached_events() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — becomes primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Trigger notification while primary is alive (gets cached) + trigger.notify_one(); + tokio::time::sleep(Duration::from_millis(200)).await; + + // Drop primary — notification was sent and cached + drop(get1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Resume with Last-Event-ID "0" — primary is dead, should replace it + // and replay cached events from index 0 + let get_resume = open_resume_get(&client, &url, &session_id, "0").await; + assert_eq!(get_resume.status(), 200); + + // The cached notification should be replayed on the new primary + assert!( + wait_for_sse_event(get_resume, "tools/list_changed", Duration::from_secs(3)).await, + "Replacement primary should receive cached notification via sync() replay" + ); + + ct.cancel(); +} + +// ─── Tests: Shadow stream limits ───────────────────────────────────────────── + +/// Opening more than 32 shadow streams should not crash or reject — the server +/// drops the oldest shadow to stay within the limit. Primary still works. +#[tokio::test] +async fn shadow_stream_limit_drops_oldest() { + let ct = CancellationToken::new(); + let trigger = Arc::new(Notify::new()); + let url = start_test_server(ct.clone(), trigger.clone()).await; + let client = reqwest::Client::new(); + + let session_id = initialize_session(&client, &url).await; + send_initialized_notification(&client, &url, &session_id).await; + tokio::time::sleep(Duration::from_millis(200)).await; + + // First GET — primary + let get1 = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(get1.status(), 200); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Open 35 shadow streams (exceeds MAX_SHADOW_STREAMS=32) + let mut shadows = Vec::new(); + for i in 0..35 { + let shadow = open_standalone_get(&client, &url, &session_id).await; + assert_eq!(shadow.status(), 200, "Shadow #{i} should succeed"); + shadows.push(shadow); + } + + // Primary should still receive notifications despite shadow churn + trigger.notify_one(); + + assert!( + wait_for_sse_event(get1, "tools/list_changed", Duration::from_secs(3)).await, + "Primary should still work after exceeding shadow limit" + ); + + ct.cancel(); +} From 6c336a90c17ed0095d51412f33d95ad48280bd5f Mon Sep 17 00:00:00 2001 From: EvianZhang Date: Thu, 26 Feb 2026 01:23:37 +0800 Subject: [PATCH 058/333] feat: add trait-based tool declaration (#677) * feat: add trait-based tool declaration * fix: typo * fix: add docs, make more idomatic patterns, allow for empty parameters and return types * fix: format code * fix: add default trait * fix: docs typo --- crates/rmcp-macros/src/tool.rs | 5 +- crates/rmcp/README.md | 2 +- crates/rmcp/src/handler/server/common.rs | 14 + crates/rmcp/src/handler/server/router/tool.rs | 161 +++++++++ .../handler/server/router/tool/tool_traits.rs | 341 ++++++++++++++++++ 5 files changed, 518 insertions(+), 5 deletions(-) create mode 100644 crates/rmcp/src/handler/server/router/tool/tool_traits.rs diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index bec3ddd11..68ab27c2e 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -238,10 +238,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { // if not found, use a default empty JSON schema object // TODO: should be updated according to the new specifications syn::parse2::(quote! { - std::sync::Arc::new(serde_json::json!({ - "type": "object", - "properties": {} - }).as_object().unwrap().clone()) + rmcp::handler::server::common::schema_for_empty_input() })? } }; diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index 54b27b910..60f5e02d4 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -130,7 +130,7 @@ async fn calculate(&self, params: Parameters) -> Result() -> Arc { }) } +// TODO: should be updated according to the new specifications +/// Schema used when input is empty. +pub fn schema_for_empty_input() -> Arc { + std::sync::Arc::new( + serde_json::json!({ + "type": "object", + "properties": {} + }) + .as_object() + .unwrap() + .clone(), + ) +} + /// Generate and validate a JSON schema for outputSchema (must have root type "object"). pub fn schema_for_output() -> Result, String> { thread_local! { diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 51d49d971..5c1941bd0 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -1,7 +1,132 @@ +//! Tools for MCP servers. +//! +//! It's straightforward to define tools using [`tool_router`][crate::tool_router] and +//! [`tool`][crate::tool] macro. +//! +//! ```rust +//! # use rmcp::{ +//! # tool_router, tool, +//! # handler::server::{wrapper::{Parameters, Json}, tool::ToolRouter}, +//! # schemars +//! # }; +//! # use serde::{Serialize, Deserialize}; +//! struct Server { +//! tool_router: ToolRouter, +//! } +//! #[derive(Deserialize, schemars::JsonSchema, Default)] +//! struct AddParameter { +//! left: usize, +//! right: usize +//! } +//! #[derive(Serialize, schemars::JsonSchema)] +//! struct AddOutput { +//! sum: usize +//! } +//! #[tool_router] +//! impl Server { +//! #[tool(name = "adder", description = "Modular add two integers")] +//! fn add( +//! &self, +//! Parameters(AddParameter { left, right }): Parameters +//! ) -> Json { +//! Json(AddOutput { sum: left.wrapping_add(right) }) +//! } +//! } +//! ``` +//! +//! Using the macro-based code pattern above is suitable for small MCP servers with simple interfaces. +//! When the business logic become larger, it is recommended that each tool should reside +//! in individual file, combined into MCP server using [`SyncTool`] and [`AsyncTool`] traits. +//! +//! ```rust +//! # use rmcp::{ +//! # handler::server::{ +//! # tool::ToolRouter, +//! # router::tool::{SyncTool, AsyncTool, ToolBase}, +//! # }, +//! # schemars, ErrorData +//! # }; +//! # pub struct MyCustomError; +//! # impl From for ErrorData { +//! # fn from(err: MyCustomError) -> ErrorData { unimplemented!() } +//! # } +//! # use serde::{Serialize, Deserialize}; +//! # use std::borrow::Cow; +//! // In tool1.rs +//! pub struct ComplexTool1; +//! #[derive(Deserialize, schemars::JsonSchema, Default)] +//! pub struct ComplexTool1Input { /* ... */ } +//! #[derive(Serialize, schemars::JsonSchema)] +//! pub struct ComplexTool1Output { /* ... */ } +//! +//! impl ToolBase for ComplexTool1 { +//! type Parameter = ComplexTool1Input; +//! type Output = ComplexTool1Output; +//! type Error = MyCustomError; +//! fn name() -> Cow<'static, str> { +//! "complex-tool1".into() +//! } +//! +//! fn description() -> Option> { +//! Some("...".into()) +//! } +//! } +//! impl SyncTool for ComplexTool1 { +//! fn invoke(service: &MyToolServer, param: Self::Parameter) -> Result { +//! // ... +//! # unimplemented!() +//! } +//! } +//! // In tool2.rs +//! pub struct ComplexTool2; +//! #[derive(Deserialize, schemars::JsonSchema, Default)] +//! pub struct ComplexTool2Input { /* ... */ } +//! #[derive(Serialize, schemars::JsonSchema)] +//! pub struct ComplexTool2Output { /* ... */ } +//! +//! impl ToolBase for ComplexTool2 { +//! type Parameter = ComplexTool2Input; +//! type Output = ComplexTool2Output; +//! type Error = MyCustomError; +//! fn name() -> Cow<'static, str> { +//! "complex-tool2".into() +//! } +//! +//! fn description() -> Option> { +//! Some("...".into()) +//! } +//! } +//! impl AsyncTool for ComplexTool2 { +//! async fn invoke(service: &MyToolServer, param: Self::Parameter) -> Result { +//! // ... +//! # unimplemented!() +//! } +//! } +//! +//! // In tool_router.rs +//! struct MyToolServer { +//! tool_router: ToolRouter, +//! } +//! impl MyToolServer { +//! pub fn tool_router() -> ToolRouter { +//! ToolRouter::new() +//! .with_sync_tool::() +//! .with_async_tool::() +//! } +//! } +//! ``` +//! +//! It's also possible to use macro-based and trait-based tool definition together: Since +//! [`ToolRouter`] implements [`Add`][std::ops::Add], you can add two tool routers into final +//! router as showed in [the documentation of `tool_router`][crate::tool_router]. + +mod tool_traits; + use std::{borrow::Cow, sync::Arc}; use futures::{FutureExt, future::BoxFuture}; use schemars::JsonSchema; +pub use tool_traits::{AsyncTool, SyncTool, ToolBase}; use crate::{ handler::server::{ @@ -219,6 +344,42 @@ where self } + /// Add a tool that implements [`SyncTool`] + pub fn with_sync_tool(self) -> Self + where + T: SyncTool + 'static, + { + if T::input_schema().is_some() { + self.with_route(( + tool_traits::tool_attribute::(), + tool_traits::sync_tool_wrapper::, + )) + } else { + self.with_route(( + tool_traits::tool_attribute::(), + tool_traits::sync_tool_wrapper_with_empty_params::, + )) + } + } + + /// Add a tool that implements [`AsyncTool`] + pub fn with_async_tool(self) -> Self + where + T: AsyncTool + 'static, + { + if T::input_schema().is_some() { + self.with_route(( + tool_traits::tool_attribute::(), + tool_traits::async_tool_wrapper::, + )) + } else { + self.with_route(( + tool_traits::tool_attribute::(), + tool_traits::async_tool_wrapper_with_empty_params::, + )) + } + } + pub fn add_route(&mut self, item: ToolRoute) { let new_name = &item.attr.name; validate_and_warn_tool_name(new_name); diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs new file mode 100644 index 000000000..60ac9cff0 --- /dev/null +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -0,0 +1,341 @@ +use std::{borrow::Cow, pin::Pin, sync::Arc}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + ErrorData, + handler::server::{ + common::schema_for_empty_input, + tool::{schema_for_output, schema_for_type}, + wrapper::{Json, Parameters}, + }, + model::{Icon, JsonObject, Meta, ToolAnnotations, ToolExecution}, + schemars::JsonSchema, +}; + +/// Base trait to define attributes of a tool. +/// +/// Tools implementing [`SyncTool`] or [`AsyncTool`] must implement this trait first. +/// +/// All methods are consistent with fields of [`Tool`][crate::model::Tool]. +pub trait ToolBase { + /// Parameter type, will used in the invoke parameter of [`SyncTool`] or [`AsyncTool`] trait + /// + /// If the tool does not have any parameters, you **MUST** override [`input_schema`][Self::input_schema] + /// method. See its documentation for more details. + type Parameter: for<'de> Deserialize<'de> + JsonSchema + Send + Default + 'static; + /// Output type, will used in the invoke output of [`SyncTool`] or [`AsyncTool`] trait + /// + /// If the tool does not have any output, you **MUST** override [`output_schema`][Self::output_schema] + /// method. See its documentation for more details. + type Output: Serialize + JsonSchema + Send + 'static; + /// Error type, will used in the invoke output of [`SyncTool`] or [`AsyncTool`] trait + type Error: Into + Send + 'static; + + fn name() -> Cow<'static, str>; + + fn title() -> Option { + None + } + fn description() -> Option> { + None + } + + /// Json schema for tool input. + /// + /// The default implementation generates schema based on [`Self::Parameter`] type. + /// + /// If the tool does not have any parameters, you should override this methods to return [`None`], + /// and when invoked, the parameter will get default values. + fn input_schema() -> Option> { + Some(schema_for_type::>()) + } + + /// Json schema for tool output. + /// + /// The default implementation generates schema based on [`Self::Output`] type. + /// + /// If the tool does not have any output, you should override this methods to return [`None`]. + fn output_schema() -> Option> { + Some(schema_for_output::().unwrap_or_else(|e| { + panic!( + "Invalid output schema for ToolBase::Output type `{0}`: {1}", + std::any::type_name::(), + e, + ); + })) + } + + fn annotations() -> Option { + None + } + fn execution() -> Option { + None + } + fn icons() -> Option> { + None + } + fn meta() -> Option { + None + } +} + +/// Synchronous version of a tool. +/// +/// Consider using [`AsyncTool`] if your workflow involves asynchronous operations. +/// Examples are shown in [the module-level documentation][crate::handler::server::router::tool]. +pub trait SyncTool: ToolBase { + fn invoke(service: &S, param: Self::Parameter) -> Result; +} + +/// Asynchronous version of a tool. +/// +/// Consider using [`SyncTool`] if your workflow does not involve asynchronous operations. +/// Examples are shown in [the module-level documentation][crate::handler::server::router::tool]. +pub trait AsyncTool: ToolBase { + fn invoke( + service: &S, + param: Self::Parameter, + ) -> impl Future> + Send; +} + +pub(crate) fn tool_attribute() -> crate::model::Tool { + crate::model::Tool { + name: T::name(), + title: T::title(), + description: T::description(), + input_schema: T::input_schema().unwrap_or_else(schema_for_empty_input), + output_schema: T::output_schema(), + annotations: T::annotations(), + execution: T::execution(), + icons: T::icons(), + meta: T::meta(), + } +} + +pub(crate) fn sync_tool_wrapper>( + service: &S, + Parameters(params): Parameters, +) -> Result, ErrorData> { + T::invoke(service, params).map(Json).map_err(Into::into) +} + +pub(crate) fn sync_tool_wrapper_with_empty_params>( + service: &S, +) -> Result, ErrorData> { + T::invoke(service, T::Parameter::default()) + .map(Json) + .map_err(Into::into) +} + +#[expect(clippy::type_complexity)] +pub(crate) fn async_tool_wrapper>( + service: &S, + Parameters(params): Parameters, +) -> Pin, ErrorData>> + Send + '_>> { + Box::pin(async move { + T::invoke(service, params) + .await + .map(Json) + .map_err(Into::into) + }) +} + +#[expect(clippy::type_complexity)] +pub(crate) fn async_tool_wrapper_with_empty_params>( + service: &S, +) -> Pin, ErrorData>> + Send + '_>> { + Box::pin(async move { + T::invoke(service, T::Parameter::default()) + .await + .map(Json) + .map_err(Into::into) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate as rmcp; + use crate::tool; // workaround for macros + + #[derive(Deserialize, schemars::JsonSchema, Default)] + struct AddParameter { + left: usize, + right: usize, + } + #[derive(Serialize, schemars::JsonSchema, PartialEq, Debug)] + struct AddOutput { + sum: usize, + } + + struct MacroBasedToolServer; + + impl MacroBasedToolServer { + #[expect(unused)] + #[tool(name = "adder", description = "Modular add two integers")] + fn add( + &self, + Parameters(AddParameter { left, right }): Parameters, + ) -> Json { + Json(AddOutput { + sum: left.wrapping_add(right), + }) + } + + #[expect(unused)] + #[tool(name = "empty", description = "Empty tool")] + fn empty(&self) {} + } + + struct AddTool; + impl ToolBase for AddTool { + type Parameter = AddParameter; + type Output = AddOutput; + type Error = ErrorData; + + fn name() -> Cow<'static, str> { + "adder".into() + } + + fn description() -> Option> { + Some("Modular add two integers".into()) + } + } + impl SyncTool for AddTool { + fn invoke( + _service: &TraitBasedToolServer, + AddParameter { left, right }: Self::Parameter, + ) -> Result { + Ok(AddOutput { + sum: left.wrapping_add(right), + }) + } + } + impl AsyncTool for AddTool { + async fn invoke( + _service: &TraitBasedToolServer, + AddParameter { left, right }: Self::Parameter, + ) -> Result { + Ok(AddOutput { + sum: left.wrapping_add(right), + }) + } + } + + enum EmptyToolCustomError { + Internal, + InvalidParams, + } + impl From for ErrorData { + fn from(value: EmptyToolCustomError) -> Self { + match value { + EmptyToolCustomError::Internal => Self::internal_error("internal error", None), + EmptyToolCustomError::InvalidParams => Self::invalid_params("invalid params", None), + } + } + } + + struct EmptyTool; + impl ToolBase for EmptyTool { + type Parameter = (); + type Output = (); + type Error = EmptyToolCustomError; + + fn name() -> Cow<'static, str> { + "empty".into() + } + + fn description() -> Option> { + Some("Empty tool".into()) + } + + fn input_schema() -> Option> { + None + } + + fn output_schema() -> Option> { + None + } + } + impl SyncTool for EmptyTool { + fn invoke( + _service: &TraitBasedToolServer, + _param: Self::Parameter, + ) -> Result { + Err(EmptyToolCustomError::Internal) + } + } + impl AsyncTool for EmptyTool { + async fn invoke( + _service: &TraitBasedToolServer, + _param: Self::Parameter, + ) -> Result { + Err(EmptyToolCustomError::InvalidParams) + } + } + + struct TraitBasedToolServer; + + #[test] + fn test_macro_and_trait_have_same_attrs() { + let macro_attrs = MacroBasedToolServer::add_tool_attr(); + let trait_attrs = tool_attribute::(); + assert_eq!(macro_attrs, trait_attrs); + } + + #[test] + fn test_macro_and_trait_have_same_attrs_for_empty_tool() { + let macro_attrs = MacroBasedToolServer::empty_tool_attr(); + let trait_attrs = tool_attribute::(); + assert_eq!(macro_attrs, trait_attrs); + } + + #[test] + fn test_sync_tool_wrapper_happy_path() { + let left = 1; + let right = 2; + let result = sync_tool_wrapper::<_, AddTool>( + &TraitBasedToolServer, + Parameters(AddParameter { left, right }), + ); + assert!(result.is_ok()); + if let Ok(result) = result { + assert_eq!(result.0, AddOutput { sum: 3 }); + } + } + + #[tokio::test] + async fn test_async_tool_wrapper_happy_path() { + let left = 1; + let right = 2; + let result = async_tool_wrapper::<_, AddTool>( + &TraitBasedToolServer, + Parameters(AddParameter { left, right }), + ) + .await; + assert!(result.is_ok()); + if let Ok(result) = result { + assert_eq!(result.0, AddOutput { sum: 3 }); + } + } + + #[test] + fn test_sync_tool_wrapper_error_conversion() { + let result = sync_tool_wrapper::<_, EmptyTool>(&TraitBasedToolServer, Parameters(())); + assert!(result.is_err()); + if let Err(result) = result { + assert_eq!(result, ErrorData::internal_error("internal error", None)); + } + } + + #[tokio::test] + async fn test_async_tool_wrapper_error_conversion() { + let result = + async_tool_wrapper::<_, EmptyTool>(&TraitBasedToolServer, Parameters(())).await; + assert!(result.is_err()); + if let Err(result) = result { + assert_eq!(result, ErrorData::invalid_params("invalid params", None)); + } + } +} From 93bfb4ac6bc99375bb206c45054f22032dbc1652 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:02:11 -0500 Subject: [PATCH 059/333] feat: add default value support to string, number, and integer schemas (#686) --- crates/rmcp/src/model/elicitation_schema.rs | 33 +++++++++++++++++++ .../server_json_rpc_message_schema.json | 23 +++++++++++++ ...erver_json_rpc_message_schema_current.json | 23 +++++++++++++ 3 files changed, 79 insertions(+) diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index a02e07f4c..5e7506e49 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -113,6 +113,10 @@ pub struct StringSchema { /// String format - limited to: "email", "uri", "date", "date-time" #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, } impl Default for StringSchema { @@ -124,6 +128,7 @@ impl Default for StringSchema { min_length: None, max_length: None, format: None, + default: None, } } } @@ -213,6 +218,12 @@ impl StringSchema { self.format = Some(format); self } + + /// Set default value + pub fn with_default(mut self, default: impl Into) -> Self { + self.default = Some(default.into()); + self + } } // ============================================================================= @@ -246,6 +257,10 @@ pub struct NumberSchema { /// Maximum value (inclusive) #[serde(skip_serializing_if = "Option::is_none")] pub maximum: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, } impl Default for NumberSchema { @@ -256,6 +271,7 @@ impl Default for NumberSchema { description: None, minimum: None, maximum: None, + default: None, } } } @@ -307,6 +323,12 @@ impl NumberSchema { self.description = Some(description.into()); self } + + /// Set default value + pub fn with_default(mut self, default: f64) -> Self { + self.default = Some(default); + self + } } // ============================================================================= @@ -340,6 +362,10 @@ pub struct IntegerSchema { /// Maximum value (inclusive) #[serde(skip_serializing_if = "Option::is_none")] pub maximum: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, } impl Default for IntegerSchema { @@ -350,6 +376,7 @@ impl Default for IntegerSchema { description: None, minimum: None, maximum: None, + default: None, } } } @@ -401,6 +428,12 @@ impl IntegerSchema { self.description = Some(description.into()); self } + + /// Set default value + pub fn with_default(mut self, default: i64) -> Self { + self.default = Some(default); + self + } } // ============================================================================= diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 2bb71dfff..4fb0febf0 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -1186,6 +1186,14 @@ "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", "type": "object", "properties": { + "default": { + "description": "Default value", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, "description": { "description": "Human-readable description", "type": [ @@ -1763,6 +1771,14 @@ "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", "type": "object", "properties": { + "default": { + "description": "Default value", + "type": [ + "number", + "null" + ], + "format": "double" + }, "description": { "description": "Human-readable description", "type": [ @@ -2869,6 +2885,13 @@ "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", "type": "object", "properties": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, "description": { "description": "Human-readable description", "type": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 2bb71dfff..4fb0febf0 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -1186,6 +1186,14 @@ "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", "type": "object", "properties": { + "default": { + "description": "Default value", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, "description": { "description": "Human-readable description", "type": [ @@ -1763,6 +1771,14 @@ "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", "type": "object", "properties": { + "default": { + "description": "Default value", + "type": [ + "number", + "null" + ], + "format": "double" + }, "description": { "description": "Human-readable description", "type": [ @@ -2869,6 +2885,13 @@ "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", "type": "object", "properties": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, "description": { "description": "Human-readable description", "type": [ From b967c132aef477afb1c0792772bb2e428ef98e32 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:05:43 -0500 Subject: [PATCH 060/333] fix: improve error logging and remove token secret from logs (#685) --- crates/rmcp/src/transport/auth.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 1ee1ceeb4..b8d4f3f4a 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -992,6 +992,9 @@ impl AuthorizationManager { } } + // When expiry info is unavailable (e.g., credentials stored before + // token_received_at was tracked), skip the expiry check and return + // the token as-is. Ok(creds.access_token().secret().to_string()) } @@ -1005,8 +1008,8 @@ impl AuthorizationManager { tracing::info!("Refreshed access token."); Ok(new_creds.access_token().secret().to_string()) } - Err(AuthError::AuthorizationRequired | AuthError::TokenRefreshFailed(_)) => { - tracing::warn!("Token refresh not possible, re-authorization required."); + Err(e @ (AuthError::AuthorizationRequired | AuthError::TokenRefreshFailed(_))) => { + tracing::warn!(error = %e, "Token refresh not possible, re-authorization required."); Err(AuthError::AuthorizationRequired) } Err(e) => Err(e), @@ -1030,7 +1033,7 @@ impl AuthorizationManager { let refresh_token = current_credentials.refresh_token().ok_or_else(|| { AuthError::TokenRefreshFailed("No refresh token available".to_string()) })?; - debug!("refresh token: {:?}", refresh_token); + debug!("refresh token present, attempting refresh"); let token_result = oauth_client .exchange_refresh_token(&RefreshToken::new(refresh_token.secret().to_string())) @@ -2680,6 +2683,8 @@ mod tests { // -- get_access_token -- + use super::{OAuthTokenResponse, StoredCredentials}; + fn make_token_response(access_token: &str, expires_in_secs: Option) -> OAuthTokenResponse { use oauth2::{AccessToken, EmptyExtraTokenFields, basic::BasicTokenType}; let mut resp = OAuthTokenResponse::new( @@ -2693,8 +2698,6 @@ mod tests { resp } - use super::{OAuthTokenResponse, StoredCredentials}; - #[tokio::test] async fn get_access_token_returns_error_when_no_credentials() { let manager = AuthorizationManager::new("http://localhost").await.unwrap(); From a7e4ae32038e5c83930b18971acc7dee2c8c926b Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 26 Feb 2026 13:33:18 -0500 Subject: [PATCH 061/333] feat: mcp sdk conformance (#687) * adds conformance server and client * adds results from initial run of https://github.com/modelcontextprotocol/conformance/tree/main/.claude/skills/mcp-sdk-tier-audit skill * various small changes applied during the testing loop Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- .github/codeql/codeql-config.yml | 4 + .github/workflows/codeql.yml | 36 + Cargo.toml | 2 +- conformance/Cargo.toml | 35 + .../results/2026-02-25-rust-sdk-assessment.md | 292 ++++++ .../2026-02-25-rust-sdk-remediation.md | 43 + conformance/src/bin/client.rs | 987 ++++++++++++++++++ conformance/src/bin/server.rs | 959 +++++++++++++++++ crates/rmcp/src/model.rs | 3 +- .../src/transport/common/client_side_sse.rs | 19 +- .../common/reqwest/streamable_http_client.rs | 14 +- .../src/transport/streamable_http_client.rs | 8 +- 12 files changed, 2392 insertions(+), 10 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 conformance/Cargo.toml create mode 100644 conformance/results/2026-02-25-rust-sdk-assessment.md create mode 100644 conformance/results/2026-02-25-rust-sdk-remediation.md create mode 100644 conformance/src/bin/client.rs create mode 100644 conformance/src/bin/server.rs diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..b2abf50cd --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,4 @@ +name: "CodeQL config" + +paths-ignore: + - conformance diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..1cb1d2de2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,36 @@ +name: "CodeQL" + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * 1' # Weekly on Monday + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + strategy: + fail-fast: false + matrix: + language: [rust, javascript-typescript, python, actions] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/Cargo.toml b/Cargo.toml index ddbff9580..551ac7bed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/rmcp", "crates/rmcp-macros", "examples/*"] +members = ["crates/rmcp", "crates/rmcp-macros", "examples/*", "conformance"] default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml new file mode 100644 index 000000000..a38e9e624 --- /dev/null +++ b/conformance/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "mcp-conformance" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "conformance-server" +path = "src/bin/server.rs" + +[[bin]] +name = "conformance-client" +path = "src/bin/client.rs" + +[dependencies] +rmcp = { path = "../crates/rmcp", features = [ + "server", + "client", + "elicitation", + "auth", + "transport-streamable-http-server", + "transport-streamable-http-client-reqwest", +] } +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +axum = { version = "0.8", features = ["macros"] } +anyhow = "1" +reqwest = { version = "0.13", features = ["json"] } +urlencoding = "2" +url = "2" +p256 = { version = "0.13", features = ["ecdsa"] } +base64 = "0.22" diff --git a/conformance/results/2026-02-25-rust-sdk-assessment.md b/conformance/results/2026-02-25-rust-sdk-assessment.md new file mode 100644 index 000000000..d9175c037 --- /dev/null +++ b/conformance/results/2026-02-25-rust-sdk-assessment.md @@ -0,0 +1,292 @@ +# MCP SDK Tier Audit: modelcontextprotocol/rust-sdk + +**Date**: 2026-02-25 +**Branch**: alexhancock/conformance +**Auditor**: mcp-sdk-tier-audit skill (automated + subagent evaluation) + +## Tier Assessment: Tier 3 + +The Rust SDK (rmcp) is currently at Tier 3. While server and client conformance pass rates exceed the 80% Tier 2 threshold, several critical Tier 2 requirements are not met: issue triage compliance is very low (14.1%), required labels are largely missing (3/12), no stable release ≥1.0.0 exists, and no roadmap is published. + +### Requirements Summary + +| # | Requirement | Tier 1 Standard | Tier 2 Standard | Current Value | T1? | T2? | Gap | +|---|-------------|----------------|-----------------|---------------|-----|-----|-----| +| 1a | Server Conformance | 100% pass rate | >= 80% pass rate | 83.3% (25/30) | FAIL | PASS | 5 failing scenarios (prompts-get-with-args, prompts-get-embedded-resource, elicitation-sep1330-enums, elicitation-sep1034-defaults, dns-rebinding-protection) | +| 1b | Client Conformance | 100% pass rate | >= 80% pass rate | 85.0% (17/20) | FAIL | PASS | 3 failing date-versioned scenarios (scope-step-up, metadata-var3, 2025-03-26-oauth-endpoint-fallback) | +| 2 | Issue Triage | >= 90% within 2 biz days | >= 80% within 1 month | 14.1% (9/64) | FAIL | FAIL | 54 issues exceeding SLA; median 4341h | +| 2b | Labels | 12 required labels | 12 required labels | 3/12 | FAIL | FAIL | Missing: bug, enhancement, needs confirmation, needs repro, ready for work, P0, P1, P2, P3 | +| 3 | Critical Bug Resolution | All P0s within 7 days | All P0s within 2 weeks | 0 open | PASS | PASS | None | +| 4 | Stable Release | Required + clear versioning | At least one stable release | rmcp-v0.16.0 | FAIL | FAIL | No release >= 1.0.0 | +| 4b | Spec Tracking | Timeline agreed per release | Within 6 months | 6d gap (PASS) | PASS | PASS | None | +| 5 | Documentation | Comprehensive w/ examples | Basic docs for core features | ~8/48 features | FAIL | FAIL | Most features lack prose documentation | +| 6 | Dependency Policy | Published update policy | Published update policy | dependabot.yml configured | PASS | PASS | None | +| 7 | Roadmap | Published roadmap | Plan toward Tier 1 | Not found | FAIL | FAIL | No ROADMAP.md or docs/roadmap.md | +| 8 | Versioning Policy | Documented breaking change policy | N/A | Not found | FAIL | N/A | No VERSIONING.md or BREAKING_CHANGES.md | + +### Tier Determination + +- Tier 1: FAIL — 3/11 requirements met (failing: server_conformance, client_conformance, triage, labels, stable_release, documentation, roadmap, versioning) +- Tier 2: FAIL — 4/9 requirements met (failing: triage, labels, stable_release, documentation, roadmap) +- **Final Tier: 3** + +--- + +## Server Conformance Details + +Pass rate: 83.3% (25/30) + +| Scenario | Status | Checks | Spec Versions | +|----------|--------|--------|---------------| +| server-tools-list | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-with-progress | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-with-logging | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-simple-text | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-sampling | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-mixed-content | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-image | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-error | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-embedded-resource | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-elicitation | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-audio | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-server-sse-multiple-streams | PASS | 2/2 | 2025-11-25 | +| server-server-initialize | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-unsubscribe | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-templates-read | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-subscribe | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-read-text | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-read-binary | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-list | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-prompts-list | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-prompts-get-with-image | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-prompts-get-with-args | FAIL | 0/1 | 2025-06-18, 2025-11-25 | +| server-prompts-get-simple | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-prompts-get-embedded-resource | FAIL | 0/1 | 2025-06-18, 2025-11-25 | +| server-ping | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-logging-set-level | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-elicitation-sep1330-enums | FAIL | 4/5 | 2025-11-25 | +| server-elicitation-sep1034-defaults | FAIL | 2/5 | 2025-11-25 | +| server-dns-rebinding-protection | FAIL | 1/2 | 2025-11-25 | +| server-completion-complete | PASS | 1/1 | 2025-06-18, 2025-11-25 | + +--- + +## Client Conformance Details + +Full suite pass rate: 85.0% (17/20 date-versioned) + +> **Suite breakdown**: Core: 4/4 (100%), Auth (date-versioned): 13/16 (81.3%) + +### Core Scenarios + +| Scenario | Status | Checks | Spec Versions | +|----------|--------|--------|---------------| +| tools_call | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| sse-retry | PASS | 3/3 | 2025-11-25 | +| initialize | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| elicitation-sep1034-client-defaults | PASS | 5/5 | 2025-11-25 | + +### Auth Scenarios (Date-Versioned) + +| Scenario | Status | Checks | Spec Versions | Notes | +|----------|--------|--------|---------------|-------| +| auth/token-endpoint-auth-post | PASS | 19/19 | 2025-06-18, 2025-11-25 | | +| auth/token-endpoint-auth-none | PASS | 19/19 | 2025-06-18, 2025-11-25 | | +| auth/token-endpoint-auth-basic | PASS | 19/19 | 2025-06-18, 2025-11-25 | | +| auth/scope-step-up | FAIL | 13/14 | 2025-11-25 | | +| auth/scope-retry-limit | PASS | 11/11 | 2025-11-25 | | +| auth/scope-omitted-when-undefined | PASS | 15/15 | 2025-11-25 | | +| auth/scope-from-www-authenticate | PASS | 11/11 | 2025-11-25 | | +| auth/scope-from-scopes-supported | PASS | 15/15 | 2025-11-25 | | +| auth/pre-registration | PASS | 14/14 | 2025-11-25 | | +| auth/metadata-var3 | FAIL | 0/4 | 2025-11-25 | | +| auth/metadata-var2 | PASS | 14/14 | 2025-11-25 | | +| auth/metadata-var1 | PASS | 14/14 | 2025-11-25 | | +| auth/metadata-default | PASS | 14/14 | 2025-11-25 | | +| auth/basic-cimd | PASS | 14/14 | 2025-11-25 | | +| auth/2025-03-26-oauth-metadata-backcompat | PASS | 12/12 | 2025-03-26 | | +| auth/2025-03-26-oauth-endpoint-fallback | FAIL | 0/3 | 2025-03-26 | | + +### Auth Scenarios (Informational — not scored) + +| Scenario | Status | Checks | Spec Versions | +|----------|--------|--------|---------------| +| auth/resource-mismatch | FAIL | 14/15 | draft | +| auth/cross-app-access-complete-flow | FAIL | 10/12 | extension | +| auth/client-credentials-jwt | FAIL | 4/5 | extension | +| auth/client-credentials-basic | PASS | 9/9 | extension | + +--- + +## Issue Triage Details + +Analysis period: Last 64 issues +Labels present: question, good first issue, help wanted (3/12) +Uses issue types: No + +| Metric | Value | T1 Req | T2 Req | Verdict | +|--------|-------|--------|--------|---------| +| Compliance rate | 14.1% | >= 90% | >= 80% | FAIL | +| Triaged within SLA | 9 | — | — | — | +| Exceeding SLA | 54 | — | — | — | +| Median triage time | 4341.3h | — | — | — | +| P95 triage time | 8095.1h | — | — | — | +| Open P0s | 0 | 0 | 0 | PASS | + +--- + +## Documentation Coverage + +### Documentation Coverage Assessment + +**SDK path**: ~/Development/rust-sdk +**Documentation locations found**: + +- README.md: Top-level overview, basic client/server setup +- crates/rmcp/README.md: Core library docs with quick start, transport options, feature flags, structured output, tasks +- examples/README.md: Quick start with Claude Desktop +- examples/servers/README.md: Server example descriptions +- examples/clients/README.md: Client example descriptions +- docs/OAUTH_SUPPORT.md: OAuth 2.1 authorization documentation +- crates/rmcp-macros/README.md: Macro crate documentation + +#### Feature Documentation Table + +| # | Feature | Documented? | Where | Has Examples? | Verdict | +|---|---------|-------------|-------|---------------|---------| +| 1 | Tools - listing | Yes | crates/rmcp/README.md:21-90 | Yes (1 example) | PASS | +| 2 | Tools - calling | Yes | crates/rmcp/README.md:21-90, examples/clients/README.md | Yes (2 examples) | PASS | +| 3 | Tools - text results | Yes | crates/rmcp/README.md:50-60 | Yes (1 example) | PASS | +| 4 | Tools - image results | No | — | No | FAIL | +| 5 | Tools - audio results | No | — | No | FAIL | +| 6 | Tools - embedded resources | No | — | No | FAIL | +| 7 | Tools - error handling | No | — | No | FAIL | +| 8 | Tools - change notifications | No | — | No | FAIL | +| 9 | Resources - listing | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 10 | Resources - reading text | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 11 | Resources - reading binary | No | — | No | FAIL | +| 12 | Resources - templates | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 13 | Resources - template reading | No | — | No | FAIL | +| 14 | Resources - subscribing | No | — | No | FAIL | +| 15 | Resources - unsubscribing | No | — | No | FAIL | +| 16 | Resources - change notifications | No | — | No | FAIL | +| 17 | Prompts - listing | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 18 | Prompts - getting simple | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 19 | Prompts - getting with arguments | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 20 | Prompts - embedded resources | No | — | No | FAIL | +| 21 | Prompts - image content | No | — | No | FAIL | +| 22 | Prompts - change notifications | No | — | No | FAIL | +| 23 | Sampling - creating messages | No | — | Yes (servers/sampling_stdio.rs, clients/sampling_stdio.rs) | PARTIAL | +| 24 | Elicitation - form mode | Yes | examples/servers/README.md:38-53 | Yes (elicitation_stdio.rs) | PASS | +| 25 | Elicitation - URL mode | No | — | No | FAIL | +| 26 | Elicitation - schema validation | No | — | No | FAIL | +| 27 | Elicitation - default values | No | — | No | FAIL | +| 28 | Elicitation - enum values | No | — | Yes (elicitation_enum_inference.rs) | PARTIAL | +| 29 | Elicitation - complete notification | No | — | No | FAIL | +| 30 | Roots - listing | No | — | No | FAIL | +| 31 | Roots - change notifications | No | — | No | FAIL | +| 32 | Logging - sending log messages | No | — | No | FAIL | +| 33 | Logging - setting level | No | — | No | FAIL | +| 34 | Completions - resource argument | No | — | Yes (completion_stdio.rs) | PARTIAL | +| 35 | Completions - prompt argument | No | — | Yes (completion_stdio.rs) | PARTIAL | +| 36 | Ping | No | — | No | FAIL | +| 37 | Streamable HTTP transport (client) | Yes | crates/rmcp/README.md:175-195 | Yes (clients/streamable_http.rs) | PASS | +| 38 | Streamable HTTP transport (server) | Yes | crates/rmcp/README.md:175-195 | Yes (servers/counter_streamhttp.rs) | PASS | +| 39 | SSE transport - legacy (client) | No | — | No | FAIL | +| 40 | SSE transport - legacy (server) | No | — | No | FAIL | +| 41 | stdio transport (client) | Yes | crates/rmcp/README.md:140-165 | Yes (clients/git_stdio.rs) | PASS | +| 42 | stdio transport (server) | Yes | crates/rmcp/README.md:21-90 | Yes (servers/counter_stdio.rs) | PASS | +| 43 | Progress notifications | No | — | Yes (servers/progress_demo.rs, clients/progress_client.rs) | PARTIAL | +| 44 | Cancellation | No | — | No | FAIL | +| 45 | Pagination | No | — | No | FAIL | +| 46 | Capability negotiation | No | — | No | FAIL | +| 47 | Protocol version negotiation | No | — | No | FAIL | +| 48 | JSON Schema 2020-12 support | Yes | README.md:32-33, crates/rmcp/README.md:92-120 | Yes (structured output example) | PASS | +| — | Tasks - get (experimental) | Yes | crates/rmcp/README.md (Tasks section) | No | INFO | +| — | Tasks - result (experimental) | Yes | crates/rmcp/README.md (Tasks section) | No | INFO | +| — | Tasks - cancel (experimental) | Yes | crates/rmcp/README.md (Tasks section) | No | INFO | +| — | Tasks - list (experimental) | No | — | No | INFO | +| — | Tasks - status notifications (experimental) | No | — | No | INFO | + +#### Summary + +**Total non-experimental features**: 48 +**PASS (documented with examples)**: 9/48 +**PARTIAL (documented or examples only)**: 11/48 +**FAIL (not documented)**: 28/48 + +**Core features documented**: ~6/36 (16.7%) +**All features documented with examples**: 9/48 (18.8%) + +#### Tier Verdicts + +**Tier 1** (all non-experimental features documented with examples): **FAIL** + +- 39 features missing full documentation with examples + +**Tier 2** (basic docs covering core features): **FAIL** + +- Most core features (resources, prompts, sampling, roots, logging, completions, notifications, subscriptions) lack prose documentation +- Only tools (basic), transports (stdio, streamable HTTP), elicitation (form mode), and JSON Schema have adequate prose docs + +--- + +## Policy Evaluation + +### Policy Evaluation Assessment + +**SDK path**: ~/Development/rust-sdk +**Repository**: modelcontextprotocol/rust-sdk + +--- + +#### 1. Dependency Update Policy: PASS + +| File | Exists (CLI) | Content Verdict | +|------|-------------|----------------| +| DEPENDENCY_POLICY.md | No | N/A | +| docs/dependency-policy.md | No | N/A | +| .github/dependabot.yml | Yes | Configured — weekly Cargo updates, daily GitHub Actions updates, with PR limits and labeling | +| .github/renovate.json | No | N/A | + +**Verdict**: **PASS** — Dependabot is properly configured with weekly Cargo dependency updates and daily GitHub Actions updates. + +--- + +#### 2. Roadmap: FAIL + +| File | Exists (CLI) | Content Verdict | +|------|-------------|----------------| +| ROADMAP.md | No | N/A | +| docs/roadmap.md | No | N/A | + +**Verdict**: + +- **Tier 1**: **FAIL** — No roadmap file exists. +- **Tier 2**: **FAIL** — No roadmap or plan-toward-Tier-1 file exists. + +--- + +#### 3. Versioning Policy: FAIL + +| File | Exists (CLI) | Content Verdict | +|------|-------------|----------------| +| VERSIONING.md | No | N/A | +| docs/versioning.md | No | N/A | +| BREAKING_CHANGES.md | No | N/A | +| CONTRIBUTING.md (versioning section) | No | N/A | + +**Verdict**: + +- **Tier 1**: **FAIL** — No versioning or breaking change documentation exists. +- **Tier 2**: **N/A** — only requires stable release. + +--- + +#### Overall Policy Summary + +| Policy Area | Tier 1 | Tier 2 | +|-------------|--------|--------| +| Dependency Update Policy | PASS | PASS | +| Roadmap | FAIL | FAIL | +| Versioning Policy | FAIL | N/A | diff --git a/conformance/results/2026-02-25-rust-sdk-remediation.md b/conformance/results/2026-02-25-rust-sdk-remediation.md new file mode 100644 index 000000000..17d4e9731 --- /dev/null +++ b/conformance/results/2026-02-25-rust-sdk-remediation.md @@ -0,0 +1,43 @@ +# Remediation Guide: modelcontextprotocol/rust-sdk + +**Date**: 2026-02-25 +**Current Tier**: 3 + +## Path to Tier 2 + +The following requirements must be met to advance from Tier 3 to Tier 2: + +| # | Action | Requirement | Effort | Where | +|---|--------|-------------|--------|-------| +| 1 | Create 9 missing issue labels (bug, enhancement, needs confirmation, needs repro, ready for work, P0, P1, P2, P3) and triage existing issues | Labels (3/12 → 12/12) + Triage (14.1% → ≥80%) | Medium | GitHub repo settings, open issues | +| 2 | Publish stable release ≥ 1.0.0 | Stable Release | Medium | Cargo.toml, release process | +| 3 | Add prose documentation for core features: resources, prompts, sampling, roots, logging, completions, notifications, subscriptions | Documentation (basic docs for core features) | Large | README.md, docs/, crates/rmcp/README.md | +| 4 | Create ROADMAP.md with plan toward Tier 1 | Roadmap | Small | ROADMAP.md | + +## Path to Tier 1 + +The following requirements must be met to advance to Tier 1 (includes all Tier 2 gaps): + +| # | Action | Requirement | Effort | Where | +|---|--------|-------------|--------|-------| +| 1 | Fix 5 failing server conformance scenarios: prompts-get-with-args, prompts-get-embedded-resource, elicitation-sep1330-enums, elicitation-sep1034-defaults, dns-rebinding-protection | Server Conformance (83.3% → 100%) | Medium | Conformance server implementation | +| 2 | Fix 3 failing client conformance scenarios: auth/scope-step-up, auth/metadata-var3, auth/2025-03-26-oauth-endpoint-fallback | Client Conformance (85.0% → 100%) | Medium | OAuth client implementation | +| 3 | Create 9 missing issue labels and triage all open issues within 2 business days going forward | Labels + Triage (14.1% → ≥90%) | Medium | GitHub repo settings, issue triage process | +| 4 | Publish stable release ≥ 1.0.0 with clear versioning | Stable Release | Medium | Cargo.toml, release process | +| 5 | Document ALL 48 non-experimental features with prose and code examples | Documentation (9/48 → 48/48) | Large | README.md, docs/, crates/rmcp/README.md, examples/ | +| 6 | Create ROADMAP.md with concrete steps tracking MCP spec components | Roadmap | Small | ROADMAP.md | +| 7 | Create VERSIONING.md documenting breaking change policy and versioning scheme | Versioning Policy | Small | VERSIONING.md | + +## Recommended Next Steps + +1. **Set up issue labels and begin triage process** (Small effort, unblocks Tier 2 triage requirement). Create the 9 missing labels (bug, enhancement, needs confirmation, needs repro, ready for work, P0-P3) and begin labeling all new issues within 2 business days. Retroactively triage the 54 unlabeled issues. + +2. **Create ROADMAP.md and VERSIONING.md** (Small effort, unblocks Tier 2 roadmap and Tier 1 versioning). Write a roadmap outlining the path to 1.0.0 and Tier 1, and document the versioning/breaking-change policy. + +3. **Write prose documentation for core features** (Large effort, unblocks Tier 2 documentation). Priority features to document: resources (listing, reading, templates, subscriptions), prompts (listing, getting, arguments, embedded resources), sampling, roots, logging, completions, notifications, and change notifications. The SDK already has good examples in `examples/` — these need accompanying prose in `docs/` or `crates/rmcp/README.md`. + +4. **Fix server conformance failures** (Medium effort, advances toward Tier 1). The 5 failures are in prompts-get-with-args, prompts-get-embedded-resource, elicitation-sep1330-enums, elicitation-sep1034-defaults, and dns-rebinding-protection. The elicitation failures appear to be in default value handling and enum validation; the prompts failures may be response format issues. + +5. **Fix client auth conformance failures** (Medium effort, advances toward Tier 1). The 3 date-versioned failures are auth/scope-step-up (1 check failing), auth/metadata-var3 (all 4 checks failing — likely a metadata discovery edge case), and auth/2025-03-26-oauth-endpoint-fallback (all 3 checks failing — legacy endpoint fallback). + +6. **Plan and execute 1.0.0 release** (Medium effort, unblocks Tier 2 stable release). The current version is 0.16.0. A 1.0.0 release signals production readiness and is required for both Tier 1 and Tier 2. diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs new file mode 100644 index 000000000..53a44d9e7 --- /dev/null +++ b/conformance/src/bin/client.rs @@ -0,0 +1,987 @@ +use std::future::Future; + +use rmcp::{ + ClientHandler, ErrorData, RoleClient, ServiceExt, + model::*, + service::RequestContext, + transport::{ + AuthClient, AuthorizationManager, StreamableHttpClientTransport, + auth::{OAuthClientConfig, OAuthState}, + streamable_http_client::StreamableHttpClientTransportConfig, + }, +}; +use serde_json::{Value, json}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +// ─── Context parsed from MCP_CONFORMANCE_CONTEXT ──────────────────────────── + +#[derive(Debug, Default, serde::Deserialize)] +struct ConformanceContext { + #[serde(default)] + name: Option, + // pre-registration / client-credentials-basic + #[serde(default)] + client_id: Option, + #[serde(default)] + client_secret: Option, + // client-credentials-jwt + #[serde(default)] + private_key_pem: Option, + #[serde(default)] + signing_algorithm: Option, + // cross-app-access + #[serde(default)] + idp_client_id: Option, + #[serde(default)] + idp_id_token: Option, + #[serde(default)] + idp_issuer: Option, + #[serde(default)] + idp_token_endpoint: Option, +} + +fn load_context() -> ConformanceContext { + std::env::var("MCP_CONFORMANCE_CONTEXT") + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +// ─── Client handlers ──────────────────────────────────────────────────────── + +/// A basic client handler that does nothing special +struct BasicClientHandler; +impl ClientHandler for BasicClientHandler {} + +/// A client handler that handles elicitation requests by applying schema defaults. +struct ElicitationDefaultsClientHandler; + +impl ClientHandler for ElicitationDefaultsClientHandler { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, + }); + info + } + + fn create_elicitation( + &self, + request: CreateElicitationRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let content = match &request { + CreateElicitationRequestParams::FormElicitationParams { + requested_schema, .. + } => { + let mut defaults = serde_json::Map::new(); + for (name, prop) in &requested_schema.properties { + match prop { + PrimitiveSchema::String(s) => { + if let Some(d) = &s.default { + defaults.insert(name.clone(), Value::String(d.clone())); + } + } + PrimitiveSchema::Number(n) => { + if let Some(d) = n.default { + defaults.insert(name.clone(), json!(d)); + } + } + PrimitiveSchema::Integer(i) => { + if let Some(d) = i.default { + defaults.insert(name.clone(), json!(d)); + } + } + PrimitiveSchema::Boolean(b) => { + if let Some(d) = b.default { + defaults.insert(name.clone(), Value::Bool(d)); + } + } + PrimitiveSchema::Enum(e) => { + let val = match e { + EnumSchema::Single(SingleSelectEnumSchema::Untitled(u)) => { + u.default.as_ref().map(|d| Value::String(d.clone())) + } + EnumSchema::Single(SingleSelectEnumSchema::Titled(t)) => { + t.default.as_ref().map(|d| Value::String(d.clone())) + } + EnumSchema::Multi(MultiSelectEnumSchema::Untitled(u)) => { + u.default.as_ref().map(|d| { + Value::Array( + d.iter() + .map(|s| Value::String(s.clone())) + .collect(), + ) + }) + } + EnumSchema::Multi(MultiSelectEnumSchema::Titled(t)) => { + t.default.as_ref().map(|d| { + Value::Array( + d.iter() + .map(|s| Value::String(s.clone())) + .collect(), + ) + }) + } + EnumSchema::Legacy(_) => None, + }; + if let Some(v) = val { + defaults.insert(name.clone(), v); + } + } + } + } + Some(Value::Object(defaults)) + } + _ => Some(json!({})), + }; + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content, + }) + } + } +} + +/// A client handler that handles both sampling and elicitation +struct FullClientHandler; + +impl ClientHandler for FullClientHandler { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, + }); + info + } + + fn create_message( + &self, + params: CreateMessageRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let prompt_text = params + .messages + .first() + .and_then(|m| m.content.first()) + .and_then(|c| c.as_text()) + .map(|t| t.text.clone()) + .unwrap_or_default(); + Ok(CreateMessageResult { + message: SamplingMessage::new( + Role::Assistant, + SamplingMessageContent::text(format!( + "This is a mock LLM response to: {}", + prompt_text + )), + ), + model: "mock-model".into(), + stop_reason: Some("endTurn".into()), + }) + } + } + + fn create_elicitation( + &self, + _request: CreateElicitationRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content: Some(json!({"username": "testuser", "email": "test@example.com"})), + }) + } + } +} + +// ─── OAuth helpers ────────────────────────────────────────────────────────── + +const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json"; +const REDIRECT_URI: &str = "http://localhost:3000/callback"; + +/// Perform the headless OAuth authorization-code flow. +/// +/// 1. Discover metadata, register (or use CIMD), get auth URL +/// 2. Fetch the auth URL with redirect:manual → extract code from Location header +/// 3. Exchange code for token +/// 4. Return an `AuthClient` wrapping `reqwest::Client` +async fn perform_oauth_flow( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result> { + let mut oauth = OAuthState::new(server_url, None).await?; + + // Discover + register + get auth URL + oauth + .start_authorization_with_metadata_url( + &[], + REDIRECT_URI, + Some("conformance-client"), + Some(CIMD_CLIENT_METADATA_URL), + ) + .await?; + + let auth_url = oauth.get_authorization_url().await?; + tracing::debug!("Authorization URL: {}", auth_url); + + // Headless: fetch the auth URL without following redirects + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let resp = http.get(&auth_url).send().await?; + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow::anyhow!("No Location header in auth redirect"))?; + + let redirect_url = url::Url::parse(location)?; + let code = redirect_url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No code in redirect URL"))?; + let state = redirect_url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No state in redirect URL"))?; + + tracing::debug!("Got auth code, exchanging for token..."); + oauth.handle_callback(&code, &state).await?; + + let am = oauth + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; + + Ok(AuthClient::new(reqwest::Client::default(), am)) +} + +/// Like `perform_oauth_flow` but uses pre-registered client credentials. +async fn perform_oauth_flow_preregistered( + server_url: &str, + client_id: &str, + client_secret: &str, +) -> anyhow::Result> { + let mut manager = AuthorizationManager::new(server_url).await?; + let metadata = manager.discover_metadata().await?; + manager.set_metadata(metadata); + + // Configure with pre-registered credentials + let config = rmcp::transport::auth::OAuthClientConfig { + client_id: client_id.to_string(), + client_secret: Some(client_secret.to_string()), + scopes: vec![], + redirect_uri: REDIRECT_URI.to_string(), + }; + manager.configure_client(config)?; + + let scopes = manager.select_scopes(None, &[]); + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + let auth_url = manager.get_authorization_url(&scope_refs).await?; + + // Headless redirect + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let resp = http.get(&auth_url).send().await?; + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow::anyhow!("No Location header"))?; + let redirect_url = url::Url::parse(location)?; + let code = redirect_url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No code"))?; + let state = redirect_url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No state"))?; + + manager.exchange_code_for_token(&code, &state).await?; + + Ok(AuthClient::new(reqwest::Client::default(), manager)) +} + +/// Run the standard auth flow, then connect and exercise the server. +async fn run_auth_client(server_url: &str, ctx: &ConformanceContext) -> anyhow::Result<()> { + let auth_client = perform_oauth_flow(server_url, ctx).await?; + + let transport = StreamableHttpClientTransport::with_client( + auth_client, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + + let client = BasicClientHandler.serve(transport).await?; + tracing::debug!("Connected (authenticated)"); + + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + + // Call each tool + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + } + + client.cancel().await?; + Ok(()) +} + +/// Auth flow with scope step-up: connect, list tools (ok with basic scope), +/// then call tool which triggers 403 → re-auth with expanded scopes → retry. +async fn run_auth_scope_step_up_client( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + // First auth + let mut oauth = OAuthState::new(server_url, None).await?; + oauth + .start_authorization_with_metadata_url( + &[], + REDIRECT_URI, + Some("conformance-client"), + Some(CIMD_CLIENT_METADATA_URL), + ) + .await?; + + let auth_url = oauth.get_authorization_url().await?; + let (code, state) = headless_authorize(&auth_url).await?; + oauth.handle_callback(&code, &state).await?; + + let am = oauth + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("No AM"))?; + let auth_client = AuthClient::new(reqwest::Client::default(), am); + + let transport = StreamableHttpClientTransport::with_client( + auth_client.clone(), + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + + let client = BasicClientHandler.serve(transport).await?; + + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + + // Try calling tool – may get 403 insufficient_scope + for tool in &tools.tools { + let args = build_tool_arguments(tool); + match client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args.clone(), + task: None, + }) + .await + { + Ok(_) => { + tracing::debug!("Tool call succeeded on first try"); + } + Err(_) => { + tracing::debug!("Tool call failed (likely 403), attempting scope upgrade..."); + // Drop old client, re-auth with upgraded scopes + client.cancel().await.ok(); + + // Re-do the full flow; the server will give us the right scopes + // on the second authorization request. + let mut oauth2 = OAuthState::new(server_url, None).await?; + // Pass the escalated scope hint + oauth2 + .start_authorization_with_metadata_url( + &[], + REDIRECT_URI, + Some("conformance-client"), + Some(CIMD_CLIENT_METADATA_URL), + ) + .await?; + let auth_url2 = oauth2.get_authorization_url().await?; + let (code2, state2) = headless_authorize(&auth_url2).await?; + oauth2.handle_callback(&code2, &state2).await?; + + let am2 = oauth2.into_authorization_manager().unwrap(); + let auth_client2 = AuthClient::new(reqwest::Client::default(), am2); + let transport2 = StreamableHttpClientTransport::with_client( + auth_client2, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + let client2 = BasicClientHandler.serve(transport2).await?; + let _ = client2 + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + client2.cancel().await.ok(); + return Ok(()); + } + } + } + + client.cancel().await?; + Ok(()) +} + +/// Auth flow for scope-retry-limit: keep re-authing on 403 until we hit a limit. +async fn run_auth_scope_retry_limit_client( + server_url: &str, + _ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let max_retries = 3u32; + let mut attempt = 0u32; + + loop { + let mut oauth = OAuthState::new(server_url, None).await?; + oauth + .start_authorization_with_metadata_url( + &[], + REDIRECT_URI, + Some("conformance-client"), + Some(CIMD_CLIENT_METADATA_URL), + ) + .await?; + let auth_url = oauth.get_authorization_url().await?; + let (code, state) = headless_authorize(&auth_url).await?; + oauth.handle_callback(&code, &state).await?; + + let am = oauth.into_authorization_manager().unwrap(); + let auth_client = AuthClient::new(reqwest::Client::default(), am); + let transport = StreamableHttpClientTransport::with_client( + auth_client, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + + let mut got_403 = false; + for tool in &tools.tools { + let args = build_tool_arguments(tool); + match client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await + { + Ok(_) => {} + Err(_) => { + got_403 = true; + break; + } + } + } + client.cancel().await.ok(); + + if !got_403 { + break; + } + attempt += 1; + if attempt >= max_retries { + tracing::info!("Reached retry limit ({max_retries}), giving up"); + return Err(anyhow::anyhow!("Scope retry limit reached")); + } + } + Ok(()) +} + +/// Auth flow with pre-registered credentials (from context). +async fn run_auth_preregistered_client( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let client_id = ctx + .client_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing client_id in context"))?; + let client_secret = ctx + .client_secret + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing client_secret in context"))?; + + let auth_client = + perform_oauth_flow_preregistered(server_url, client_id, client_secret).await?; + + let transport = StreamableHttpClientTransport::with_client( + auth_client, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + } + client.cancel().await?; + Ok(()) +} + +/// Client-credentials flow with client_secret_basic. +async fn run_client_credentials_basic( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let client_id = ctx + .client_id + .as_deref() + .unwrap_or("conformance-test-client"); + let client_secret = ctx + .client_secret + .as_deref() + .unwrap_or("conformance-test-secret"); + + let mut manager = AuthorizationManager::new(server_url).await?; + let metadata = manager.discover_metadata().await?; + let token_endpoint = metadata.token_endpoint.clone(); + manager.set_metadata(metadata); + + let http = reqwest::Client::new(); + let resp = http + .post(&token_endpoint) + .basic_auth(client_id, Some(client_secret)) + .header("content-type", "application/x-www-form-urlencoded") + .body("grant_type=client_credentials") + .send() + .await?; + + let token_resp: serde_json::Value = resp.json().await?; + let access_token = token_resp["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("No access_token in response"))?; + + // Use static token + let transport = StreamableHttpClientTransport::with_client( + reqwest::Client::default(), + StreamableHttpClientTransportConfig::with_uri(server_url) + .auth_header(access_token.to_string()), + ); + + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + } + client.cancel().await?; + Ok(()) +} + +/// Client-credentials flow with private_key_jwt (JWT assertion). +async fn run_client_credentials_jwt( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let client_id = ctx + .client_id + .as_deref() + .unwrap_or("conformance-test-client"); + let _pem = ctx + .private_key_pem + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?; + let _alg = ctx + .signing_algorithm + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?; + + // Discover metadata to get token endpoint + let mut manager = AuthorizationManager::new(server_url).await?; + let metadata = manager.discover_metadata().await?; + let token_endpoint = metadata.token_endpoint.clone(); + manager.set_metadata(metadata); + + // Build JWT assertion + // Parse the PEM private key + let key = openssl_free_ec_sign(_pem, client_id, &token_endpoint)?; + + let http = reqwest::Client::new(); + let form_body = format!( + "grant_type=client_credentials&client_assertion_type={}&client_assertion={}", + urlencoding::encode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + urlencoding::encode(&key), + ); + let resp = http + .post(&token_endpoint) + .header("content-type", "application/x-www-form-urlencoded") + .body(form_body) + .send() + .await?; + + let token_resp: serde_json::Value = resp.json().await?; + let access_token = token_resp["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("No access_token: {}", token_resp))?; + + let transport = StreamableHttpClientTransport::with_client( + reqwest::Client::default(), + StreamableHttpClientTransportConfig::with_uri(server_url) + .auth_header(access_token.to_string()), + ); + + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + } + client.cancel().await?; + Ok(()) +} + +/// Minimal ES256 JWT signing without heavy deps. +/// We use ring or pure-Rust approach. For simplicity, use the p256 + base64 crates +/// that are already transitive deps of oauth2. +fn openssl_free_ec_sign(pem: &str, client_id: &str, audience: &str) -> anyhow::Result { + use std::time::{SystemTime, UNIX_EPOCH}; + + // Decode PEM → DER + let pem_body = pem + .lines() + .filter(|l| !l.starts_with("-----")) + .collect::(); + let der = base64_decode(&pem_body)?; + + // Parse PKCS#8 DER to get the raw EC private key bytes + // PKCS#8 for EC P-256: the raw 32-byte key is at the end of the structure + let raw_key = extract_ec_private_key(&der)?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let header = base64url_encode(br#"{"alg":"ES256","typ":"JWT"}"#); + let payload_json = serde_json::json!({ + "iss": client_id, + "sub": client_id, + "aud": audience, + "iat": now, + "exp": now + 300, + "jti": format!("jti-{}", now), + }); + let payload = base64url_encode(payload_json.to_string().as_bytes()); + let signing_input = format!("{}.{}", header, payload); + + // Sign with p256 + let secret_key = p256::ecdsa::SigningKey::from_bytes(raw_key.as_slice().into()) + .map_err(|e| anyhow::anyhow!("Invalid EC key: {}", e))?; + use p256::ecdsa::signature::Signer; + let sig: p256::ecdsa::Signature = secret_key.sign(signing_input.as_bytes()); + let sig_bytes = sig.to_bytes(); + let sig_b64 = base64url_encode(&sig_bytes); + + Ok(format!("{}.{}", signing_input, sig_b64)) +} + +fn base64url_encode(data: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) +} + +fn base64_decode(s: &str) -> anyhow::Result> { + use base64::Engine; + Ok(base64::engine::general_purpose::STANDARD.decode(s.trim())?) +} + +/// Extract the raw 32-byte EC private key from a PKCS#8 DER blob. +fn extract_ec_private_key(der: &[u8]) -> anyhow::Result> { + // PKCS#8 wraps an ECPrivateKey. We look for the octet string containing + // the 32-byte private key. A simple heuristic: find 0x04 0x20 (OCTET STRING, len 32) + // followed by exactly 32 bytes that form the key. + // More robust: parse ASN.1. But for conformance testing this suffices. + for i in 0..der.len().saturating_sub(33) { + if der[i] == 0x04 && der[i + 1] == 0x20 && i + 34 <= der.len() { + return Ok(der[i + 2..i + 34].to_vec()); + } + } + Err(anyhow::anyhow!( + "Could not extract 32-byte EC private key from PKCS#8 DER" + )) +} + +/// Cross-app access flow (SEP-1046 extension). +async fn run_cross_app_access_client( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + // For now, fall back to standard auth flow + // The cross-app-access test is an extension scenario + run_auth_client(server_url, ctx).await +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/// Fetch an authorization URL headlessly, returning (code, state). +async fn headless_authorize(auth_url: &str) -> anyhow::Result<(String, String)> { + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let resp = http.get(auth_url).send().await?; + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow::anyhow!("No Location header in auth redirect"))?; + let redirect_url = url::Url::parse(location)?; + let code = redirect_url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No code in redirect URL"))?; + let state = redirect_url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No state in redirect URL"))?; + Ok((code, state)) +} + +/// Build arguments for a tool based on its input schema. +fn build_tool_arguments(tool: &Tool) -> Option> { + let schema = &tool.input_schema; + let properties = schema.get("properties").and_then(|p| p.as_object()); + let required = schema + .get("required") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + + let Some(properties) = properties else { + return None; + }; + if properties.is_empty() && required.is_empty() { + return None; + } + + let mut args = serde_json::Map::new(); + for (name, prop_schema) in properties { + if !required.contains(name) { + continue; + } + let type_str = prop_schema.get("type").and_then(|t| t.as_str()); + let value = match type_str { + Some("number") => json!(1.0), + Some("integer") => json!(1), + Some("string") => json!("test"), + Some("boolean") => json!(true), + _ => json!(null), + }; + args.insert(name.clone(), value); + } + Some(args) +} + +// ─── Non-auth scenarios ───────────────────────────────────────────────────── + +async fn run_basic_client(server_url: &str) -> anyhow::Result<()> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + client.cancel().await?; + Ok(()) +} + +async fn run_tools_call_client(server_url: &str) -> anyhow::Result<()> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = FullClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await?; + } + client.cancel().await?; + Ok(()) +} + +async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = ElicitationDefaultsClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + let test_tool = tools.tools.iter().find(|t| { + let n = t.name.as_ref(); + n == "test_client_elicitation_defaults" || n == "test_elicitation_sep1034_defaults" + }); + if let Some(tool) = test_tool { + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: None, + task: None, + }) + .await?; + } + client.cancel().await?; + Ok(()) +} + +async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + if let Some(tool) = tools + .tools + .iter() + .find(|t| t.name.as_ref() == "test_reconnection") + { + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: None, + task: None, + }) + .await?; + } + client.cancel().await?; + Ok(()) +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let scenario = + std::env::var("MCP_CONFORMANCE_SCENARIO").unwrap_or_else(|_| "initialize".to_string()); + let server_url = std::env::args() + .nth(1) + .unwrap_or_else(|| "http://127.0.0.1:8001/mcp".to_string()); + let ctx = load_context(); + + tracing::info!("Running scenario '{}' against {}", scenario, server_url); + + match scenario.as_str() { + // Non-auth scenarios + "initialize" => run_basic_client(&server_url).await?, + "tools_call" => run_tools_call_client(&server_url).await?, + "elicitation-sep1034-client-defaults" => { + run_elicitation_defaults_client(&server_url).await? + } + "sse-retry" => run_sse_retry_client(&server_url).await?, + + // Auth scenarios - standard OAuth flow + "auth/metadata-default" + | "auth/metadata-var1" + | "auth/metadata-var2" + | "auth/metadata-var3" + | "auth/basic-cimd" + | "auth/scope-from-www-authenticate" + | "auth/scope-from-scopes-supported" + | "auth/scope-omitted-when-undefined" + | "auth/token-endpoint-auth-basic" + | "auth/token-endpoint-auth-post" + | "auth/token-endpoint-auth-none" + | "auth/2025-03-26-oauth-metadata-backcompat" + | "auth/2025-03-26-oauth-endpoint-fallback" => run_auth_client(&server_url, &ctx).await?, + + // Auth - scope step-up + "auth/scope-step-up" => run_auth_scope_step_up_client(&server_url, &ctx).await?, + + // Auth - scope retry limit + "auth/scope-retry-limit" => run_auth_scope_retry_limit_client(&server_url, &ctx).await?, + + // Auth - pre-registration + "auth/pre-registration" => run_auth_preregistered_client(&server_url, &ctx).await?, + + // Auth - resource mismatch (should fail to auth → pass) + "auth/resource-mismatch" => { + // Try to auth; it should fail because PRM resource doesn't match + match run_auth_client(&server_url, &ctx).await { + Ok(_) => { + tracing::warn!("Auth succeeded despite resource mismatch!"); + } + Err(e) => { + tracing::info!("Auth correctly failed: {}", e); + } + } + } + + // Auth - client credentials + "auth/client-credentials-basic" => run_client_credentials_basic(&server_url, &ctx).await?, + "auth/client-credentials-jwt" => run_client_credentials_jwt(&server_url, &ctx).await?, + + // Auth - cross-app access + "auth/cross-app-access-complete-flow" => { + run_cross_app_access_client(&server_url, &ctx).await? + } + + _ => { + tracing::warn!("Unknown scenario '{}', trying auth flow", scenario); + match run_auth_client(&server_url, &ctx).await { + Ok(_) => {} + Err(e) => { + tracing::debug!("Auth flow failed for unknown scenario: {e}"); + run_basic_client(&server_url).await? + } + } + } + } + + Ok(()) +} diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs new file mode 100644 index 000000000..97bfbcdcc --- /dev/null +++ b/conformance/src/bin/server.rs @@ -0,0 +1,959 @@ +use std::{collections::HashSet, future::Future, sync::Arc}; + +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + model::*, + service::RequestContext, + transport::{ + StreamableHttpServerConfig, StreamableHttpService, + streamable_http_server::session::local::LocalSessionManager, + }, +}; +use serde_json::{Value, json}; +use tokio::sync::Mutex; +use tracing_subscriber::EnvFilter; + +// Small base64-encoded 1x1 red PNG +const TEST_IMAGE_DATA: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; +// Small base64-encoded WAV (silence) +const TEST_AUDIO_DATA: &str = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA="; + +/// Helper to convert a serde_json::Value (must be an object) into a JsonObject +fn json_object(v: Value) -> JsonObject { + match v { + Value::Object(map) => map, + _ => panic!("Expected JSON object"), + } +} + +#[derive(Clone)] +struct ConformanceServer { + subscriptions: Arc>>, + log_level: Arc>, +} + +impl ConformanceServer { + fn new() -> Self { + Self { + subscriptions: Arc::new(Mutex::new(HashSet::new())), + log_level: Arc::new(Mutex::new(LoggingLevel::Debug)), + } + } +} + +impl ServerHandler for ConformanceServer { + fn initialize( + &self, + _request: InitializeRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + Ok(InitializeResult { + server_info: Implementation { + name: "rust-conformance-server".into(), + title: None, + version: "0.1.0".into(), + description: None, + icons: None, + website_url: None, + }, + capabilities: ServerCapabilities::builder() + .enable_prompts() + .enable_resources() + .enable_tools() + .enable_logging() + .build(), + instructions: Some("Rust MCP conformance test server".into()), + ..Default::default() + }) + } + } + + fn ping( + &self, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { Ok(()) } + } + + fn list_tools( + &self, + _request: Option, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + let tools = vec![ + Tool::new( + "test_simple_text", + "Returns simple text content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_image_content", + "Returns image content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_audio_content", + "Returns audio content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_embedded_resource", + "Returns embedded resource content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_multiple_content_types", + "Returns multiple content types", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_tool_with_logging", + "Sends logging notifications during execution", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_error_handling", + "Always returns an error", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_tool_with_progress", + "Reports progress notifications", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_sampling", + "Requests LLM sampling from client", + json_object(json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "The prompt to send" } + }, + "required": ["prompt"] + })), + ), + Tool::new( + "test_elicitation", + "Requests user input from client", + json_object(json!({ + "type": "object", + "properties": { + "message": { "type": "string", "description": "The message to show" } + }, + "required": ["message"] + })), + ), + Tool::new( + "test_elicitation_sep1034_defaults", + "Tests elicitation with default values (SEP-1034)", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_elicitation_sep1330_enums", + "Tests enum schema improvements (SEP-1330)", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "json_schema_2020_12_tool", + "Tool with JSON Schema 2020-12 features", + json_object(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + } + } + }, + "properties": { + "name": { "type": "string" }, + "address": { "$ref": "#/$defs/address" } + }, + "additionalProperties": false + })), + ), + Tool::new( + "test_reconnection", + "Tests SSE reconnection behavior", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + ]; + Ok(ListToolsResult { + meta: None, + tools, + next_cursor: None, + }) + } + } + + fn call_tool( + &self, + request: CallToolRequestParams, + cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let args = request.arguments.unwrap_or_default(); + match request.name.as_ref() { + "test_simple_text" => Ok(CallToolResult { + content: vec![Content::text("This is a simple text response for testing.")], + structured_content: None, + is_error: None, + meta: None, + }), + + "test_image_content" => Ok(CallToolResult { + content: vec![Content::image(TEST_IMAGE_DATA, "image/png")], + structured_content: None, + is_error: None, + meta: None, + }), + + "test_audio_content" => { + // No Content::audio() helper, construct manually + let audio = RawContent::Audio(RawAudioContent { + data: TEST_AUDIO_DATA.into(), + mime_type: "audio/wav".into(), + }) + .no_annotation(); + Ok(CallToolResult { + content: vec![audio], + structured_content: None, + is_error: None, + meta: None, + }) + } + + "test_embedded_resource" => Ok(CallToolResult { + content: vec![Content::resource(ResourceContents::TextResourceContents { + uri: "test://embedded-resource".into(), + mime_type: Some("text/plain".into()), + text: "This is an embedded resource content.".into(), + meta: None, + })], + structured_content: None, + is_error: None, + meta: None, + }), + + "test_multiple_content_types" => Ok(CallToolResult { + content: vec![ + Content::text("Multiple content types test:"), + Content::image(TEST_IMAGE_DATA, "image/png"), + Content::resource(ResourceContents::TextResourceContents { + uri: "test://mixed-content-resource".into(), + mime_type: Some("application/json".into()), + text: r#"{"test":"data","value":123}"#.into(), + meta: None, + }), + ], + structured_content: None, + is_error: None, + meta: None, + }), + + "test_tool_with_logging" => { + for msg in [ + "Tool execution started", + "Tool processing data", + "Tool execution completed", + ] { + let _ = cx + .peer + .notify_logging_message(LoggingMessageNotificationParam { + level: LoggingLevel::Info, + logger: Some("conformance-server".into()), + data: json!(msg), + }) + .await; + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + Ok(CallToolResult { + content: vec![Content::text("Logging test completed")], + structured_content: None, + is_error: None, + meta: None, + }) + } + + "test_error_handling" => Ok(CallToolResult { + content: vec![Content::text( + "This tool intentionally returns an error for testing", + )], + structured_content: None, + is_error: Some(true), + meta: None, + }), + + "test_tool_with_progress" => { + let progress_token = cx.meta.get_progress_token(); + + for (progress, message) in + [(0.0, "Starting"), (50.0, "Halfway"), (100.0, "Complete")] + { + if let Some(token) = &progress_token { + let _ = cx + .peer + .notify_progress(ProgressNotificationParam { + progress_token: token.clone(), + progress, + total: Some(100.0), + message: Some(message.into()), + }) + .await; + } + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + Ok(CallToolResult { + content: vec![Content::text("Progress test completed")], + structured_content: None, + is_error: None, + meta: None, + }) + } + + "test_sampling" => { + let prompt = args + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or("Hello"); + + match cx + .peer + .create_message(CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::user_text(prompt)], + max_tokens: 100, + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + }) + .await + { + Ok(result) => { + let text = result + .message + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.clone()) + .unwrap_or_else(|| "No text response".into()); + Ok(CallToolResult { + content: vec![Content::text(format!("LLM response: {}", text))], + structured_content: None, + is_error: None, + meta: None, + }) + } + Err(e) => Ok(CallToolResult { + content: vec![Content::text(format!("Sampling error: {}", e))], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } + + "test_elicitation" => { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Please provide your information"); + + let schema_json = json!({ + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "User's response" + }, + "email": { + "type": "string", + "description": "User's email address" + } + }, + "required": ["username", "email"] + }); + + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); + + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: message.into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult { + content: vec![Content::text(format!( + "User response: action={}, content={:?}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }, + result.content + ))], + structured_content: None, + is_error: None, + meta: None, + }), + Err(e) => Ok(CallToolResult { + content: vec![Content::text(format!("Elicitation error: {}", e))], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } + + "test_elicitation_sep1034_defaults" => { + let schema_json = json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "User's name", + "default": "John Doe" + }, + "age": { + "type": "integer", + "description": "User's age", + "default": 30 + }, + "score": { + "type": "number", + "description": "User's score", + "default": 95.5 + }, + "status": { + "type": "string", + "description": "User's status", + "enum": ["active", "inactive", "pending"], + "default": "active" + }, + "verified": { + "type": "boolean", + "description": "Whether user is verified", + "default": true + } + } + }); + + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); + + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Please provide values (all have defaults)".into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult { + content: vec![Content::text(format!( + "Elicitation completed: action={}, content={:?}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }, + result.content + ))], + structured_content: None, + is_error: None, + meta: None, + }), + Err(e) => Ok(CallToolResult { + content: vec![Content::text(format!("Elicitation error: {}", e))], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } + + "test_elicitation_sep1330_enums" => { + let schema_json = json!({ + "type": "object", + "properties": { + "untitledSingle": { + "type": "string", + "enum": ["option1", "option2", "option3"] + }, + "titledSingle": { + "type": "string", + "oneOf": [ + { "const": "value1", "title": "First Option" }, + { "const": "value2", "title": "Second Option" }, + { "const": "value3", "title": "Third Option" } + ] + }, + "legacyEnum": { + "type": "string", + "enum": ["opt1", "opt2", "opt3"], + "enumNames": ["Option One", "Option Two", "Option Three"] + }, + "untitledMulti": { + "type": "array", + "items": { + "type": "string", + "enum": ["option1", "option2", "option3"] + } + }, + "titledMulti": { + "type": "array", + "items": { + "anyOf": [ + { "const": "value1", "title": "First Choice" }, + { "const": "value2", "title": "Second Choice" }, + { "const": "value3", "title": "Third Choice" } + ] + } + } + } + }); + + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); + + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Test enum schema improvements".into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult { + content: vec![Content::text(format!( + "Enum elicitation completed: action={}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + } + ))], + structured_content: None, + is_error: None, + meta: None, + }), + Err(e) => Ok(CallToolResult { + content: vec![Content::text(format!("Elicitation error: {}", e))], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } + + "json_schema_2020_12_tool" => { + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world"); + Ok(CallToolResult { + content: vec![Content::text(format!("Hello, {}!", name))], + structured_content: None, + is_error: None, + meta: None, + }) + } + + "test_reconnection" => { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + Ok(CallToolResult { + content: vec![Content::text("Reconnection test completed")], + structured_content: None, + is_error: None, + meta: None, + }) + } + + _ => Err(ErrorData::invalid_params( + format!("Unknown tool: {}", request.name), + None, + )), + } + } + } + + fn list_resources( + &self, + _request: Option, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + Ok(ListResourcesResult { + meta: None, + resources: vec![ + RawResource { + uri: "test://static-text".into(), + name: "Static Text Resource".into(), + title: None, + description: Some("A static text resource for testing".into()), + mime_type: Some("text/plain".into()), + size: None, + icons: None, + meta: None, + } + .no_annotation(), + RawResource { + uri: "test://static-binary".into(), + name: "Static Binary Resource".into(), + title: None, + description: Some("A static binary/blob resource for testing".into()), + mime_type: Some("image/png".into()), + size: None, + icons: None, + meta: None, + } + .no_annotation(), + ], + next_cursor: None, + }) + } + } + + fn read_resource( + &self, + request: ReadResourceRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let uri = request.uri.as_str(); + match uri { + "test://static-text" => Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("text/plain".into()), + text: "This is the content of the static text resource.".into(), + meta: None, + }], + }), + "test://static-binary" => Ok(ReadResourceResult { + contents: vec![ResourceContents::BlobResourceContents { + uri: uri.into(), + mime_type: Some("image/png".into()), + blob: TEST_IMAGE_DATA.into(), + meta: None, + }], + }), + _ => { + // Check if it matches template: test://template/{id}/data + if uri.starts_with("test://template/") && uri.ends_with("/data") { + let id = uri + .strip_prefix("test://template/") + .and_then(|s| s.strip_suffix("/data")) + .unwrap_or("unknown"); + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("application/json".into()), + text: format!( + r#"{{"id":"{}","templateTest":true,"data":"Data for ID: {}"}}"#, + id, id + ), + meta: None, + }], + }) + } else { + Err(ErrorData::resource_not_found( + format!("Resource not found: {}", uri), + None, + )) + } + } + } + } + } + + fn list_resource_templates( + &self, + _request: Option, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + Ok(ListResourceTemplatesResult { + meta: None, + resource_templates: vec![ + RawResourceTemplate { + uri_template: "test://template/{id}/data".into(), + name: "Dynamic Resource".into(), + title: None, + description: Some("A dynamic resource with parameter substitution".into()), + mime_type: Some("application/json".into()), + icons: None, + } + .no_annotation(), + ], + next_cursor: None, + }) + } + } + + fn subscribe( + &self, + request: SubscribeRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let mut subs = self.subscriptions.lock().await; + subs.insert(request.uri.to_string()); + Ok(()) + } + } + + fn unsubscribe( + &self, + request: UnsubscribeRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let mut subs = self.subscriptions.lock().await; + subs.remove(request.uri.as_str()); + Ok(()) + } + } + + fn list_prompts( + &self, + _request: Option, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + Ok(ListPromptsResult { + meta: None, + prompts: vec![ + Prompt::new( + "test_simple_prompt", + Some("A simple test prompt with no arguments"), + None, + ), + Prompt::new( + "test_prompt_with_arguments", + Some("A test prompt that accepts arguments"), + Some(vec![ + PromptArgument { + name: "name".into(), + title: None, + description: Some("The name to greet".into()), + required: Some(true), + }, + PromptArgument { + name: "style".into(), + title: None, + description: Some("The greeting style".into()), + required: Some(false), + }, + ]), + ), + Prompt::new( + "test_prompt_with_embedded_resource", + Some("A test prompt that includes an embedded resource"), + None, + ), + Prompt::new( + "test_prompt_with_image", + Some("A test prompt that includes an image"), + None, + ), + ], + next_cursor: None, + }) + } + } + + fn get_prompt( + &self, + request: GetPromptRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + match request.name.as_str() { + "test_simple_prompt" => Ok(GetPromptResult { + description: Some("A simple test prompt".into()), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + "This is a simple test prompt.", + )], + }), + "test_prompt_with_arguments" => { + let args = request.arguments.unwrap_or_default(); + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("World"); + let style = args + .get("style") + .and_then(|v| v.as_str()) + .unwrap_or("friendly"); + Ok(GetPromptResult { + description: Some("A prompt with arguments".into()), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!("Please greet {} in a {} style.", name, style), + )], + }) + } + "test_prompt_with_embedded_resource" => Ok(GetPromptResult { + description: Some("A prompt with an embedded resource".into()), + messages: vec![ + PromptMessage::new_text(PromptMessageRole::User, "Here is a resource:"), + PromptMessage::new_resource( + PromptMessageRole::User, + "test://static-text".into(), + Some("text/plain".into()), + Some("Resource content for prompt".into()), + None, + None, + None, + ), + ], + }), + "test_prompt_with_image" => { + let image_content = RawImageContent { + data: TEST_IMAGE_DATA.into(), + mime_type: "image/png".into(), + meta: None, + }; + Ok(GetPromptResult { + description: Some("A prompt with an image".into()), + messages: vec![ + PromptMessage::new_text(PromptMessageRole::User, "Here is an image:"), + PromptMessage { + role: PromptMessageRole::User, + content: PromptMessageContent::Image { + image: image_content.no_annotation(), + }, + }, + ], + }) + } + _ => Err(ErrorData::invalid_params( + format!("Unknown prompt: {}", request.name), + None, + )), + } + } + } + + fn complete( + &self, + request: CompleteRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let values = match &request.r#ref { + Reference::Resource(_) => { + if request.argument.name == "id" { + vec!["1".into(), "2".into(), "3".into()] + } else { + vec![] + } + } + Reference::Prompt(prompt_ref) => { + if request.argument.name == "name" { + vec!["Alice".into(), "Bob".into(), "Charlie".into()] + } else if request.argument.name == "style" { + vec!["friendly".into(), "formal".into(), "casual".into()] + } else { + vec![prompt_ref.name.clone()] + } + } + }; + Ok(CompleteResult { + completion: CompletionInfo::new(values) + .map_err(|e| ErrorData::internal_error(e, None))?, + }) + } + } + + fn set_level( + &self, + request: SetLevelRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let mut level = self.log_level.lock().await; + *level = request.level; + Ok(()) + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .init(); + + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8001); + + let bind_addr = format!("127.0.0.1:{}", port); + tracing::info!("Starting conformance server on {}", bind_addr); + + let server = ConformanceServer::new(); + let config = StreamableHttpServerConfig { + stateful_mode: true, + ..Default::default() + }; + let service = StreamableHttpService::new( + move || Ok(server.clone()), + LocalSessionManager::default().into(), + config, + ); + + let router = axum::Router::new().nest_service("/mcp", service); + + let listener = tokio::net::TcpListener::bind(&bind_addr).await?; + tracing::info!("Conformance server listening on http://{}/mcp", bind_addr); + axum::serve(listener, router).await?; + + Ok(()) +} diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index a72301b09..b358f5233 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -153,8 +153,7 @@ impl ProtocolVersion { pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); - // Keep LATEST at 2025-03-26 until full 2025-06-18 compliance and automated testing are in place. - pub const LATEST: Self = Self::V_2025_03_26; + pub const LATEST: Self = Self::V_2025_06_18; /// All protocol versions known to this SDK. pub const KNOWN_VERSIONS: &[Self] = diff --git a/crates/rmcp/src/transport/common/client_side_sse.rs b/crates/rmcp/src/transport/common/client_side_sse.rs index 4e01994fa..b826b12d4 100644 --- a/crates/rmcp/src/transport/common/client_side_sse.rs +++ b/crates/rmcp/src/transport/common/client_side_sse.rs @@ -255,8 +255,23 @@ where } } None => { - tracing::debug!("sse stream terminated"); - return Poll::Ready(None); + // Per SEP-1699, a graceful stream close is + // reconnectable. If the server sent a `retry` field + // we MUST wait that long before reconnecting. + let interval = this + .server_retry_interval + .take() + .or_else(|| this.retry_policy.retry(0)); + if let Some(interval) = interval { + tracing::debug!(?interval, "sse stream ended gracefully, reconnecting"); + SseAutoReconnectStreamState::WaitingNextRetry { + sleep: tokio::time::sleep(interval), + retry_times: 0, + } + } else { + tracing::debug!("sse stream terminated, no reconnect policy"); + return Poll::Ready(None); + } } } } diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 5a39b4a4a..ae70f72fa 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -197,8 +197,18 @@ impl StreamableHttpClient for reqwest::Client { Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) } Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { - let message: ServerJsonRpcMessage = response.json().await?; - Ok(StreamableHttpPostResponse::Json(message, session_id)) + // Try to parse as a valid JSON-RPC message. If the body is + // malformed (e.g. a 200 response to a notification that lacks + // an `id` field), treat it as accepted rather than failing. + match response.json::().await { + Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), + Err(e) => { + tracing::warn!( + "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" + ); + Ok(StreamableHttpPostResponse::Accepted) + } + } } _ => { // unexpected content type diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 1c388e503..779dfe1c5 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -154,14 +154,16 @@ impl StreamableHttpPostResponse { } } - pub fn expect_accepted(self) -> Result<(), StreamableHttpError> + pub fn expect_accepted_or_json(self) -> Result<(), StreamableHttpError> where E: std::error::Error + Send + Sync + 'static, { match self { Self::Accepted => Ok(()), + // Tolerate servers that return 200 with JSON for notifications + Self::Json(..) => Ok(()), got => Err(StreamableHttpError::UnexpectedServerResponse( - format!("expect accepted, got {got:?}").into(), + format!("expect accepted or json, got {got:?}").into(), )), } } @@ -410,7 +412,7 @@ impl Worker for StreamableHttpClientWorker { .map_err(WorkerQuitReason::fatal_context( "send initialized notification", ))? - .expect_accepted::() + .expect_accepted_or_json::() .map_err(WorkerQuitReason::fatal_context( "process initialized notification response", ))?; From e83665f583ea3c1089efc403ebb5cfcb20747b58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:44:00 -0500 Subject: [PATCH 062/333] chore(deps): bump actions/checkout from 4 to 6 (#696) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1cb1d2de2..145aa43c8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,7 +21,7 @@ jobs: language: [rust, javascript-typescript, python, actions] steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v3 From 4677a65291e0624e7c23f5e894d2d4a952d8d86c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:44:24 -0500 Subject: [PATCH 063/333] chore(deps): bump github/codeql-action from 3 to 4 (#695) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 145aa43c8..6182e8fd7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,13 +24,13 @@ jobs: uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 From d6703dad75f31465ea923919e71de478edcc13b4 Mon Sep 17 00:00:00 2001 From: Thiago Mendes Date: Fri, 27 Feb 2026 00:23:05 -0300 Subject: [PATCH 064/333] feat(streamable-http): add json_response option for stateless server mode (#683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(streamable-http): add json_response option for stateless server mode Adds `json_response: bool` field to `StreamableHttpServerConfig`. When true and `stateful_mode` is false, the server returns `Content-Type: application/json` directly instead of `text/event-stream`, eliminating SSE framing overhead for simple request-response patterns. This completes server-side JSON response support (client-side was added in #540) and contributes to the stateless server goals of SEP-1442 (#526). Backwards-compatible: `json_response: false` (default) preserves all existing SSE behaviour unchanged, and `stateful_mode: true` is unaffected. Benchmark evidence (50 VUs, 5min, 2 CPUs): - RPS: 770 → 1139 (+48%) - get_user_cart latency: 41ms → 0.76ms (-98%) - checkout latency: 41ms → 0.55ms (-99%) - Zero regressions, zero errors * fix(tower): add cancellation awareness and logging to JSON response path * fix(test): add missing Default to StreamableHttpServerConfig in concurrent streams test Made-with: Cursor --- crates/rmcp/Cargo.toml | 5 + .../transport/streamable_http_server/tower.rs | 63 +++++-- .../rmcp/tests/test_sse_concurrent_streams.rs | 1 + .../test_streamable_http_json_response.rs | 155 ++++++++++++++++++ 4 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_json_response.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index ea2a3f264..96c319dc4 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -210,6 +210,11 @@ name = "test_streamable_http_priming" required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] path = "tests/test_streamable_http_priming.rs" +[[test]] +name = "test_streamable_http_json_response" +required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] +path = "tests/test_streamable_http_json_response.rs" + [[test]] name = "test_custom_request" diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 0fbe98769..f6cffb0bb 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -38,6 +38,11 @@ pub struct StreamableHttpServerConfig { /// If true, the server will create a session for each request and keep it alive. /// When enabled, SSE priming events are sent to enable client reconnection. pub stateful_mode: bool, + /// When true and `stateful_mode` is false, the server returns + /// `Content-Type: application/json` directly instead of `text/event-stream`. + /// This eliminates SSE framing overhead for simple request-response tools, + /// allowed by the MCP Streamable HTTP spec (2025-06-18). + pub json_response: bool, /// Cancellation token for the Streamable HTTP server. /// /// When this token is cancelled, all active sessions are terminated and @@ -51,6 +56,7 @@ impl Default for StreamableHttpServerConfig { sse_keep_alive: Some(Duration::from_secs(15)), sse_retry: Some(Duration::from_secs(3)), stateful_mode: true, + json_response: false, cancellation_token: CancellationToken::new(), } } @@ -585,27 +591,56 @@ where match message { ClientJsonRpcMessage::Request(mut request) => { request.request.extensions_mut().insert(part); - let (transport, receiver) = + let (transport, mut receiver) = OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); let service = serve_directly(service, transport, None); tokio::spawn(async move { // on service created let _ = service.waiting().await; }); - // Stateless mode: no priming (no session to resume) - let stream = ReceiverStream::new(receiver).map(|message| { - tracing::info!(?message); - ServerSseMessage { - event_id: None, - message: Some(Arc::new(message)), - retry: None, + if self.config.json_response { + // JSON-direct mode: await the single response and return as + // application/json, eliminating SSE framing overhead. + // Allowed by MCP Streamable HTTP spec (2025-06-18). + let cancel = self.config.cancellation_token.child_token(); + match tokio::select! { + res = receiver.recv() => res, + _ = cancel.cancelled() => None, + } { + Some(message) => { + tracing::info!(?message); + let body = serde_json::to_vec(&message).map_err(|e| { + internal_error_response("serialize json response")(e) + })?; + Ok(Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response")) + } + None => Err(internal_error_response("empty response")( + std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "no response message received from handler", + ), + )), } - }); - Ok(sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )) + } else { + // SSE mode (default): original behaviour preserved unchanged + let stream = ReceiverStream::new(receiver).map(|message| { + tracing::info!(?message); + ServerSseMessage { + event_id: None, + message: Some(Arc::new(message)), + retry: None, + } + }); + Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )) + } } ClientJsonRpcMessage::Notification(_notification) => { // ignore diff --git a/crates/rmcp/tests/test_sse_concurrent_streams.rs b/crates/rmcp/tests/test_sse_concurrent_streams.rs index 9a7204ab9..b54ed5562 100644 --- a/crates/rmcp/tests/test_sse_concurrent_streams.rs +++ b/crates/rmcp/tests/test_sse_concurrent_streams.rs @@ -84,6 +84,7 @@ async fn start_test_server(ct: CancellationToken, trigger: Arc) -> Strin sse_keep_alive: Some(Duration::from_secs(15)), sse_retry: Some(Duration::from_secs(3)), cancellation_token: ct.child_token(), + ..Default::default() }, ); diff --git a/crates/rmcp/tests/test_streamable_http_json_response.rs b/crates/rmcp/tests/test_streamable_http_json_response.rs new file mode 100644 index 000000000..e5b3323a9 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -0,0 +1,155 @@ +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, +}; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#; + +async fn spawn_server( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + let base_url = format!("http://{addr}/mcp"); + (client, base_url, ct) +} + +#[tokio::test] +async fn stateless_json_response_returns_application_json() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_server(StreamableHttpServerConfig { + stateful_mode: false, + json_response: true, + sse_keep_alive: None, + cancellation_token: ct.child_token(), + ..Default::default() + }) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("application/json"), + "Expected application/json, got: {content_type}" + ); + + let body = response.text().await?; + let parsed: serde_json::Value = serde_json::from_str(&body)?; + assert_eq!(parsed["jsonrpc"], "2.0"); + assert_eq!(parsed["id"], 1); + assert!(parsed["result"].is_object(), "Expected result object"); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_server(StreamableHttpServerConfig { + stateful_mode: false, + json_response: false, + sse_keep_alive: None, + cancellation_token: ct.child_token(), + ..Default::default() + }) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("text/event-stream"), + "Expected text/event-stream, got: {content_type}" + ); + + let body = response.text().await?; + assert!( + body.contains("data:"), + "Expected SSE framing (data: prefix), got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn json_response_ignored_in_stateful_mode() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + // json_response: true has no effect when stateful_mode: true — server still uses SSE + let (client, url, ct) = spawn_server(StreamableHttpServerConfig { + stateful_mode: true, + json_response: true, + sse_keep_alive: None, + cancellation_token: ct.child_token(), + ..Default::default() + }) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("text/event-stream"), + "Stateful mode should always use SSE regardless of json_response, got: {content_type}" + ); + + ct.cancel(); + Ok(()) +} From 98653855efcb59307b7b0bb73cdc4a56fd0b244d Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 27 Feb 2026 11:35:18 -0500 Subject: [PATCH 065/333] feat: issue triage tooling (#698) * feat: issue triage tooling * fix: update triage-new-issues script Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- .github/workflows/triage.yml | 73 +++++++ scripts/triage-new-issues.sh | 400 +++++++++++++++++++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 .github/workflows/triage.yml create mode 100755 scripts/triage-new-issues.sh diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 000000000..f064cee2d --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,73 @@ +name: Auto Triage Issues + +on: + # Triage newly opened or reopened issues immediately + issues: + types: [opened, reopened] + + # Daily sweep to catch anything missed (e.g., label removals, edits) + schedule: + - cron: "0 9 * * *" # 9:00 UTC daily + + # Allow manual runs from the Actions tab + workflow_dispatch: + inputs: + issue_number: + description: "Triage a specific issue number (leave empty for all untriaged)" + required: false + type: string + dry_run: + description: "Dry-run mode (preview only, don't apply labels)" + required: false + type: boolean + default: false + +permissions: + issues: write + +jobs: + triage: + name: Triage Issues + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + TRIAGE_MODEL: ${{ vars.TRIAGE_MODEL || 'gpt-4o-mini' }} + + steps: + - uses: actions/checkout@v6 + + - name: Install jq + run: sudo apt-get install -y jq + + - name: Triage single issue (on issue event) + if: github.event_name == 'issues' + run: | + ./scripts/triage-new-issues.sh \ + --issue ${{ github.event.issue.number }} \ + --apply + + - name: Triage specific issue (manual dispatch) + if: >- + github.event_name == 'workflow_dispatch' + && github.event.inputs.issue_number != '' + run: | + ARGS=(--issue ${{ github.event.inputs.issue_number }}) + if [[ "${{ github.event.inputs.dry_run }}" != "true" ]]; then + ARGS+=(--apply) + fi + ./scripts/triage-new-issues.sh "${ARGS[@]}" + + - name: Triage all untriaged issues (schedule or manual sweep) + if: >- + github.event_name == 'schedule' + || (github.event_name == 'workflow_dispatch' + && github.event.inputs.issue_number == '') + run: | + ARGS=() + if [[ "${{ github.event.inputs.dry_run }}" != "true" ]]; then + ARGS+=(--apply) + fi + ./scripts/triage-new-issues.sh "${ARGS[@]}" diff --git a/scripts/triage-new-issues.sh b/scripts/triage-new-issues.sh new file mode 100755 index 000000000..6f2f3ca38 --- /dev/null +++ b/scripts/triage-new-issues.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# ============================================================================= +# triage-new-issues.sh — Ongoing Issue Triage for modelcontextprotocol/rust-sdk +# +# Finds open issues that are missing required triage labels (type + priority) +# and uses an LLM to classify them automatically. +# +# Modes: +# Single issue: ./scripts/triage-new-issues.sh --issue 700 +# All untriaged: ./scripts/triage-new-issues.sh +# Apply labels: ./scripts/triage-new-issues.sh --apply +# Both: ./scripts/triage-new-issues.sh --issue 700 --apply +# +# Environment: +# OPENAI_API_KEY — Required. API key for the LLM (OpenAI-compatible endpoint) +# OPENAI_BASE_URL — Optional. Override the API base URL (default: https://api.openai.com/v1) +# TRIAGE_MODEL — Optional. Model to use (default: gpt-4o-mini) +# GITHUB_TOKEN — Optional. Used by `gh` CLI for GitHub API access +# +# ============================================================================= +set -euo pipefail + +REPO="modelcontextprotocol/rust-sdk" +DRY_RUN=true +SINGLE_ISSUE="" +MODEL="${TRIAGE_MODEL:-gpt-4o-mini}" +BASE_URL="${OPENAI_BASE_URL:-https://api.openai.com/v1}" +TRIAGED=0 +SKIPPED=0 +FAILED=0 + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --apply) DRY_RUN=false; shift ;; + --issue) SINGLE_ISSUE="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; + --help|-h) + echo "Usage: $0 [--apply] [--issue NUMBER] [--model MODEL]" + echo "" + echo " --apply Apply labels to GitHub (default: dry-run)" + echo " --issue NUM Triage a single issue by number" + echo " --model MODEL LLM model to use (default: gpt-4o-mini)" + echo "" + echo "Environment:" + echo " OPENAI_API_KEY Required. API key for the LLM" + echo " OPENAI_BASE_URL Optional. API base URL" + echo " TRIAGE_MODEL Optional. Model override" + exit 0 + ;; + *) echo "Unknown argument: $1"; exit 1 ;; + esac +done + +# --------------------------------------------------------------------------- +# Preflight checks +# --------------------------------------------------------------------------- +if ! command -v gh &>/dev/null; then + echo "Error: 'gh' CLI is required. Install from https://cli.github.com/" + exit 1 +fi + +if ! command -v jq &>/dev/null; then + echo "Error: 'jq' is required. Install with: brew install jq" + exit 1 +fi + +if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo "Error: OPENAI_API_KEY environment variable is required." + echo "Set it to an OpenAI API key, or set OPENAI_BASE_URL for a compatible endpoint." + exit 1 +fi + +echo "=============================================" +echo " rust-sdk Ongoing Issue Triage" +echo " Repo: $REPO" +echo " Model: $MODEL" +if $DRY_RUN; then + echo " Mode: DRY-RUN (pass --apply to execute)" +else + echo " Mode: APPLYING CHANGES" +fi +echo "=============================================" +echo "" + +# --------------------------------------------------------------------------- +# Label definitions — used to build the LLM prompt +# --------------------------------------------------------------------------- +TYPE_LABELS='["bug", "enhancement", "question"]' +PRIORITY_LABELS='["P0", "P1", "P2", "P3"]' +WORKFLOW_LABELS='["needs confirmation", "needs repro", "ready for work"]' +COMPONENT_LABELS='["T-core", "T-transport", "T-macros", "T-handler", "T-model", "T-security", "T-documentation", "T-examples", "T-service", "T-test", "T-CI", "T-config", "T-dependencies"]' + +# --------------------------------------------------------------------------- +# Build the system prompt for the LLM +# --------------------------------------------------------------------------- +read -r -d '' SYSTEM_PROMPT << 'SYSTEM_EOF' || true +You are an issue triage bot for the modelcontextprotocol/rust-sdk repository — a Rust implementation of the Model Context Protocol (MCP). + +Your job is to classify GitHub issues by assigning labels. You MUST return valid JSON with exactly these fields: + +{ + "type": "", + "priority": "", + "components": [""], + "workflow": "", + "reasoning": "" +} + +## Label Definitions + +### Type +- bug: Something is not working (errors, crashes, incorrect behavior) +- enhancement: New feature or improvement request +- question: User asking for help or clarification + +### Priority +- P0: Critical — blocking, security vulnerability, data loss, or crash affecting all users +- P1: High — MCP spec violation, conformance blocker, or significant functionality broken +- P2: Medium — important but non-blocking improvement, interop issue, or DX gap +- P3: Low — nice-to-have, exploratory, long-term, or questions + +### Components (prefix: T-) +- T-core: Core library (rmcp crate internals, JSON-RPC, error handling) +- T-transport: Transport layer (stdio, SSE, streamable HTTP) +- T-macros: Proc macros (#[tool], #[prompt], etc.) +- T-handler: Handler/service implementation +- T-model: Model/data structures and JSON-RPC types +- T-security: OAuth, auth, security features +- T-documentation: Documentation and guides +- T-examples: Example code +- T-service: Service layer +- T-test: Testing +- T-CI: CI/CD workflows +- T-config: Configuration +- T-dependencies: Dependency updates + +### Workflow +- "needs confirmation": Bug report that needs verification from a maintainer +- "needs repro": Bug report without a minimal reproduction case +- "ready for work": Issue is well-scoped and ready for a contributor to pick up +- null: None of the above apply + +## Rules +1. Every issue MUST get exactly one type and one priority. +2. Assign 0-2 component labels (only if clearly relevant). +3. Assign a workflow label only when appropriate; default to null. +4. When in doubt between two priorities, pick the higher one. +5. Security issues are always P0. +6. MCP spec violations are P1. +7. Questions from users are typically P3. +8. Return ONLY the JSON object, no markdown fences, no extra text. +SYSTEM_EOF + +# --------------------------------------------------------------------------- +# classify_issue — call the LLM to classify a single issue +# --------------------------------------------------------------------------- +classify_issue() { + local title="$1" + local body="$2" + local number="$3" + local existing_labels="$4" + + # Truncate body to ~3000 chars to stay within token limits + local truncated_body + truncated_body="$(echo "$body" | head -c 3000)" + + local user_prompt="Classify this GitHub issue. + +Issue #${number}: ${title} + +Existing labels: ${existing_labels} + +Body: +${truncated_body}" + + # Build the JSON payload + local payload + payload=$(jq -n \ + --arg model "$MODEL" \ + --arg system "$SYSTEM_PROMPT" \ + --arg user "$user_prompt" \ + '{ + model: $model, + temperature: 0.1, + messages: [ + { role: "system", content: $system }, + { role: "user", content: $user } + ] + }') + + # Call the LLM + local response + response=$(curl -s -w "\n%{http_code}" \ + "${BASE_URL}/chat/completions" \ + -H "Authorization: Bearer ${OPENAI_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$payload" 2>/dev/null) + + local http_code + http_code=$(echo "$response" | tail -1) + local body_response + body_response=$(echo "$response" | sed '$d') + + if [[ "$http_code" != "200" ]]; then + echo "ERROR: LLM API returned HTTP $http_code" >&2 + echo "$body_response" | jq -r '.error.message // .' >&2 2>/dev/null || echo "$body_response" >&2 + return 1 + fi + + # Extract the content from the response + local content + content=$(echo "$body_response" | jq -r '.choices[0].message.content' 2>/dev/null) + + if [[ -z "$content" || "$content" == "null" ]]; then + echo "ERROR: Empty response from LLM" >&2 + return 1 + fi + + # Strip markdown fences if present + content=$(echo "$content" | sed 's/^```json//; s/^```//; s/```$//' | tr -d '\n') + + # Validate it's valid JSON with required fields + if ! echo "$content" | jq -e '.type and .priority' &>/dev/null; then + echo "ERROR: LLM returned invalid classification: $content" >&2 + return 1 + fi + + echo "$content" +} + +# --------------------------------------------------------------------------- +# apply_labels — apply the classification labels to an issue +# --------------------------------------------------------------------------- +apply_labels() { + local issue_num="$1" + local classification="$2" + + local type_label priority_label workflow_label reasoning + type_label=$(echo "$classification" | jq -r '.type') + priority_label=$(echo "$classification" | jq -r '.priority') + workflow_label=$(echo "$classification" | jq -r '.workflow // empty') + reasoning=$(echo "$classification" | jq -r '.reasoning // "No reasoning provided"') + + # Collect component labels + local components + components=$(echo "$classification" | jq -r '.components[]? // empty' 2>/dev/null) + + # Build label list + local labels=("$type_label" "$priority_label") + if [[ -n "$workflow_label" && "$workflow_label" != "null" ]]; then + labels+=("$workflow_label") + fi + while IFS= read -r comp; do + [[ -n "$comp" ]] && labels+=("$comp") + done <<< "$components" + + # Build gh command + local cmd_args=(gh issue edit "$issue_num" --repo "$REPO") + for label in "${labels[@]}"; do + cmd_args+=(--add-label "$label") + done + + echo " Labels: ${labels[*]}" + echo " Reasoning: $reasoning" + + if $DRY_RUN; then + echo " [DRY-RUN] ${cmd_args[*]}" + else + echo " [APPLY] Labeling #$issue_num..." + if "${cmd_args[@]}" 2>/dev/null; then + echo " ✅ Done" + else + echo " ❌ Failed to apply labels" + return 1 + fi + fi +} + +# --------------------------------------------------------------------------- +# has_triage_labels — check if an issue already has type + priority labels +# --------------------------------------------------------------------------- +has_triage_labels() { + local labels_json="$1" + + local has_type has_priority + has_type=$(echo "$labels_json" | jq '[.[] | select(. == "bug" or . == "enhancement" or . == "question")] | length') + has_priority=$(echo "$labels_json" | jq '[.[] | select(test("^P[0-3]$"))] | length') + + [[ "$has_type" -gt 0 && "$has_priority" -gt 0 ]] +} + +# --------------------------------------------------------------------------- +# triage_issue — fetch, classify, and label a single issue +# --------------------------------------------------------------------------- +triage_issue() { + local issue_num="$1" + + # Fetch issue details + local issue_json + issue_json=$(gh issue view "$issue_num" --repo "$REPO" --json title,body,labels 2>/dev/null) + + if [[ -z "$issue_json" ]]; then + echo " ❌ Could not fetch issue #$issue_num" + FAILED=$((FAILED + 1)) + return 1 + fi + + local title body labels_json labels_str + title=$(echo "$issue_json" | jq -r '.title') + body=$(echo "$issue_json" | jq -r '.body // ""') + labels_json=$(echo "$issue_json" | jq '[.labels[].name]') + labels_str=$(echo "$labels_json" | jq -r 'join(", ")') + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Issue #$issue_num: $title" + echo " Current labels: ${labels_str:-none}" + + # Check if already triaged + if has_triage_labels "$labels_json"; then + echo " ⏭️ Already triaged (has type + priority). Skipping." + SKIPPED=$((SKIPPED + 1)) + return 0 + fi + + # Classify with LLM + echo " 🤖 Classifying with $MODEL..." + local classification + if ! classification=$(classify_issue "$title" "$body" "$issue_num" "$labels_str"); then + echo " ❌ Classification failed" + FAILED=$((FAILED + 1)) + return 1 + fi + + # Apply labels + if apply_labels "$issue_num" "$classification"; then + TRIAGED=$((TRIAGED + 1)) + else + FAILED=$((FAILED + 1)) + fi +} + +# --------------------------------------------------------------------------- +# Main: single issue or scan all untriaged +# --------------------------------------------------------------------------- +if [[ -n "$SINGLE_ISSUE" ]]; then + echo "--- Triaging single issue #$SINGLE_ISSUE ---" + echo "" + triage_issue "$SINGLE_ISSUE" +else + echo "--- Scanning for untriaged open issues ---" + echo "" + + # Fetch all open issues (paginated, up to 500) + issue_numbers=$(gh issue list --repo "$REPO" --state open --limit 500 --json number,labels \ + | jq -r '.[] | select( + ([.labels[].name | select(. == "bug" or . == "enhancement" or . == "question")] | length) == 0 + or + ([.labels[].name | select(startswith("P"))] | length) == 0 + ) | .number') + + if [[ -z "$issue_numbers" ]]; then + echo "✅ All open issues are already triaged! Nothing to do." + exit 0 + fi + + count=$(echo "$issue_numbers" | wc -l | tr -d ' ') + echo "Found $count untriaged issue(s)." + echo "" + + while IFS= read -r num; do + [[ -z "$num" ]] && continue + triage_issue "$num" + echo "" + # Rate-limit: small delay between LLM calls + sleep 1 + done <<< "$issue_numbers" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "=============================================" +echo " Triage Summary" +echo "" +echo " Triaged: $TRIAGED" +echo " Skipped: $SKIPPED (already triaged)" +echo " Failed: $FAILED" +echo "" +if $DRY_RUN; then + echo " This was a DRY RUN. To apply changes:" + echo " $0 --apply" +fi +echo "=============================================" + +# Exit with error if any failures +[[ "$FAILED" -eq 0 ]] || exit 1 From e68b15e600f0d861e8669d9ca2cf562655960851 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:33:53 -0500 Subject: [PATCH 066/333] docs: add prose documentation for core features to meet conformance (#702) * docs: add prose documentation for core features to meet conformance * docs: remove static coverage badge and svg * docs: rewrite Chinese README to match current English README --- README.md | 16 +- crates/rmcp/README.md | 4 +- docs/FEATURES.md | 758 ++++++++++++++++++++++++++++++++++++ docs/coverage.svg | 1 - docs/readme/README.zh-cn.md | 80 ++-- 5 files changed, 830 insertions(+), 29 deletions(-) create mode 100644 docs/FEATURES.md delete mode 100644 docs/coverage.svg diff --git a/README.md b/README.md index 892d55655..b2d17c084 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # RMCP [![Crates.io Version](https://img.shields.io/crates/v/rmcp)](https://crates.io/crates/rmcp) - - -![Coverage](docs/coverage.svg) +[![docs.rs](https://img.shields.io/docsrs/rmcp)](https://docs.rs/rmcp/latest/rmcp) +[![CI](https://github.com/modelcontextprotocol/rust-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/modelcontextprotocol/rust-sdk/actions/workflows/ci.yml) +[![License](https://img.shields.io/crates/l/rmcp)](LICENSE) An official Rust Model Context Protocol SDK implementation with tokio async runtime. @@ -20,7 +20,7 @@ This repository contains the following crates: ### Import the crate ```toml -rmcp = { version = "0.8.0", features = ["server"] } +rmcp = { version = "0.16.0", features = ["server"] } ## or dev channel rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main" } ``` @@ -111,6 +111,10 @@ let quit_reason = server.cancel().await?; See [examples](examples/README.md). +## Feature Documentation + +See [docs/FEATURES.md](docs/FEATURES.md) for detailed documentation on core MCP features: resources, prompts, sampling, roots, logging, completions, notifications, and subscriptions. + ## OAuth Support See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. @@ -129,6 +133,8 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. ### Built with `rmcp` +- [goose](https://github.com/block/goose) - An open-source, extensible AI agent that goes beyond code suggestions +- [apollo-mcp-server](https://github.com/apollographql/apollo-mcp-server) - MCP server that connects AI agents to GraphQL APIs via Apollo GraphOS - [rustfs-mcp](https://github.com/rustfs/rustfs/tree/main/crates/mcp) - High-performance MCP server providing S3-compatible object storage operations for AI/LLM integration - [containerd-mcp-server](https://github.com/jokemanfire/mcp-containerd) - A containerd-based MCP server implementation - [rmcp-openapi-server](https://gitlab.com/lx-industries/rmcp-openapi/-/tree/main/crates/rmcp-openapi-server) - High-performance MCP server that exposes OpenAPI definition endpoints as MCP tools diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index 60f5e02d4..217b22cd6 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -191,7 +191,9 @@ async fn main() -> Result<(), Box> { } ``` -For more examples, see the [examples directory](https://github.com/anthropics/mcp-rust-sdk/tree/main/examples) in the repository. +For more examples, see the [examples directory](https://github.com/modelcontextprotocol/rust-sdk/tree/main/examples) in the repository. + +For detailed documentation on core MCP features (resources, prompts, sampling, roots, logging, completions, notifications, subscriptions), see [FEATURES.md](https://github.com/modelcontextprotocol/rust-sdk/blob/main/docs/FEATURES.md). ## Transport Options diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 000000000..f27a152d6 --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,758 @@ +# RMCP Feature Documentation + +This document covers the core MCP features supported by `rmcp`, with server and client code examples for each. + +For the full MCP specification, see [modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2025-11-25). + +## Table of Contents + +- [Resources](#resources) +- [Prompts](#prompts) +- [Sampling](#sampling) +- [Roots](#roots) +- [Logging](#logging) +- [Completions](#completions) +- [Notifications](#notifications) +- [Subscriptions](#subscriptions) + +--- + +## Resources + +Resources let servers expose data (files, database records, API responses) that clients can read. Each resource is identified by a URI and returns content as text or binary (base64-encoded) data. Resource templates allow servers to declare URI patterns with dynamic parameters. + +**MCP Spec:** [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) + +### Server-side + +Implement `list_resources()`, `read_resource()`, and optionally `list_resource_templates()` on the `ServerHandler` trait. Enable the resources capability in `get_info()`. + +```rust +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + model::*, + service::RequestContext, + transport::stdio, +}; +use serde_json::json; + +#[derive(Clone)] +struct MyServer; + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_resources() + .build(), + ..Default::default() + } + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![ + RawResource::new("file:///config.json", "config").no_annotation(), + RawResource::new("memo://insights", "insights").no_annotation(), + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + match request.uri.as_str() { + "file:///config.json" => Ok(ReadResourceResult { + contents: vec![ResourceContents::text(r#"{"key": "value"}"#, &request.uri)], + }), + "memo://insights" => Ok(ReadResourceResult { + contents: vec![ResourceContents::text("Analysis results...", &request.uri)], + }), + _ => Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": request.uri })), + )), + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: vec![], + next_cursor: None, + meta: None, + }) + } +} +``` + +### Client-side + +```rust +use rmcp::model::{ReadResourceRequestParams}; + +// List all resources (handles pagination automatically) +let resources = client.list_all_resources().await?; + +// Read a specific resource by URI +let result = client.read_resource(ReadResourceRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; + +// List resource templates +let templates = client.list_all_resource_templates().await?; +``` + +### Notifications + +Servers can notify clients when the resource list changes or when a specific resource is updated: + +```rust +// Notify that the resource list has changed (clients should re-fetch) +context.peer.notify_resource_list_changed().await?; + +// Notify that a specific resource was updated +context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { + uri: "file:///config.json".into(), +}).await?; +``` + +Clients handle these via `ClientHandler`: + +```rust +impl ClientHandler for MyClient { + async fn on_resource_list_changed( + &self, + _context: NotificationContext, + ) { + // Re-fetch the resource list + } + + async fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + _context: NotificationContext, + ) { + // Re-read the updated resource at params.uri + } +} +``` + +**Example:** [`examples/servers/src/common/counter.rs`](../examples/servers/src/common/counter.rs) (server), [`examples/clients/src/everything_stdio.rs`](../examples/clients/src/everything_stdio.rs) (client) + +--- + +## Prompts + +Prompts are reusable message templates that servers expose to clients. They accept typed arguments and return conversation messages. The `#[prompt]` macro handles argument validation and routing automatically. + +**MCP Spec:** [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) + +### Server-side + +Use the `#[prompt_router]`, `#[prompt]`, and `#[prompt_handler]` macros to define prompts declaratively. Arguments are defined as structs deriving `JsonSchema`. + +```rust +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, + model::*, + prompt, prompt_handler, prompt_router, + schemars::JsonSchema, + service::RequestContext, + transport::stdio, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CodeReviewArgs { + #[schemars(description = "Programming language of the code")] + pub language: String, + #[schemars(description = "Focus areas for the review")] + pub focus_areas: Option>, +} + +#[derive(Clone)] +pub struct MyServer { + prompt_router: PromptRouter, +} + +#[prompt_router] +impl MyServer { + fn new() -> Self { + Self { prompt_router: Self::prompt_router() } + } + + /// Simple prompt without parameters + #[prompt(name = "greeting", description = "A simple greeting")] + async fn greeting(&self) -> Vec { + vec![PromptMessage::new_text( + PromptMessageRole::User, + "Hello! How can you help me today?", + )] + } + + /// Prompt with typed arguments + #[prompt(name = "code_review", description = "Review code in a given language")] + async fn code_review( + &self, + Parameters(args): Parameters, + ) -> Result { + let focus = args.focus_areas + .unwrap_or_else(|| vec!["correctness".into()]); + + Ok(GetPromptResult { + description: Some(format!("Code review for {}", args.language)), + messages: vec![ + PromptMessage::new_text( + PromptMessageRole::User, + format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), + ), + ], + }) + } +} + +#[prompt_handler] +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_prompts().build(), + ..Default::default() + } + } +} +``` + +Prompt functions support several return types: +- `Vec` -- simple message list +- `GetPromptResult` -- messages with an optional description +- `Result` -- either of the above, with error handling + +### Client-side + +```rust +use rmcp::model::GetPromptRequestParams; + +// List all prompts +let prompts = client.list_all_prompts().await?; + +// Get a prompt with arguments +let result = client.get_prompt(GetPromptRequestParams { + meta: None, + name: "code_review".into(), + arguments: Some(rmcp::object!({ + "language": "Rust", + "focus_areas": ["performance", "safety"] + })), +}).await?; +``` + +### Notifications + +```rust +// Server: notify that available prompts have changed +context.peer.notify_prompt_list_changed().await?; +``` + +**Example:** [`examples/servers/src/prompt_stdio.rs`](../examples/servers/src/prompt_stdio.rs) (server), [`examples/clients/src/everything_stdio.rs`](../examples/clients/src/everything_stdio.rs) (client) + +--- + +## Sampling + +Sampling flips the usual direction: the server asks the client to run an LLM completion. The server sends a `create_message` request, the client processes it through its LLM, and returns the result. + +**MCP Spec:** [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) + +### Server-side (requesting sampling) + +Access the client's sampling capability through `context.peer.create_message()`: + +```rust +use rmcp::model::*; + +// Inside a ServerHandler method (e.g., call_tool): +let response = context.peer.create_message(CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::user_text("Explain this error: connection refused")], + model_preferences: Some(ModelPreferences { + hints: Some(vec![ModelHint { name: Some("claude".into()) }]), + cost_priority: Some(0.3), + speed_priority: Some(0.8), + intelligence_priority: Some(0.7), + }), + system_prompt: Some("You are a helpful assistant.".into()), + include_context: Some(ContextInclusion::None), + temperature: Some(0.7), + max_tokens: 150, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, +}).await?; + +// Extract the response text +let text = response.message.content + .first() + .and_then(|c| c.as_text()) + .map(|t| &t.text); +``` + +### Client-side (handling sampling) + +On the client side, implement `ClientHandler::create_message()`. This is where you'd call your actual LLM: + +```rust +use rmcp::{ClientHandler, model::*, service::{RequestContext, RoleClient}}; + +#[derive(Clone, Default)] +struct MyClient; + +impl ClientHandler for MyClient { + async fn create_message( + &self, + params: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + // Forward to your LLM, or return a mock response: + let response_text = call_your_llm(¶ms.messages).await; + + Ok(CreateMessageResult { + message: SamplingMessage::assistant_text(response_text), + model: "my-model".into(), + stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.into()), + }) + } +} +``` + +**Example:** [`examples/servers/src/sampling_stdio.rs`](../examples/servers/src/sampling_stdio.rs) (server), [`examples/clients/src/sampling_stdio.rs`](../examples/clients/src/sampling_stdio.rs) (client) + +--- + +## Roots + +Roots tell servers which directories or projects the client is working in. A root is a URI (typically `file://`) pointing to a workspace or repository. Servers can query roots to know where to look for files and how to scope their work. + +**MCP Spec:** [Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) + +### Server-side + +Ask the client for its root list, and handle change notifications: + +```rust +use rmcp::{ServerHandler, model::*, service::{NotificationContext, RoleServer}}; + +impl ServerHandler for MyServer { + // Query the client for its roots + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let roots = context.peer.list_roots().await?; + // Use roots.roots to understand workspace boundaries + // ... + } + + // Called when the client's root list changes + async fn on_roots_list_changed( + &self, + _context: NotificationContext, + ) { + // Re-fetch roots to stay current + } +} +``` + +### Client-side + +Clients declare roots capability and implement `list_roots()`: + +```rust +use rmcp::{ClientHandler, model::*}; + +impl ClientHandler for MyClient { + async fn list_roots( + &self, + _context: RequestContext, + ) -> Result { + Ok(ListRootsResult { + roots: vec![ + Root { + uri: "file:///home/user/project".into(), + name: Some("My Project".into()), + }, + ], + }) + } +} +``` + +Clients notify the server when roots change: + +```rust +// After adding or removing a workspace root: +client.notify_roots_list_changed().await?; +``` + +--- + +## Logging + +Servers can send structured log messages to clients. The client sets a minimum severity level, and the server sends messages through the peer notification interface. + +**MCP Spec:** [Logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) + +### Server-side + +Enable the logging capability, handle level changes from the client, and send log messages via the peer: + +```rust +use rmcp::{ServerHandler, model::*, service::RequestContext}; + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_logging() + .build(), + ..Default::default() + } + } + + // Client sets the minimum log level + async fn set_level( + &self, + request: SetLevelRequestParams, + _context: RequestContext, + ) -> Result<(), ErrorData> { + // Store request.level and filter future log messages accordingly + Ok(()) + } +} + +// Send a log message from any handler with access to the peer: +context.peer.notify_logging_message(LoggingMessageNotificationParam { + level: LoggingLevel::Info, + logger: Some("my-server".into()), + data: serde_json::json!({ + "message": "Processing completed", + "items_processed": 42 + }), +}).await?; +``` + +Available log levels (from least to most severe): `Debug`, `Info`, `Notice`, `Warning`, `Error`, `Critical`, `Alert`, `Emergency`. + +### Client-side + +Clients handle incoming log messages via `ClientHandler`: + +```rust +impl ClientHandler for MyClient { + async fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + _context: NotificationContext, + ) { + println!("[{}] {}: {}", params.level, + params.logger.unwrap_or_default(), params.data); + } +} +``` + +Clients can also set the server's log level: + +```rust +client.set_level(SetLevelRequestParams { + level: LoggingLevel::Warning, + meta: None, +}).await?; +``` + +--- + +## Completions + +Completions give auto-completion suggestions for prompt or resource template arguments. As a user fills in arguments, the client can ask the server for suggestions based on what's already been entered. + +**MCP Spec:** [Completions](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) + +### Server-side + +Enable the completions capability and implement the `complete()` handler. Use `request.context` to inspect previously filled arguments: + +```rust +use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_completions() + .enable_prompts() + .build(), + ..Default::default() + } + } + + async fn complete( + &self, + request: CompleteRequestParams, + _context: RequestContext, + ) -> Result { + let values = match &request.r#ref { + Reference::Prompt(prompt_ref) if prompt_ref.name == "sql_query" => { + match request.argument.name.as_str() { + "operation" => vec!["SELECT", "INSERT", "UPDATE", "DELETE"], + "table" => vec!["users", "orders", "products"], + "columns" => { + // Adapt suggestions based on previously filled arguments + if let Some(ctx) = &request.context { + if let Some(op) = ctx.get_argument("operation") { + match op.to_uppercase().as_str() { + "SELECT" | "UPDATE" => { + vec!["id", "name", "email", "created_at"] + } + _ => vec![], + } + } else { vec![] } + } else { vec![] } + } + _ => vec![], + } + } + _ => vec![], + }; + + // Filter by the user's partial input + let filtered: Vec = values.into_iter() + .map(String::from) + .filter(|v| v.to_lowercase().contains(&request.argument.value.to_lowercase())) + .collect(); + + Ok(CompleteResult { + completion: CompletionInfo { + values: filtered, + total: None, + has_more: Some(false), + }, + }) + } +} +``` + +### Client-side + +```rust +use rmcp::model::*; + +let result = client.complete(CompleteRequestParams { + meta: None, + r#ref: Reference::Prompt(PromptReference { + name: "sql_query".into(), + }), + argument: ArgumentInfo { + name: "operation".into(), + value: "SEL".into(), + }, + context: None, +}).await?; + +// result.completion.values contains suggestions like ["SELECT"] +``` + +**Example:** [`examples/servers/src/completion_stdio.rs`](../examples/servers/src/completion_stdio.rs) + +--- + +## Notifications + +Notifications are fire-and-forget messages -- no response is expected. They cover progress updates, cancellation, and lifecycle events. Both sides can send and receive them. + +**MCP Spec:** [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/notifications) + +### Progress notifications + +Servers can report progress during long-running operations: + +```rust +use rmcp::model::*; + +// Inside a tool handler: +for i in 0..total_items { + process_item(i).await; + + context.peer.notify_progress(ProgressNotificationParam { + progress_token: ProgressToken(NumberOrString::Number(i as i64)), + progress: i as f64, + total: Some(total_items as f64), + message: Some(format!("Processing item {}/{}", i + 1, total_items)), + }).await?; +} +``` + +### Cancellation + +Either side can cancel an in-progress request: + +```rust +// Send a cancellation +context.peer.notify_cancelled(CancelledNotificationParam { + request_id: the_request_id, + reason: Some("User requested cancellation".into()), +}).await?; +``` + +Handle cancellation in `ServerHandler` or `ClientHandler`: + +```rust +impl ServerHandler for MyServer { + async fn on_cancelled( + &self, + params: CancelledNotificationParam, + _context: NotificationContext, + ) { + // Abort work for params.request_id + } +} +``` + +### Initialized notification + +Clients send `initialized` after the handshake completes: + +```rust +// Sent automatically by rmcp during the serve() handshake. +// Servers handle it via: +impl ServerHandler for MyServer { + async fn on_initialized( + &self, + _context: NotificationContext, + ) { + // Server is ready to receive requests + } +} +``` + +### List-changed notifications + +When available tools, prompts, or resources change, tell the client: + +```rust +context.peer.notify_tool_list_changed().await?; +context.peer.notify_prompt_list_changed().await?; +context.peer.notify_resource_list_changed().await?; +``` + +**Example:** [`examples/servers/src/common/progress_demo.rs`](../examples/servers/src/common/progress_demo.rs) + +--- + +## Subscriptions + +Clients can subscribe to specific resources. When a subscribed resource changes, the server sends a notification and the client can re-read it. + +**MCP Spec:** [Resources - Subscriptions](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions) + +### Server-side + +Enable subscriptions in the resources capability and implement the `subscribe()` / `unsubscribe()` handlers: + +```rust +use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; +use std::sync::Arc; +use tokio::sync::Mutex; +use std::collections::HashSet; + +#[derive(Clone)] +struct MyServer { + subscriptions: Arc>>, +} + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_resources() + .enable_resources_subscribe() + .build(), + ..Default::default() + } + } + + async fn subscribe( + &self, + request: SubscribeRequestParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.subscriptions.lock().await.insert(request.uri); + Ok(()) + } + + async fn unsubscribe( + &self, + request: UnsubscribeRequestParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.subscriptions.lock().await.remove(&request.uri); + Ok(()) + } +} +``` + +When a subscribed resource changes, notify the client: + +```rust +// Check if the resource has subscribers, then notify +context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { + uri: "file:///config.json".into(), +}).await?; +``` + +### Client-side + +```rust +use rmcp::model::*; + +// Subscribe to updates for a resource +client.subscribe(SubscribeRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; + +// Unsubscribe when no longer needed +client.unsubscribe(UnsubscribeRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; +``` + +Handle update notifications in `ClientHandler`: + +```rust +impl ClientHandler for MyClient { + async fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + _context: NotificationContext, + ) { + // Re-read the resource at params.uri + } +} +``` diff --git a/docs/coverage.svg b/docs/coverage.svg deleted file mode 100644 index 229bca9a6..000000000 --- a/docs/coverage.svg +++ /dev/null @@ -1 +0,0 @@ -Coverage: 53%Coverage53% \ No newline at end of file diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md index edd01ad60..f666928f2 100644 --- a/docs/readme/README.zh-cn.md +++ b/docs/readme/README.zh-cn.md @@ -1,32 +1,41 @@ + + # RMCP [![Crates.io Version](https://img.shields.io/crates/v/rmcp)](https://crates.io/crates/rmcp) -![Release status](https://github.commodelcontextprotocol/rust-sdk/actions/workflows/release.yml/badge.svg) [![docs.rs](https://img.shields.io/docsrs/rmcp)](https://docs.rs/rmcp/latest/rmcp) +[![CI](https://github.com/modelcontextprotocol/rust-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/modelcontextprotocol/rust-sdk/actions/workflows/ci.yml) +[![License](https://img.shields.io/crates/l/rmcp)](../../LICENSE) -一个基于 tokio 异步运行时的官方 Model Context Protocol SDK 实现。 +一个基于 tokio 异步运行时的官方 Rust Model Context Protocol SDK 实现。 -本项目使用了以下开源库: +本仓库包含以下 crate: -- [rmcp](crates/rmcp): 实现 RMCP 协议的核心库 (详见:[rmcp](crates/rmcp/README.md)) -- [rmcp-macros](crates/rmcp-macros): 一个用于生成 RMCP 工具实现的过程宏库。 (详见:[rmcp-macros](crates/rmcp-macros/README.md)) +- [rmcp](../../crates/rmcp):实现 RMCP 协议的核心库 - 详见 [rmcp](../../crates/rmcp/README.md) +- [rmcp-macros](../../crates/rmcp-macros):用于生成 RMCP 工具实现的过程宏库 - 详见 [rmcp-macros](../../crates/rmcp-macros/README.md) ## 使用 ### 导入 + ```toml -rmcp = { version = "0.2.0", features = ["server"] } +rmcp = { version = "0.16.0", features = ["server"] } ## 或使用最新开发版本 rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main" } ``` +### 第三方依赖 -### 第三方依赖库 -基本依赖: -- [tokio required](https://github.com/tokio-rs/tokio) -- [serde required](https://github.com/serde-rs/serde) +基本依赖: +- [tokio](https://github.com/tokio-rs/tokio) +- [serde](https://github.com/serde-rs/serde) +JSON Schema 生成 (version 2020-12): +- [schemars](https://github.com/GREsau/schemars) ### 构建客户端 +
-构建客户端 +启动客户端 ```rust, ignore use rmcp::{ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}}; @@ -57,7 +66,7 @@ let transport = (stdin(), stdout());
构建服务 -You can easily build a service by using [`ServerHandler`](crates/rmcp/src/handler/server.rs) or [`ClientHandler`](crates/rmcp/src/handler/client.rs). +你可以通过 [`ServerHandler`](../../crates/rmcp/src/handler/server.rs) 或 [`ClientHandler`](../../crates/rmcp/src/handler/client.rs) 轻松构建服务。 ```rust, ignore let service = common::counter::Counter::new(); @@ -68,7 +77,7 @@ let service = common::counter::Counter::new(); 启动服务端 ```rust, ignore -// this call will finish the initialization process +// 此调用将完成初始化过程 let server = service.serve(transport).await?; ```
@@ -76,13 +85,13 @@ let server = service.serve(transport).await?;
与服务端交互 -Once the server is initialized, you can send requests or notifications: +服务端初始化完成后,你可以发送请求或通知: ```rust, ignore -// request +// 请求 let roots = server.list_roots().await?; -// or send notification +// 或发送通知 server.notify_cancelled(...).await?; ```
@@ -97,27 +106,54 @@ let quit_reason = server.cancel().await?; ```
-### 示例 -查看 [examples](examples/README.md) + +## 示例 + +查看 [examples](../../examples/README.md)。 + +## 功能文档 + +查看 [docs/FEATURES.md](../FEATURES.md) 了解核心 MCP 功能的详细文档:资源、提示词、采样、根目录、日志、补全、通知和订阅。 ## OAuth 支持 -查看 [oauth_support](docs/OAUTH_SUPPORT.md) +查看 [OAuth 支持](../OAUTH_SUPPORT.md) 了解详情。 ## 相关资源 -- [MCP Specification](https://modelcontextprotocol.io/specification/2025-11-25) +- [MCP 规范](https://modelcontextprotocol.io/specification/2025-11-25) - [Schema](https://github.com/modelcontextprotocol/specification/blob/main/schema/2025-11-25/schema.ts) ## 相关项目 + +### 扩展 `rmcp` + +- [rmcp-actix-web](https://gitlab.com/lx-industries/rmcp-actix-web) - 基于 `actix_web` 的 `rmcp` 后端 +- [rmcp-openapi](https://gitlab.com/lx-industries/rmcp-openapi) - 将 OpenAPI 定义的端点转换为 MCP 工具 + +### 基于 `rmcp` 构建 + +- [goose](https://github.com/block/goose) - 一个超越代码建议的开源、可扩展 AI 智能体 +- [apollo-mcp-server](https://github.com/apollographql/apollo-mcp-server) - 通过 Apollo GraphOS 将 AI 智能体连接到 GraphQL API 的 MCP 服务 +- [rustfs-mcp](https://github.com/rustfs/rustfs/tree/main/crates/mcp) - 为 AI/LLM 集成提供 S3 兼容对象存储操作的高性能 MCP 服务 - [containerd-mcp-server](https://github.com/jokemanfire/mcp-containerd) - 基于 containerd 实现的 MCP 服务 +- [rmcp-openapi-server](https://gitlab.com/lx-industries/rmcp-openapi/-/tree/main/crates/rmcp-openapi-server) - 将 OpenAPI 定义的端点暴露为 MCP 工具的高性能 MCP 服务 +- [nvim-mcp](https://github.com/linw1995/nvim-mcp) - 与 Neovim 交互的 MCP 服务 +- [terminator](https://github.com/mediar-ai/terminator) - AI 驱动的桌面自动化 MCP 服务,支持跨平台,成功率超过 95% +- [stakpak-agent](https://github.com/stakpak/agent) - 安全加固的 DevOps 终端智能体,支持 MCP over mTLS、流式传输、密钥令牌化和异步任务管理 +- [video-transcriber-mcp-rs](https://github.com/nhatvu148/video-transcriber-mcp-rs) - 使用 whisper.cpp 从 1000+ 平台转录视频的高性能 MCP 服务 +- [NexusCore MCP](https://github.com/sjkim1127/Nexuscore_MCP) - 具有 Frida 集成和隐蔽脱壳功能的高级恶意软件分析与动态检测 MCP 服务 +- [spreadsheet-mcp](https://github.com/PSU3D0/spreadsheet-mcp) - 面向 LLM 智能体的高效 Token 使用的电子表格分析 MCP 服务,支持自动区域检测、重新计算、截图和编辑 +- [hyper-mcp](https://github.com/hyper-mcp-rs/hyper-mcp) - 通过 WebAssembly (WASM) 插件扩展功能的快速、安全的 MCP 服务 +- [rudof-mcp](https://github.com/rudof-project/rudof/tree/master/rudof_mcp) - RDF 验证和数据处理 MCP 服务,支持 ShEx/SHACL 验证、SPARQL 查询和格式转换。支持 stdio 和 Streamable HTTP 传输,具备完整的 MCP 功能(工具、提示词、资源、日志、补全、任务) + ## 开发 ### 贡献指南 -查看 [docs/CONTRIBUTE.MD](docs/CONTRIBUTE.MD) +查看 [docs/CONTRIBUTE.MD](../CONTRIBUTE.MD) 获取贡献提示。 ### 使用 Dev Container -如果你想使用 Dev Container,查看 [docs/DEVCONTAINER.md](docs/DEVCONTAINER.md) 获取开发指南。 +如果你想使用 Dev Container,查看 [docs/DEVCONTAINER.md](../DEVCONTAINER.md) 获取开发指南。 From 634852aaa0bfe33ef417b9d5d6f8e36a3c030c76 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:29:29 -0500 Subject: [PATCH 067/333] fix: prevent mcp-conformance from being published to crates.io (#701) --- conformance/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index a38e9e624..42a5e851f 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -2,6 +2,7 @@ name = "mcp-conformance" version = "0.1.0" edition = "2021" +publish = false [[bin]] name = "conformance-server" From 955186502dc2d8b5ff46592054f87295447fa22b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:32:46 -0500 Subject: [PATCH 068/333] chore: release (#697) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 10 ++++++++++ crates/rmcp/CHANGELOG.md | 24 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 551ac7bed..fae59e7c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "0.16.0", path = "./crates/rmcp" } -rmcp-macros = { version = "0.16.0", path = "./crates/rmcp-macros" } +rmcp = { version = "0.17.0", path = "./crates/rmcp" } +rmcp-macros = { version = "0.17.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "0.16.0" +version = "0.17.0" authors = ["4t145 "] license = "Apache-2.0" license-file = "LICENSE" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index a45fb54bc..7313439e7 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.17.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.16.0...rmcp-macros-v0.17.0) - 2026-02-27 + +### Added + +- add trait-based tool declaration ([#677](https://github.com/modelcontextprotocol/rust-sdk/pull/677)) + +### Other + +- add prose documentation for core features to meet conformance ([#702](https://github.com/modelcontextprotocol/rust-sdk/pull/702)) + ## [0.16.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.15.0...rmcp-macros-v0.16.0) - 2026-02-17 ### Fixed diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 40c96902a..0f4a3a400 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.17.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.16.0...rmcp-v0.17.0) - 2026-02-27 + +### Added + +- *(streamable-http)* add json_response option for stateless server mode ([#683](https://github.com/modelcontextprotocol/rust-sdk/pull/683)) +- mcp sdk conformance ([#687](https://github.com/modelcontextprotocol/rust-sdk/pull/687)) +- add default value support to string, number, and integer schemas ([#686](https://github.com/modelcontextprotocol/rust-sdk/pull/686)) +- add trait-based tool declaration ([#677](https://github.com/modelcontextprotocol/rust-sdk/pull/677)) +- send and validate MCP-Protocol-Version header ([#675](https://github.com/modelcontextprotocol/rust-sdk/pull/675)) + +### Fixed + +- improve error logging and remove token secret from logs ([#685](https://github.com/modelcontextprotocol/rust-sdk/pull/685)) +- refresh token expiry ([#680](https://github.com/modelcontextprotocol/rust-sdk/pull/680)) +- gate optional dependencies behind feature flags ([#672](https://github.com/modelcontextprotocol/rust-sdk/pull/672)) +- allow empty content in CallToolResult ([#681](https://github.com/modelcontextprotocol/rust-sdk/pull/681)) +- *(schema)* remove AddNullable from draft2020_12 settings ([#664](https://github.com/modelcontextprotocol/rust-sdk/pull/664)) + +### Other + +- add prose documentation for core features to meet conformance ([#702](https://github.com/modelcontextprotocol/rust-sdk/pull/702)) +- Fix/sse channel replacement conflict ([#682](https://github.com/modelcontextprotocol/rust-sdk/pull/682)) +- document session management for streamable HTTP transport ([#674](https://github.com/modelcontextprotocol/rust-sdk/pull/674)) + ## [0.16.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.15.0...rmcp-v0.16.0) - 2026-02-17 ### Added From 876da502711b7073e635b6de611fc2223a20222f Mon Sep 17 00:00:00 2001 From: Kristof Mattei <864376+kristof-mattei@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:11:22 -0700 Subject: [PATCH 069/333] fix: downgrade logging of message to `TRACE` to avoid spamming logs (#699) --- crates/rmcp/src/transport/streamable_http_server/tower.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index f6cffb0bb..74b1fd79e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -608,7 +608,7 @@ where _ = cancel.cancelled() => None, } { Some(message) => { - tracing::info!(?message); + tracing::trace!(?message); let body = serde_json::to_vec(&message).map_err(|e| { internal_error_response("serialize json response")(e) })?; @@ -628,7 +628,7 @@ where } else { // SSE mode (default): original behaviour preserved unchanged let stream = ReceiverStream::new(receiver).map(|message| { - tracing::info!(?message); + tracing::trace!(?message); ServerSseMessage { event_id: None, message: Some(Arc::new(message)), From 9299fd379236b6de5da6873062f0a5421d1a8a9f Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 2 Mar 2026 10:36:42 -0500 Subject: [PATCH 070/333] fix: do not attempt triage workflow without an API key (#712) --- .github/workflows/triage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index f064cee2d..a56f84d9b 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -28,6 +28,7 @@ permissions: jobs: triage: name: Triage Issues + if: ${{ secrets.OPENAI_API_KEY != '' }} runs-on: ubuntu-latest timeout-minutes: 10 From 78d959fcd411ee8e7befa113c3599d385bdc0bdc Mon Sep 17 00:00:00 2001 From: Guy Lichtman <1395797+glicht@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:38:15 +0200 Subject: [PATCH 071/333] feat(auth): support returning extra fields from token exchange (#700) * feat(auth): support returning extra fields that may be returned from token generation exchange_code_for_token and refresh_token now return a StandardTokenResponse which includes any additionalfields which might have been sent by the vendor BREAKING CHANGE: Return type of exchange_code_for_token and refresh_token has changed and may require code changes. * fix: doc links --- crates/rmcp/src/transport/auth.rs | 64 ++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index b8d4f3f4a..2236590ab 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -7,16 +7,16 @@ use std::{ use async_trait::async_trait; use oauth2::{ AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, - EmptyExtraTokenFields, HttpClientError, HttpRequest, HttpResponse, PkceCodeChallenge, - PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, - TokenResponse, TokenUrl, - basic::{BasicClient, BasicTokenType}, + EmptyExtraTokenFields, ExtraTokenFields, HttpClientError, HttpRequest, HttpResponse, + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, + StandardTokenResponse, TokenResponse, TokenUrl, basic::BasicTokenType, }; use reqwest::{ Client as HttpClient, IntoUrl, StatusCode, Url, header::{AUTHORIZATION, WWW_AUTHENTICATE}, }; use serde::{Deserialize, Serialize}; +use serde_json::Value; use thiserror::Error; use tokio::sync::{Mutex, RwLock}; use tracing::{debug, error, warn}; @@ -126,6 +126,32 @@ pub struct StoredAuthorizationState { pub created_at: u64, } +/// A transparent wrapper around a JSON object that captures any extra fields returned by the +/// authorization server during token exchange that are not part of the standard OAuth 2.0 token +/// response. +/// +/// OAuth providers may include non-standard fields alongside the +/// standard OAuth fields. Those fields are collected here so callers +/// can inspect them without losing data. +/// +/// The inner [`HashMap`] maps field names to their raw JSON values. +/// +/// # Accessing extra fields +/// +/// Extra fields are available through [`StandardTokenResponse::extra_fields()`], which returns a +/// reference to this struct. Use the inner map (`.0`) to look up individual fields by name: +/// +/// ```rust,ignore +/// // Obtain the token response from the AuthorizationManager, then: +/// if let Some(value) = token_response.extra_fields().0.get("vendorSpecificField") { +/// println!("vendorSpecificField = {value}"); +/// } +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VendorExtraTokenFields(pub HashMap); + +impl ExtraTokenFields for VendorExtraTokenFields {} + impl StoredAuthorizationState { pub fn new(pkce_verifier: &PkceCodeVerifier, csrf_token: &CsrfToken) -> Self { Self { @@ -345,7 +371,18 @@ pub struct OAuthClientConfig { // add type aliases for oauth2 types type OAuthErrorResponse = oauth2::StandardErrorResponse; -pub type OAuthTokenResponse = StandardTokenResponse; + +/// The token response returned by the authorization server after a successful OAuth 2.0 flow. +/// +/// This is a [`StandardTokenResponse`] parameterised with [`VendorExtraTokenFields`], which means +/// it carries both the standard OAuth fields and +/// any vendor-specific fields the server may have included in the JSON response body. +/// +/// # Accessing vendor-specific fields +/// +/// Call [`extra_fields()`][OAuthTokenResponse::extra_fields] to obtain a reference to the +/// [`VendorExtraTokenFields`] wrapper, then index into its inner map. +pub type OAuthTokenResponse = StandardTokenResponse; type OAuthTokenIntrospection = oauth2::StandardTokenIntrospectionResponse; type OAuthRevocableToken = oauth2::StandardRevocableToken; @@ -581,7 +618,7 @@ impl AuthorizationManager { let redirect_url = RedirectUrl::new(config.redirect_uri.clone()) .map_err(|e| AuthError::OAuthError(format!("Invalid re URL: {}", e)))?; - let mut client_builder = BasicClient::new(client_id.clone()) + let mut client_builder: OAuthClient = oauth2::Client::new(client_id.clone()) .set_auth_uri(auth_url) .set_token_uri(token_url) .set_redirect_uri(redirect_url); @@ -882,7 +919,7 @@ impl AuthorizationManager { &self, code: &str, csrf_token: &str, - ) -> Result, AuthError> { + ) -> Result { debug!("start exchange code for token: {:?}", code); let oauth_client = self .oauth_client @@ -1017,9 +1054,7 @@ impl AuthorizationManager { } /// refresh access token - pub async fn refresh_token( - &self, - ) -> Result, AuthError> { + pub async fn refresh_token(&self) -> Result { let oauth_client = self .oauth_client .as_ref() @@ -1551,7 +1586,7 @@ impl AuthorizationSession { &self, code: &str, csrf_token: &str, - ) -> Result, AuthError> { + ) -> Result { self.auth_manager .exchange_code_for_token(code, csrf_token) .await @@ -1876,6 +1911,7 @@ mod tests { AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore, OAuthClientConfig, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, }; + use crate::transport::auth::VendorExtraTokenFields; // -- url helpers -- @@ -2686,11 +2722,13 @@ mod tests { use super::{OAuthTokenResponse, StoredCredentials}; fn make_token_response(access_token: &str, expires_in_secs: Option) -> OAuthTokenResponse { - use oauth2::{AccessToken, EmptyExtraTokenFields, basic::BasicTokenType}; + use oauth2::{AccessToken, basic::BasicTokenType}; let mut resp = OAuthTokenResponse::new( AccessToken::new(access_token.to_string()), BasicTokenType::Bearer, - EmptyExtraTokenFields {}, + VendorExtraTokenFields { + ..Default::default() + }, ); if let Some(secs) = expires_in_secs { resp.set_expires_in(Some(&std::time::Duration::from_secs(secs))); From 8d6b75cd5e9a08f453ca1948532a8146a50e5863 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 2 Mar 2026 14:09:46 -0500 Subject: [PATCH 072/333] fix: properly disable the triage workflow (#714) --- .github/workflows/triage.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index a56f84d9b..c5043bd80 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -28,7 +28,8 @@ permissions: jobs: triage: name: Triage Issues - if: ${{ secrets.OPENAI_API_KEY != '' }} + # TODO: Re-enable once OPENAI_API_KEY secret is available + if: false runs-on: ubuntu-latest timeout-minutes: 10 From 79834b6211b3731fa309e2dbce09a22c2cc4f002 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 2 Mar 2026 14:09:59 -0500 Subject: [PATCH 073/333] chore: add ROADMAP.md tracking gaps from tier 1 (#713) --- ROADMAP.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..62eee5dc9 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,67 @@ +# RMCP Roadmap + +This roadmap tracks the path to SEP-1730 Tier 1 for the Rust MCP SDK. + +Server conformance: 86.7% (26/30) · Client conformance: 85.0% (18/24) · Spec tracking gap: 6 days + +--- + +## Tier 2 → Tier 1 + +### Conformance + +#### Server (86.7% → 100%) + +- [ ] Fix `server-prompts-get-with-args` — prompt argument handling returns incorrect result +- [ ] Fix `server-prompts-get-embedded-resource` — embedded resource content in prompt responses +- [ ] Fix `server-elicitation-sep1330-enums` — enum inference handling per SEP-1330 +- [ ] Fix `server-dns-rebinding-protection` — validate `Host` / `Origin` headers on Streamable HTTP transport + +#### Client (85.0% → 100%) + +- [ ] Fix `auth/scope-step-up` (2025-11-25) — handle 403 `insufficient_scope` and re-authorize with upgraded scopes +- [ ] Fix `auth/metadata-var3` (2025-11-25) — AS metadata discovery variant 3 +- [ ] Fix `auth/2025-03-26-oauth-endpoint-fallback` (2025-03-26) — legacy OAuth endpoint fallback for pre-2025-06-18 servers + +### Governance & Policy + +- [ ] Create `VERSIONING.md` — document semver scheme, what constitutes a breaking change, and how breaking changes are communicated + +### Documentation (26/48 → 48/48 features with prose + examples) + +#### Undocumented features (14) + +- [ ] Tools — image results +- [ ] Tools — audio results +- [ ] Tools — embedded resources +- [ ] Prompts — embedded resources +- [ ] Prompts — image content +- [ ] Elicitation — URL mode +- [ ] Elicitation — default values +- [ ] Elicitation — complete notification +- [ ] Ping +- [ ] SSE transport — legacy (client) +- [ ] SSE transport — legacy (server) +- [ ] Pagination +- [ ] Protocol version negotiation +- [ ] JSON Schema 2020-12 support *(upgrade from partial)* + +#### Partially documented features (7) + +- [ ] Tools — error handling *(add dedicated prose + example)* +- [ ] Resources — reading binary *(add dedicated example)* +- [ ] Elicitation — form mode *(add prose docs, not just example README)* +- [ ] Elicitation — schema validation *(add prose docs)* +- [ ] Elicitation — enum values *(add prose docs)* +- [ ] Capability negotiation *(add dedicated prose explaining the builder API)* +- [ ] Protocol version negotiation *(document version negotiation behavior)* + +--- + +## Informational (not scored) + +These draft/extension scenarios are tracked but do not block tier advancement: + +- [ ] `auth/resource-mismatch` (draft) +- [ ] `auth/cross-app-access-complete-flow` (extension) +- [ ] `auth/client-credentials-jwt` (extension) From f63718d202f0a7226a62b2b292a08bf55dab4fb1 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 3 Mar 2026 10:38:01 -0500 Subject: [PATCH 074/333] chore: add #[non_exhaustive] and mutation methods to improve compatibility (#715) * chore: add #[non_exhaustive] to reduce backwards-incompatible changes going forward * fix: remove ProtocolVersion import * fix: add a few more with_ mutator methods --------- Co-authored-by: Alex Hancock --- crates/rmcp-macros/src/prompt.rs | 20 +- crates/rmcp-macros/src/task_handler.rs | 76 +-- crates/rmcp-macros/src/tool.rs | 42 +- crates/rmcp/README.md | 21 +- crates/rmcp/src/error.rs | 1 + crates/rmcp/src/model.rs | 542 +++++++++++++++++- crates/rmcp/src/model/annotated.rs | 1 + crates/rmcp/src/model/capabilities.rs | 2 + crates/rmcp/src/model/content.rs | 13 + crates/rmcp/src/model/elicitation_schema.rs | 82 +++ crates/rmcp/src/model/prompt.rs | 76 ++- crates/rmcp/src/model/resource.rs | 103 ++++ crates/rmcp/src/model/task.rs | 66 +++ crates/rmcp/src/model/tool.rs | 89 ++- crates/rmcp/src/service.rs | 14 + crates/rmcp/src/service/client.rs | 1 + crates/rmcp/src/service/server.rs | 4 + crates/rmcp/src/transport/auth.rs | 1 + .../src/transport/streamable_http_client.rs | 3 + .../streamable_http_server/session/local.rs | 2 + crates/rmcp/src/transport/worker.rs | 1 + crates/rmcp/tests/common/calculator.rs | 7 +- crates/rmcp/tests/common/handlers.rs | 28 +- .../rmcp/tests/test_client_initialization.rs | 2 + crates/rmcp/tests/test_completion.rs | 21 +- crates/rmcp/tests/test_custom_headers.rs | 5 +- crates/rmcp/tests/test_elicitation.rs | 229 +++----- crates/rmcp/tests/test_handler_wrappers.rs | 7 +- crates/rmcp/tests/test_logging.rs | 21 +- crates/rmcp/tests/test_message_protocol.rs | 227 ++------ crates/rmcp/tests/test_notification.rs | 12 +- crates/rmcp/tests/test_progress_subscriber.rs | 9 +- .../tests/test_prompt_macro_annotations.rs | 12 +- crates/rmcp/tests/test_prompt_macros.rs | 56 +- crates/rmcp/tests/test_prompt_routers.rs | 12 +- .../tests/test_resource_link_integration.rs | 8 +- crates/rmcp/tests/test_sampling.rs | 291 +++------- .../rmcp/tests/test_sse_concurrent_streams.rs | 16 +- crates/rmcp/tests/test_structured_output.rs | 7 +- .../tests/test_task_support_validation.rs | 29 +- crates/rmcp/tests/test_tool_macros.rs | 18 +- crates/rmcp/tests/test_tool_result_meta.rs | 7 +- examples/clients/src/collection.rs | 14 +- examples/clients/src/everything_stdio.rs | 40 +- examples/clients/src/git_stdio.rs | 14 +- examples/clients/src/progress_client.rs | 28 +- examples/clients/src/sampling_stdio.rs | 19 +- examples/clients/src/streamable_http.rs | 27 +- examples/rig-integration/src/mcp_adaptor.rs | 12 +- examples/servers/src/common/calculator.rs | 7 +- examples/servers/src/common/counter.rs | 55 +- .../servers/src/common/generic_service.rs | 7 +- examples/servers/src/common/progress_demo.rs | 12 +- examples/servers/src/completion_stdio.rs | 60 +- .../servers/src/elicitation_enum_inference.rs | 11 +- examples/servers/src/elicitation_stdio.rs | 11 +- examples/servers/src/prompt_stdio.rs | 51 +- examples/servers/src/sampling_stdio.rs | 64 +-- examples/simple-chat-client/src/tool.rs | 14 +- examples/transport/src/common/calculator.rs | 7 +- examples/transport/src/named-pipe.rs | 17 +- examples/transport/src/unix_socket.rs | 17 +- examples/wasi/src/calculator.rs | 7 +- 63 files changed, 1568 insertions(+), 1110 deletions(-) diff --git a/crates/rmcp-macros/src/prompt.rs b/crates/rmcp-macros/src/prompt.rs index d5b0b0f16..a7a13f450 100644 --- a/crates/rmcp-macros/src/prompt.rs +++ b/crates/rmcp-macros/src/prompt.rs @@ -42,9 +42,9 @@ impl ResolvedPromptAttribute { meta, } = self; let description = if let Some(description) = description { - quote! { Some(#description.into()) } + quote! { Some::(#description.into()) } } else { - quote! { None } + quote! { None:: } }; let title = if let Some(title) = title { quote! { Some(#title.into()) } @@ -63,14 +63,14 @@ impl ResolvedPromptAttribute { }; let tokens = quote! { pub fn #fn_ident() -> rmcp::model::Prompt { - rmcp::model::Prompt { - name: #name.into(), - description: #description, - arguments: #arguments, - title: #title, - icons: #icons, - meta: #meta, - } + rmcp::model::Prompt::from_raw( + #name, + #description, + #arguments, + ) + .with_title(#title) + .with_icons(#icons) + .with_meta(#meta) } }; syn::parse2::(tokens) diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index 4ad02d6b8..86664b18f 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -42,23 +42,16 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result>(); - Ok(rmcp::model::ListTasksResult { - tasks, - next_cursor: None, - total: Some(total), - }) + Ok(rmcp::model::ListTasksResult::new(tasks)) } }; item_impl.items.push(syn::parse2::(list_fn)?); @@ -106,17 +99,14 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result(enqueue_fn)?); @@ -151,15 +141,15 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result rmcp::model::TaskStatus::Failed, }; let timestamp = current_timestamp(); - let task = rmcp::model::Task { + let mut task = rmcp::model::Task::new( task_id, status, - status_message: None, - created_at: timestamp.clone(), - last_updated_at: timestamp, - ttl: completed_result.descriptor.ttl, - poll_interval: None, - }; + timestamp.clone(), + timestamp, + ); + if let Some(ttl) = completed_result.descriptor.ttl { + task = task.with_ttl(ttl); + } return Ok(rmcp::model::GetTaskResult { meta: None, task }); } @@ -167,15 +157,12 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result { let value = ::serde_json::to_value(call_tool).unwrap_or(::serde_json::Value::Null); - return Ok(rmcp::model::GetTaskPayloadResult(value)); + return Ok(rmcp::model::GetTaskPayloadResult::new(value)); } Err(err) => return Err(McpError::internal_error( format!("task failed: {}", err), @@ -254,15 +241,12 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result rmcp::model::Tool { - rmcp::model::Tool { - name: #name.into(), - title: #title, - description: #description, - input_schema: #input_schema, - output_schema: #output_schema, - annotations: #annotations, - execution: #execution, - icons: #icons, - meta: #meta, - } + rmcp::model::Tool::new_with_raw( + #name, + #description, + #input_schema, + ) + .with_title(#title) + .with_raw_output_schema(#output_schema) + .with_annotations(#annotations) + .with_execution(#execution) + .with_icons(#icons) + .with_meta(#meta) } }; syn::parse2::(tokens) @@ -260,13 +260,13 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { let idempotent_hint = wrap_option(idempotent_hint); let open_world_hint = wrap_option(open_world_hint); let token_stream = quote! { - Some(rmcp::model::ToolAnnotations { - title: #title, - read_only_hint: #read_only_hint, - destructive_hint: #destructive_hint, - idempotent_hint: #idempotent_hint, - open_world_hint: #open_world_hint, - }) + Some(rmcp::model::ToolAnnotations::from_raw( + #title, + #read_only_hint, + #destructive_hint, + #idempotent_hint, + #open_world_hint, + )) }; syn::parse2::(token_stream)? } else { @@ -296,9 +296,9 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { }; let token_stream = quote! { - Some(rmcp::model::ToolExecution { - task_support: #task_support_expr, - }) + Some(rmcp::model::ToolExecution::from_raw( + #task_support_expr, + )) }; syn::parse2::(token_stream)? } else { diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index 217b22cd6..ebc1db336 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -19,7 +19,7 @@ Creating a server with tools is simple using the `#[tool]` macro: -```rust,no_run +```rust,ignore use rmcp::{ ServerHandler, ServiceExt, handler::server::tool::ToolRouter, @@ -68,11 +68,8 @@ impl Counter { #[tool_handler] impl ServerHandler for Counter { fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some("A simple counter that tallies the number of times the increment tool has been used".into()), - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("A simple counter that tallies the number of times the increment tool has been used") } } @@ -147,7 +144,7 @@ To expose task support, enable the `tasks` capability when building `ServerCapab Creating a client to interact with a server: -```rust,no_run +```rust,ignore use rmcp::{ ServiceExt, model::CallToolRequestParams, @@ -176,12 +173,10 @@ async fn main() -> Result<(), Box> { // Call a tool let result = service - .call_tool(CallToolRequestParams { - meta: None, - name: "git_status".into(), - arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(), - task: None, - }) + .call_tool( + CallToolRequestParams::new("git_status") + .with_arguments(serde_json::json!({ "repo_path": "." }).as_object().cloned().unwrap_or_default()) + ) .await?; println!("Result: {result:#?}"); diff --git a/crates/rmcp/src/error.rs b/crates/rmcp/src/error.rs index c7901f4b5..74f7d4383 100644 --- a/crates/rmcp/src/error.rs +++ b/crates/rmcp/src/error.rs @@ -20,6 +20,7 @@ impl std::error::Error for ErrorData {} /// This is an unified error type for the errors could be returned by the service. #[derive(Debug, thiserror::Error)] #[allow(clippy::large_enum_variant)] +#[non_exhaustive] pub enum RmcpError { #[cfg(any(feature = "client", feature = "server"))] #[error("Service error: {0}")] diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index b358f5233..c0b3dc436 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -306,6 +306,7 @@ pub struct ProgressToken(pub NumberOrString); /// - `extensions`: Additional context data (similar to HTTP headers) #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Request { pub method: M, pub params: P, @@ -379,6 +380,7 @@ impl GetExtensions for RequestNoParam { } #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Notification { pub method: M, pub params: P, @@ -419,6 +421,17 @@ pub struct JsonRpcRequest { pub request: R, } +impl JsonRpcRequest { + /// Create a new JsonRpcRequest. + pub fn new(id: RequestId, request: R) -> Self { + Self { + jsonrpc: JsonRpcVersion2_0, + id, + request, + } + } +} + type DefaultResponse = JsonObject; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -436,6 +449,17 @@ pub struct JsonRpcError { pub error: ErrorData, } +impl JsonRpcError { + /// Create a new JsonRpcError. + pub fn new(id: RequestId, error: ErrorData) -> Self { + Self { + jsonrpc: JsonRpcVersion2_0, + id, + error, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct JsonRpcNotification { @@ -467,7 +491,7 @@ impl ErrorCode { /// /// This structure follows the JSON-RPC 2.0 specification for error reporting, /// providing a standardized way to communicate errors between clients and servers. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct ErrorData { /// The error type that occurred (using standard JSON-RPC error codes) @@ -745,6 +769,7 @@ pub type InitializedNotification = NotificationNoParam Self { + Self { + meta: None, + protocol_version: ProtocolVersion::default(), + capabilities, + client_info, + } + } + + pub fn with_protocol_version(mut self, protocol_version: ProtocolVersion) -> Self { + self.protocol_version = protocol_version; + self + } +} + impl RequestParamsMeta for InitializeRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -777,6 +819,7 @@ pub type InitializeRequestParam = InitializeRequestParams; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct InitializeResult { /// The MCP protocol version this server supports pub protocol_version: ProtocolVersion, @@ -789,6 +832,36 @@ pub struct InitializeResult { pub instructions: Option, } +impl InitializeResult { + /// Create a new `InitializeResult` with default protocol version and the given capabilities. + pub fn new(capabilities: ServerCapabilities) -> Self { + Self { + protocol_version: ProtocolVersion::default(), + capabilities, + server_info: Implementation::from_build_env(), + instructions: None, + } + } + + /// Set instructions on this result. + pub fn with_instructions(mut self, instructions: impl Into) -> Self { + self.instructions = Some(instructions.into()); + self + } + + /// Set the server info on this result. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.server_info = server_info; + self + } + + /// Set the protocol version on this result. + pub fn with_protocol_version(mut self, protocol_version: ProtocolVersion) -> Self { + self.protocol_version = protocol_version; + self + } +} + pub type ServerInfo = InitializeResult; pub type ClientInfo = InitializeRequestParams; @@ -828,6 +901,7 @@ impl Default for ClientInfo { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Icon { /// A standard URI pointing to an icon resource pub src: String, @@ -839,9 +913,33 @@ pub struct Icon { pub sizes: Option>, } +impl Icon { + /// Create a new Icon with the given source URL. + pub fn new(src: impl Into) -> Self { + Self { + src: src.into(), + mime_type: None, + sizes: None, + } + } + + /// Set the MIME type. + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + /// Set the sizes. + pub fn with_sizes(mut self, sizes: Vec) -> Self { + self.sizes = Some(sizes); + self + } +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Implementation { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -862,6 +960,18 @@ impl Default for Implementation { } impl Implementation { + /// Create a new Implementation. + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + title: None, + version: version.into(), + description: None, + icons: None, + website_url: None, + } + } + pub fn from_build_env() -> Self { Implementation { name: env!("CARGO_CRATE_NAME").to_owned(), @@ -872,11 +982,36 @@ impl Implementation { website_url: None, } } + + /// Set the human-readable title. + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the icons. + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + /// Set the website URL. + pub fn with_website_url(mut self, website_url: impl Into) -> Self { + self.website_url = Some(website_url.into()); + self + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct PaginatedRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -885,6 +1020,13 @@ pub struct PaginatedRequestParams { pub cursor: Option, } +impl PaginatedRequestParams { + pub fn with_cursor(mut self, cursor: Option) -> Self { + self.cursor = cursor; + self + } +} + impl RequestParamsMeta for PaginatedRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -920,6 +1062,30 @@ pub struct ProgressNotificationParam { pub message: Option, } +impl ProgressNotificationParam { + /// Create a new ProgressNotificationParam with required fields. + pub fn new(progress_token: ProgressToken, progress: f64) -> Self { + Self { + progress_token, + progress, + total: None, + message: None, + } + } + + /// Set the total number of items to process. + pub fn with_total(mut self, total: f64) -> Self { + self.total = Some(total); + self + } + + /// Set a message describing the current progress. + pub fn with_message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } +} + pub type ProgressNotification = Notification; pub type Cursor = String; @@ -980,6 +1146,7 @@ const_string!(ReadResourceRequestMethod = "resources/read"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ReadResourceRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -988,6 +1155,22 @@ pub struct ReadResourceRequestParams { pub uri: String, } +impl ReadResourceRequestParams { + /// Create a new ReadResourceRequestParams with the given URI. + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + } + } + + /// Set the metadata for this request. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } +} + impl RequestParamsMeta for ReadResourceRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -1004,11 +1187,19 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams; /// Result containing the contents of a read resource #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ReadResourceResult { /// The actual content of the resource pub contents: Vec, } +impl ReadResourceResult { + /// Create a new ReadResourceResult with the given contents. + pub fn new(contents: Vec) -> Self { + Self { contents } + } +} + /// Request to read a specific resource pub type ReadResourceRequest = Request; @@ -1022,6 +1213,7 @@ const_string!(SubscribeRequestMethod = "resources/subscribe"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct SubscribeRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -1030,6 +1222,16 @@ pub struct SubscribeRequestParams { pub uri: String, } +impl SubscribeRequestParams { + /// Create a new SubscribeRequestParams. + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + } + } +} + impl RequestParamsMeta for SubscribeRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -1051,6 +1253,7 @@ const_string!(UnsubscribeRequestMethod = "resources/unsubscribe"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct UnsubscribeRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -1084,6 +1287,14 @@ pub struct ResourceUpdatedNotificationParam { /// The URI of the resource that was updated pub uri: String, } + +impl ResourceUpdatedNotificationParam { + /// Create a new ResourceUpdatedNotificationParam. + pub fn new(uri: impl Into) -> Self { + Self { uri: uri.into() } + } +} + /// Notification sent when a subscribed resource is updated pub type ResourceUpdatedNotification = Notification; @@ -1103,9 +1314,10 @@ paginated_result!(ListPromptsResult { const_string!(GetPromptRequestMethod = "prompts/get"); /// Parameters for retrieving a specific prompt -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct GetPromptRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -1115,6 +1327,29 @@ pub struct GetPromptRequestParams { pub arguments: Option, } +impl GetPromptRequestParams { + /// Create a new `GetPromptRequestParams` with the given prompt name. + pub fn new(name: impl Into) -> Self { + Self { + meta: None, + name: name.into(), + arguments: None, + } + } + + /// Set the arguments for this prompt request. + pub fn with_arguments(mut self, arguments: JsonObject) -> Self { + self.arguments = Some(arguments); + self + } + + /// Set the metadata for this request. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } +} + impl RequestParamsMeta for GetPromptRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -1163,6 +1398,7 @@ const_string!(SetLevelRequestMethod = "logging/setLevel"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct SetLevelRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -1171,6 +1407,13 @@ pub struct SetLevelRequestParams { pub level: LoggingLevel, } +impl SetLevelRequestParams { + /// Create a new SetLevelRequestParams with the given logging level. + pub fn new(level: LoggingLevel) -> Self { + Self { meta: None, level } + } +} + impl RequestParamsMeta for SetLevelRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -1201,6 +1444,27 @@ pub struct LoggingMessageNotificationParam { /// The actual log data pub data: Value, } + +impl LoggingMessageNotificationParam { + /// Create a new LoggingMessageNotificationParam. + pub fn new(level: LoggingLevel, data: Value) -> Self { + Self { + level, + logger: None, + data, + } + } + + /// Create with a logger name. + pub fn with_logger(level: LoggingLevel, logger: impl Into, data: Value) -> Self { + Self { + level, + logger: Some(logger.into()), + data, + } + } +} + /// Notification containing a log message pub type LoggingMessageNotification = Notification; @@ -1249,6 +1513,7 @@ impl Default for ToolChoiceMode { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ToolChoice { #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, @@ -1379,6 +1644,7 @@ impl From> for SamplingContent { /// for generating appropriate responses. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct SamplingMessage { /// The role of the message sender (User or Assistant) pub role: Role, @@ -1539,9 +1805,10 @@ pub enum ContextInclusion { /// /// This implements `TaskAugmentedRequestParamsMeta` as sampling requests can be /// long-running and may benefit from task-based execution. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct CreateMessageRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -1598,6 +1865,72 @@ impl TaskAugmentedRequestParamsMeta for CreateMessageRequestParams { } impl CreateMessageRequestParams { + /// Create a new CreateMessageRequestParams with required fields. + pub fn new(messages: Vec, max_tokens: u32) -> Self { + Self { + meta: None, + task: None, + messages, + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + max_tokens, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + } + } + + /// Set model preferences. + pub fn with_model_preferences(mut self, model_preferences: ModelPreferences) -> Self { + self.model_preferences = Some(model_preferences); + self + } + + /// Set system prompt. + pub fn with_system_prompt(mut self, system_prompt: impl Into) -> Self { + self.system_prompt = Some(system_prompt.into()); + self + } + + /// Set include context. + pub fn with_include_context(mut self, include_context: ContextInclusion) -> Self { + self.include_context = Some(include_context); + self + } + + /// Set temperature. + pub fn with_temperature(mut self, temperature: f32) -> Self { + self.temperature = Some(temperature); + self + } + + /// Set stop sequences. + pub fn with_stop_sequences(mut self, stop_sequences: Vec) -> Self { + self.stop_sequences = Some(stop_sequences); + self + } + + /// Set metadata. + pub fn with_metadata(mut self, metadata: Value) -> Self { + self.metadata = Some(metadata); + self + } + + /// Set tools. + pub fn with_tools(mut self, tools: Vec) -> Self { + self.tools = Some(tools); + self + } + + /// Set tool choice. + pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self { + self.tool_choice = Some(tool_choice); + self + } + /// Validate the sampling request parameters per SEP-1577 spec requirements. /// /// Checks: @@ -1688,6 +2021,7 @@ pub type CreateMessageRequestParam = CreateMessageRequestParams; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ModelPreferences { /// Specific model names or families to prefer (e.g., "claude", "gpt") #[serde(skip_serializing_if = "Option::is_none")] @@ -1703,18 +2037,70 @@ pub struct ModelPreferences { pub intelligence_priority: Option, } +impl ModelPreferences { + /// Create a new default ModelPreferences. + pub fn new() -> Self { + Self { + hints: None, + cost_priority: None, + speed_priority: None, + intelligence_priority: None, + } + } + + /// Set hints for model selection. + pub fn with_hints(mut self, hints: Vec) -> Self { + self.hints = Some(hints); + self + } + + /// Set cost priority (0.0 to 1.0). + pub fn with_cost_priority(mut self, cost_priority: f32) -> Self { + self.cost_priority = Some(cost_priority); + self + } + + /// Set speed priority (0.0 to 1.0). + pub fn with_speed_priority(mut self, speed_priority: f32) -> Self { + self.speed_priority = Some(speed_priority); + self + } + + /// Set intelligence priority (0.0 to 1.0). + pub fn with_intelligence_priority(mut self, intelligence_priority: f32) -> Self { + self.intelligence_priority = Some(intelligence_priority); + self + } +} + +impl Default for ModelPreferences { + fn default() -> Self { + Self::new() + } +} + /// A hint suggesting a preferred model name or family. /// /// Model hints are advisory suggestions that help clients choose appropriate /// models. They can be specific model names or general families like "claude" or "gpt". -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ModelHint { /// The suggested model name or family identifier #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, } +impl ModelHint { + /// Create a new ModelHint with a name. + pub fn new(name: impl Into) -> Self { + Self { + name: Some(name.into()), + } + } +} + // ============================================================================= // COMPLETION AND AUTOCOMPLETE // ============================================================================= @@ -1768,6 +2154,7 @@ impl CompletionContext { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct CompleteRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -1779,6 +2166,24 @@ pub struct CompleteRequestParams { pub context: Option, } +impl CompleteRequestParams { + /// Create a new CompleteRequestParams with required fields. + pub fn new(r#ref: Reference, argument: ArgumentInfo) -> Self { + Self { + meta: None, + r#ref, + argument, + context: None, + } + } + + /// Set the completion context + pub fn with_context(mut self, context: CompletionContext) -> Self { + self.context = Some(context); + self + } +} + impl RequestParamsMeta for CompleteRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -1875,10 +2280,18 @@ impl CompletionInfo { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct CompleteResult { pub completion: CompletionInfo, } +impl CompleteResult { + /// Create a new CompleteResult with the given completion info. + pub fn new(completion: CompletionInfo) -> Self { + Self { completion } + } +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(tag = "type")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -1939,6 +2352,7 @@ pub struct ResourceReference { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct PromptReference { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -1960,6 +2374,7 @@ pub struct ArgumentInfo { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Root { pub uri: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -1972,6 +2387,7 @@ pub type ListRootsRequest = RequestNoParam; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ListRootsResult { pub roots: Vec, } @@ -2180,18 +2596,45 @@ pub struct CreateElicitationResult { pub content: Option, } +impl CreateElicitationResult { + /// Create a new CreateElicitationResult. + pub fn new(action: ElicitationAction) -> Self { + Self { + action, + content: None, + } + } + + /// Create with content. + pub fn with_content(action: ElicitationAction, content: Value) -> Self { + Self { + action, + content: Some(content), + } + } +} + /// Request type for creating an elicitation to gather user input pub type CreateElicitationRequest = Request; /// Notification parameters for an url elicitation completion notification. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct ElicitationResponseNotificationParam { pub elicitation_id: String, } +impl ElicitationResponseNotificationParam { + /// Create a new ElicitationResponseNotificationParam. + pub fn new(elicitation_id: impl Into) -> Self { + Self { + elicitation_id: elicitation_id.into(), + } + } +} + /// Notification sent when an url elicitation process is completed. pub type ElicitationCompletionNotification = Notification; @@ -2204,9 +2647,10 @@ pub type ElicitationCompletionNotification = /// /// Contains the content returned by the tool execution and an optional /// flag indicating whether the operation resulted in an error. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct CallToolResult { /// The content returned by the tool (text, images, etc.) pub content: Vec, @@ -2289,6 +2733,12 @@ impl CallToolResult { } } + /// Set the metadata on this result + pub fn with_meta(mut self, meta: Option) -> Self { + self.meta = meta; + self + } + /// Convert the `structured_content` part of response into a certain type. /// /// # About json schema validation @@ -2336,9 +2786,10 @@ const_string!(CallToolRequestMethod = "tools/call"); /// /// This implements `TaskAugmentedRequestParamsMeta` as tool calls can be /// long-running and may benefit from task-based execution. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct CallToolRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -2353,6 +2804,30 @@ pub struct CallToolRequestParams { pub task: Option, } +impl CallToolRequestParams { + /// Creates a new `CallToolRequestParams` with the given tool name. + pub fn new(name: impl Into>) -> Self { + Self { + meta: None, + name: name.into(), + arguments: None, + task: None, + } + } + + /// Sets the arguments for this tool call. + pub fn with_arguments(mut self, arguments: JsonObject) -> Self { + self.arguments = Some(arguments); + self + } + + /// Sets the task metadata for this tool call. + pub fn with_task(mut self, task: Option) -> Self { + self.task = task; + self + } +} + impl RequestParamsMeta for CallToolRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -2386,6 +2861,7 @@ pub type CallToolRequest = Request #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct CreateMessageResult { /// The identifier of the model that generated the response pub model: String, @@ -2398,11 +2874,32 @@ pub struct CreateMessageResult { } impl CreateMessageResult { + /// Create a new CreateMessageResult with required fields. + pub fn new(message: SamplingMessage, model: String) -> Self { + Self { + message, + model, + stop_reason: None, + } + } + pub const STOP_REASON_END_TURN: &str = "endTurn"; pub const STOP_REASON_END_SEQUENCE: &str = "stopSequence"; pub const STOP_REASON_END_MAX_TOKEN: &str = "maxTokens"; pub const STOP_REASON_TOOL_USE: &str = "toolUse"; + /// Set the stop reason. + pub fn with_stop_reason(mut self, stop_reason: Option) -> Self { + self.stop_reason = stop_reason; + self + } + + /// Set the model identifier. + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = model.into(); + self + } + /// Validate the result per SEP-1577: role must be "assistant". pub fn validate(&self) -> Result<(), String> { if self.message.role != Role::Assistant { @@ -2412,15 +2909,32 @@ impl CreateMessageResult { } } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct GetPromptResult { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub messages: Vec, } +impl GetPromptResult { + /// Create a new GetPromptResult with required fields. + pub fn new(messages: Vec) -> Self { + Self { + description: None, + messages, + } + } + + /// Set the description + pub fn with_description>(mut self, description: D) -> Self { + self.description = Some(description.into()); + self + } +} + // ============================================================================= // TASK MANAGEMENT // ============================================================================= @@ -2512,6 +3026,7 @@ pub type GetTaskInfoResult = GetTaskResult; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ListTasksResult { pub tasks: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -2520,6 +3035,17 @@ pub struct ListTasksResult { pub total: Option, } +impl ListTasksResult { + /// Create a new ListTasksResult. + pub fn new(tasks: Vec) -> Self { + Self { + tasks, + next_cursor: None, + total: None, + } + } +} + // ============================================================================= // MESSAGE TYPE UNIONS // ============================================================================= diff --git a/crates/rmcp/src/model/annotated.rs b/crates/rmcp/src/model/annotated.rs index f9921146a..9158e10be 100644 --- a/crates/rmcp/src/model/annotated.rs +++ b/crates/rmcp/src/model/annotated.rs @@ -11,6 +11,7 @@ use super::{ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Annotations { #[serde(skip_serializing_if = "Option::is_none")] pub audience: Option>, diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index e5716acca..b47a8a849 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -243,6 +243,7 @@ pub struct SamplingCapability { /// ``` #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ClientCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub experimental: Option, @@ -280,6 +281,7 @@ pub struct ClientCapabilities { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ServerCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub experimental: Option, diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index beb4d9f5d..83658b023 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -38,6 +38,17 @@ pub struct RawEmbeddedResource { pub meta: Option, pub resource: ResourceContents, } + +impl RawEmbeddedResource { + /// Create a new RawEmbeddedResource. + pub fn new(resource: ResourceContents) -> Self { + Self { + meta: None, + resource, + } + } +} + pub type EmbeddedResource = Annotated; impl EmbeddedResource { @@ -63,6 +74,7 @@ pub type AudioContent = Annotated; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ToolUseContent { /// Unique identifier for this tool call pub id: String, @@ -79,6 +91,7 @@ pub struct ToolUseContent { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ToolResultContent { /// Optional metadata #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 5e7506e49..cdbb87d6d 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -89,6 +89,7 @@ pub enum StringFormat { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct StringSchema { /// Type discriminator #[serde(rename = "type")] @@ -237,6 +238,7 @@ impl StringSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct NumberSchema { /// Type discriminator #[serde(rename = "type")] @@ -444,6 +446,7 @@ impl IntegerSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct BooleanSchema { /// Type discriminator #[serde(rename = "type")] @@ -513,6 +516,16 @@ pub struct ConstTitle { pub title: String, } +impl ConstTitle { + /// Create a new ConstTitle. + pub fn new(const_: impl Into, title: impl Into) -> Self { + Self { + const_: const_.into(), + title: title.into(), + } + } +} + /// Legacy enum schema, keep for backward compatibility #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -533,6 +546,7 @@ pub struct LegacyEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct UntitledSingleSelectEnumSchema { #[serde(rename = "type")] pub type_: StringTypeConst, @@ -550,6 +564,7 @@ pub struct UntitledSingleSelectEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct TitledSingleSelectEnumSchema { #[serde(rename = "type")] pub type_: StringTypeConst, @@ -563,6 +578,19 @@ pub struct TitledSingleSelectEnumSchema { pub default: Option, } +impl TitledSingleSelectEnumSchema { + /// Create a new TitledSingleSelectEnumSchema. + pub fn new(one_of: Vec) -> Self { + Self { + type_: StringTypeConst, + title: None, + description: None, + one_of, + default: None, + } + } +} + /// Combined single-select #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -592,10 +620,18 @@ pub struct TitledItems { pub any_of: Vec, } +impl TitledItems { + /// Create a new TitledItems. + pub fn new(any_of: Vec) -> Self { + Self { any_of } + } +} + /// Multi-select untitled options #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct UntitledMultiSelectEnumSchema { #[serde(rename = "type")] pub type_: ArrayTypeConst, @@ -616,6 +652,7 @@ pub struct UntitledMultiSelectEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct TitledMultiSelectEnumSchema { #[serde(rename = "type")] pub type_: ArrayTypeConst, @@ -632,6 +669,51 @@ pub struct TitledMultiSelectEnumSchema { pub default: Option>, } +impl TitledMultiSelectEnumSchema { + /// Create a new TitledMultiSelectEnumSchema. + pub fn new(items: TitledItems) -> Self { + Self { + type_: ArrayTypeConst, + title: None, + description: None, + min_items: None, + max_items: None, + items, + default: None, + } + } + + /// Set the title. + pub fn with_title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description. + pub fn with_description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the minimum number of items. + pub fn with_min_items(mut self, min_items: u64) -> Self { + self.min_items = Some(min_items); + self + } + + /// Set the maximum number of items. + pub fn with_max_items(mut self, max_items: u64) -> Self { + self.max_items = Some(max_items); + self + } + + /// Set the default values. + pub fn with_default(mut self, default: Vec) -> Self { + self.default = Some(default); + self + } +} + /// Multi-select enum options #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index f90aff199..4d491d0e1 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -7,9 +7,10 @@ use super::{ }; /// A prompt that can be used to generate text from a model -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Prompt { /// The name of the prompt pub name: String, @@ -49,11 +50,46 @@ impl Prompt { meta: None, } } + + /// Create a new prompt from raw fields (used by the macro) + pub fn from_raw( + name: impl Into, + description: Option>, + arguments: Option>, + ) -> Self { + Prompt { + name: name.into(), + title: None, + description: description.map(Into::into), + arguments, + icons: None, + meta: None, + } + } + + /// Set the human-readable title + pub fn with_title(mut self, title: Option) -> Self { + self.title = title; + self + } + + /// Set the icons + pub fn with_icons(mut self, icons: Option>) -> Self { + self.icons = icons; + self + } + + /// Set the metadata + pub fn with_meta(mut self, meta: Option) -> Self { + self.meta = meta; + self + } } /// Represents a prompt argument that can be passed to customize the prompt -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct PromptArgument { /// The name of the argument pub name: String, @@ -68,6 +104,36 @@ pub struct PromptArgument { pub required: Option, } +impl PromptArgument { + /// Create a new prompt argument + pub fn new>(name: N) -> Self { + PromptArgument { + name: name.into(), + title: None, + description: None, + required: None, + } + } + + /// Set the title + pub fn with_title>(mut self, title: T) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description + pub fn with_description>(mut self, description: D) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the required flag + pub fn with_required(mut self, required: bool) -> Self { + self.required = Some(required); + self + } +} + /// Represents the role of a message sender in a prompt conversation #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -112,6 +178,7 @@ impl PromptMessageContent { /// A message in a prompt conversation #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct PromptMessage { /// The role of the message sender pub role: PromptMessageRole, @@ -120,6 +187,11 @@ pub struct PromptMessage { } impl PromptMessage { + /// Create a new prompt message with the given role and content + pub fn new(role: PromptMessageRole, content: PromptMessageContent) -> Self { + Self { role, content } + } + /// Create a new text message with the given role and text content pub fn new_text>(role: PromptMessageRole, text: S) -> Self { Self { diff --git a/crates/rmcp/src/model/resource.rs b/crates/rmcp/src/model/resource.rs index cf3a1071f..8a25e25ba 100644 --- a/crates/rmcp/src/model/resource.rs +++ b/crates/rmcp/src/model/resource.rs @@ -80,6 +80,7 @@ pub enum ResourceContents { } impl ResourceContents { + /// Create text resource contents. pub fn text(text: impl Into, uri: impl Into) -> Self { Self::TextResourceContents { uri: uri.into(), @@ -88,6 +89,34 @@ impl ResourceContents { meta: None, } } + + /// Create blob resource contents. + pub fn blob(blob: impl Into, uri: impl Into) -> Self { + Self::BlobResourceContents { + uri: uri.into(), + mime_type: None, + blob: blob.into(), + meta: None, + } + } + + /// Set the MIME type on this resource contents. + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + match &mut self { + Self::TextResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()), + Self::BlobResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()), + } + self + } + + /// Set the metadata on this resource contents. + pub fn with_meta(mut self, meta: Meta) -> Self { + match &mut self { + Self::TextResourceContents { meta: m, .. } => *m = Some(meta), + Self::BlobResourceContents { meta: m, .. } => *m = Some(meta), + } + self + } } impl RawResource { @@ -104,6 +133,80 @@ impl RawResource { meta: None, } } + + /// Set the human-readable title. + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the MIME type. + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + /// Set the size in bytes. + pub fn with_size(mut self, size: u32) -> Self { + self.size = Some(size); + self + } + + /// Set the icons. + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + /// Set the metadata. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } +} + +impl RawResourceTemplate { + /// Creates a new RawResourceTemplate with a URI template and name. + pub fn new(uri_template: impl Into, name: impl Into) -> Self { + Self { + uri_template: uri_template.into(), + name: name.into(), + title: None, + description: None, + mime_type: None, + icons: None, + } + } + + /// Set the human-readable title. + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the MIME type. + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + /// Set the icons. + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } } #[cfg(test)] diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index a18ed0c59..8373aa243 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -28,6 +28,7 @@ pub enum TaskStatus { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Task { /// Unique task identifier generated by the receiver. pub task_id: String, @@ -48,14 +49,60 @@ pub struct Task { pub poll_interval: Option, } +impl Task { + /// Create a new Task with required fields. + pub fn new( + task_id: String, + status: TaskStatus, + created_at: String, + last_updated_at: String, + ) -> Self { + Self { + task_id, + status, + status_message: None, + created_at, + last_updated_at, + ttl: None, + poll_interval: None, + } + } + + /// Set the status message. + pub fn with_status_message(mut self, status_message: impl Into) -> Self { + self.status_message = Some(status_message.into()); + self + } + + /// Set the TTL in milliseconds. `None` means unlimited retention. + pub fn with_ttl(mut self, ttl: u64) -> Self { + self.ttl = Some(ttl); + self + } + + /// Set the poll interval in milliseconds. + pub fn with_poll_interval(mut self, poll_interval: u64) -> Self { + self.poll_interval = Some(poll_interval); + self + } +} + /// Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct CreateTaskResult { pub task: Task, } +impl CreateTaskResult { + /// Create a new CreateTaskResult. + pub fn new(task: Task) -> Self { + Self { task } + } +} + /// Response to a `tasks/get` request. /// /// Per spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are @@ -78,8 +125,16 @@ pub struct GetTaskResult { /// serialized as a JSON value. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct GetTaskPayloadResult(pub Value); +impl GetTaskPayloadResult { + /// Create a new GetTaskPayloadResult with the given value. + pub fn new(value: Value) -> Self { + Self(value) + } +} + /// Response to a `tasks/cancel` request. /// /// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`. @@ -104,3 +159,14 @@ pub struct TaskList { #[serde(skip_serializing_if = "Option::is_none")] pub total: Option, } + +impl TaskList { + /// Create a new TaskList. + pub fn new(tasks: Vec) -> Self { + Self { + tasks, + next_cursor: None, + total: None, + } + } +} diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index 9732faca1..82b762de3 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -10,9 +10,10 @@ use serde_json::Value; use super::{Icon, JsonObject, Meta}; /// A tool that can be used by a model. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct Tool { /// The name of the tool pub name: Cow<'static, str>, @@ -67,6 +68,7 @@ pub enum TaskSupport { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ToolExecution { /// Indicates whether this tool supports task-based invocation. /// @@ -83,6 +85,11 @@ impl ToolExecution { Self::default() } + /// Create a ToolExecution from raw optional fields. + pub fn from_raw(task_support: Option) -> Self { + Self { task_support } + } + /// Set the task support mode. pub fn with_task_support(mut self, task_support: TaskSupport) -> Self { self.task_support = Some(task_support); @@ -101,6 +108,7 @@ impl ToolExecution { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] pub struct ToolAnnotations { /// A human-readable title for the tool. #[serde(skip_serializing_if = "Option::is_none")] @@ -145,6 +153,24 @@ impl ToolAnnotations { pub fn new() -> Self { Self::default() } + + /// Create a new ToolAnnotations with all fields specified + pub fn from_raw( + title: Option, + read_only_hint: Option, + destructive_hint: Option, + idempotent_hint: Option, + open_world_hint: Option, + ) -> Self { + ToolAnnotations { + title, + read_only_hint, + destructive_hint, + idempotent_hint, + open_world_hint, + } + } + pub fn with_title(title: T) -> Self where T: Into, @@ -211,6 +237,59 @@ impl Tool { } } + /// Create a new tool with just a name and input schema (no description) + pub fn new_with_raw( + name: N, + description: Option>, + input_schema: S, + ) -> Self + where + N: Into>, + S: Into>, + { + Tool { + name: name.into(), + title: None, + description, + input_schema: input_schema.into(), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + } + } + + /// Set the human-readable title + pub fn with_title(mut self, title: Option) -> Self { + self.title = title; + self + } + + /// Set the output schema from a raw value + pub fn with_raw_output_schema(mut self, output_schema: Option>) -> Self { + self.output_schema = output_schema; + self + } + + /// Set the annotations + pub fn with_annotations(mut self, annotations: Option) -> Self { + self.annotations = annotations; + self + } + + /// Set the icons + pub fn with_icons(mut self, icons: Option>) -> Self { + self.icons = icons; + self + } + + /// Set the metadata + pub fn with_meta(mut self, meta: Option) -> Self { + self.meta = meta; + self + } + pub fn annotate(self, annotations: ToolAnnotations) -> Self { Tool { annotations: Some(annotations), @@ -219,11 +298,9 @@ impl Tool { } /// Set the execution configuration for this tool. - pub fn with_execution(self, execution: ToolExecution) -> Self { - Tool { - execution: Some(execution), - ..self - } + pub fn with_execution(mut self, execution: Option) -> Self { + self.execution = execution; + self } /// Returns the task support mode for this tool. diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index b12839c6f..d6613dd3c 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -566,6 +566,7 @@ impl RunningServiceCancellationToken { } #[derive(Debug)] +#[non_exhaustive] pub enum QuitReason { Cancelled, Closed, @@ -584,6 +585,19 @@ pub struct RequestContext { pub peer: Peer, } +impl RequestContext { + /// Create a new RequestContext. + pub fn new(id: RequestId, peer: Peer) -> Self { + Self { + ct: CancellationToken::new(), + id, + meta: Meta::default(), + extensions: Extensions::default(), + peer, + } + } +} + /// Request execution context #[derive(Debug, Clone)] pub struct NotificationContext { diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 837fafeff..6528e4144 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -25,6 +25,7 @@ use crate::{ /// /// if you want to handle the error, you can use `serve_client_with_ct` or `serve_client` with `Result, ClientError>` #[derive(Error, Debug)] +#[non_exhaustive] pub enum ClientInitializeError { #[error("expect initialized response, but received: {0:?}")] ExpectedInitResponse(Option), diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 5f54f3dcd..666a79980 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -47,6 +47,7 @@ impl ServiceRole for RoleServer { /// /// if you want to handle the error, you can use `serve_server_with_ct` or `serve_server` with `Result, ServerError>` #[derive(Error, Debug)] +#[non_exhaustive] pub enum ServerInitializeError { #[error("expect initialized request, but received: {0:?}")] ExpectedInitializeRequest(Option), @@ -457,6 +458,7 @@ impl Peer { /// Errors that can occur during typed elicitation operations #[cfg(feature = "elicitation")] #[derive(Error, Debug)] +#[non_exhaustive] pub enum ElicitationError { /// The elicitation request failed at the service level #[error("Service error: {0}")] @@ -808,6 +810,7 @@ impl Peer { /// ElicitationAction::Cancel => { /// println!("User cancelled/dismissed the request"); /// } + /// _ => {} /// } /// Ok(()) /// } @@ -858,6 +861,7 @@ impl Peer { /// ElicitationAction::Cancel => { /// println!("User cancelled/dismissed the request"); /// } + /// _ => {} /// } /// Ok(()) /// } diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 2236590ab..6578b5c3b 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -266,6 +266,7 @@ impl AuthClient { /// Auth error #[derive(Debug, Error)] +#[non_exhaustive] pub enum AuthError { #[error("OAuth authorization required")] AuthorizationRequired, diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 779dfe1c5..85915c976 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -44,6 +44,7 @@ impl InsufficientScopeError { } #[derive(Error, Debug)] +#[non_exhaustive] pub enum StreamableHttpError { #[error("SSE error: {0}")] Sse(#[from] SseError), @@ -81,12 +82,14 @@ pub enum StreamableHttpError { } #[derive(Debug, Clone, Error)] +#[non_exhaustive] pub enum StreamableHttpProtocolError { #[error("Missing session id in response")] MissingSessionIdInResponse, } #[allow(clippy::large_enum_variant)] +#[non_exhaustive] pub enum StreamableHttpPostResponse { Accepted, Json(ServerJsonRpcMessage, Option), diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 6e197b5b8..cad533802 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -688,6 +688,7 @@ pub enum SessionEvent { } #[derive(Debug, Clone)] +#[non_exhaustive] pub enum SessionQuitReason { ServiceTerminated, ClientTerminated, @@ -880,6 +881,7 @@ pub type SessionTransport = WorkerTransport; #[allow(clippy::large_enum_variant)] #[derive(Debug, Error)] +#[non_exhaustive] pub enum LocalSessionWorkerError { #[error("transport terminated")] TransportTerminated, diff --git a/crates/rmcp/src/transport/worker.rs b/crates/rmcp/src/transport/worker.rs index 769d448a5..d7c53afd4 100644 --- a/crates/rmcp/src/transport/worker.rs +++ b/crates/rmcp/src/transport/worker.rs @@ -7,6 +7,7 @@ use super::{IntoTransport, Transport}; use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum WorkerQuitReason { #[error("Join error {0}")] Join(#[from] tokio::task::JoinError), diff --git a/crates/rmcp/tests/common/calculator.rs b/crates/rmcp/tests/common/calculator.rs index 5b8cebf7a..22c6d38ef 100644 --- a/crates/rmcp/tests/common/calculator.rs +++ b/crates/rmcp/tests/common/calculator.rs @@ -53,10 +53,7 @@ impl Calculator { impl ServerHandler for Calculator { fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some("A simple calculator".into()), - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("A simple calculator") } } diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index 654413fa8..2084981a2 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -3,15 +3,17 @@ use std::{ sync::{Arc, Mutex}, }; -use rmcp::{ - ClientHandler, ErrorData as McpError, RoleClient, RoleServer, ServerHandler, - model::*, - service::{NotificationContext, RequestContext}, -}; +#[cfg(feature = "client")] +use rmcp::service::NotificationContext; +#[cfg(feature = "client")] +use rmcp::{ClientHandler, RoleClient}; +use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, model::*, service::RequestContext}; +#[cfg(feature = "client")] use serde_json::json; use tokio::sync::Notify; #[derive(Clone)] +#[allow(dead_code)] pub struct TestClientHandler { pub honor_this_server: bool, pub honor_all_servers: bool, @@ -46,6 +48,7 @@ impl TestClientHandler { } } +#[cfg(feature = "client")] impl ClientHandler for TestClientHandler { async fn create_message( &self, @@ -71,11 +74,11 @@ impl ClientHandler for TestClientHandler { _ => "Test response without context", }; - Ok(CreateMessageResult { - message: SamplingMessage::assistant_text(response.to_string()), - model: "test-model".to_string(), - stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), - }) + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text(response.to_string()), + "test-model".to_string(), + ) + .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()))) } fn on_logging_message( @@ -106,10 +109,7 @@ impl TestServer { impl ServerHandler for TestServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_logging().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_logging().build()) } fn set_level( diff --git a/crates/rmcp/tests/test_client_initialization.rs b/crates/rmcp/tests/test_client_initialization.rs index fed6eceed..c9b8f94a2 100644 --- a/crates/rmcp/tests/test_client_initialization.rs +++ b/crates/rmcp/tests/test_client_initialization.rs @@ -1,4 +1,6 @@ // cargo test --features "server client" --package rmcp test_client_initialization +#![cfg(feature = "client")] + mod common; use std::borrow::Cow; diff --git a/crates/rmcp/tests/test_completion.rs b/crates/rmcp/tests/test_completion.rs index bd563cadf..694ae4d9a 100644 --- a/crates/rmcp/tests/test_completion.rs +++ b/crates/rmcp/tests/test_completion.rs @@ -52,15 +52,14 @@ fn test_complete_request_param_serialization() { let mut args = HashMap::new(); args.insert("previous_input".to_string(), "test".to_string()); - let request = CompleteRequestParams { - meta: None, - r#ref: Reference::for_prompt("weather_prompt"), - argument: ArgumentInfo { + let request = CompleteRequestParams::new( + Reference::for_prompt("weather_prompt"), + ArgumentInfo { name: "location".to_string(), value: "San".to_string(), }, - context: Some(CompletionContext::with_arguments(args)), - }; + ) + .with_context(CompletionContext::with_arguments(args)); let json = serde_json::to_value(&request).unwrap(); assert!(json["ref"]["name"].as_str().unwrap() == "weather_prompt"); @@ -196,15 +195,13 @@ fn test_completion_context_empty() { #[test] fn test_mcp_schema_compliance() { // Test that our types serialize correctly according to MCP specification - let request = CompleteRequestParams { - meta: None, - r#ref: Reference::for_resource("file://{path}"), - argument: ArgumentInfo { + let request = CompleteRequestParams::new( + Reference::for_resource("file://{path}"), + ArgumentInfo { name: "path".to_string(), value: "src/".to_string(), }, - context: None, - }; + ); let json_str = serde_json::to_string(&request).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 82537a80c..b83c85772 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -730,10 +730,7 @@ async fn test_server_rejects_unsupported_protocol_version() { impl ServerHandler for TestHandler { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().build()) } } diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index ce8be280e..7d946a2bf 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -141,15 +141,13 @@ async fn test_elicitation_json_rpc_protocol() { let request = JsonRpcRequest { jsonrpc: JsonRpcVersion2_0, id: RequestId::Number(1), - request: CreateElicitationRequest { - method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParams::FormElicitationParams { + request: CreateElicitationRequest::new( + CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Do you want to continue?".to_string(), requested_schema: schema, }, - extensions: Default::default(), - }, + ), }; // Test serialization of complete request @@ -710,30 +708,24 @@ async fn test_elicitation_multi_select_enum() { assert_eq!( schema, &EnumSchema::Multi(MultiSelectEnumSchema::Titled( - TitledMultiSelectEnumSchema { - type_: ArrayTypeConst, - title: None, - description: None, - min_items: Some(1), - max_items: Some(2), - items: TitledItems { - any_of: vec![ - ConstTitle { - const_: "A".to_string(), - title: "A name".to_string() - }, - ConstTitle { - const_: "B".to_string(), - title: "B name".to_string() - }, - ConstTitle { - const_: "C".to_string(), - title: "C name".to_string() - } - ], - }, - default: None - } + TitledMultiSelectEnumSchema::new(TitledItems { + any_of: vec![ + ConstTitle { + const_: "A".to_string(), + title: "A name".to_string() + }, + ConstTitle { + const_: "B".to_string(), + title: "B name".to_string() + }, + ConstTitle { + const_: "C".to_string(), + title: "C name".to_string() + }, + ], + }) + .with_min_items(1) + .with_max_items(2) )) ) } @@ -789,26 +781,20 @@ async fn test_elicitation_single_select_enum() { assert_eq!( schema, &EnumSchema::Single(SingleSelectEnumSchema::Titled( - TitledSingleSelectEnumSchema { - type_: StringTypeConst, - title: None, - description: None, - one_of: vec![ - ConstTitle { - const_: "A".to_string(), - title: "A name".to_string() - }, - ConstTitle { - const_: "B".to_string(), - title: "B name".to_string() - }, - ConstTitle { - const_: "C".to_string(), - title: "C name".to_string() - } - ], - default: None - } + TitledSingleSelectEnumSchema::new(vec![ + ConstTitle { + const_: "A".to_string(), + title: "A name".to_string() + }, + ConstTitle { + const_: "B".to_string(), + title: "B name".to_string() + }, + ConstTitle { + const_: "C".to_string(), + title: "C name".to_string() + } + ]) )) ) } @@ -850,11 +836,8 @@ async fn test_elicitation_direction_server_to_client() { assert_eq!(serialized["requestedSchema"]["type"], "object"); // Test that elicitation requests are part of ServerRequest - let _server_request = ServerRequest::CreateElicitationRequest(CreateElicitationRequest { - method: ElicitationCreateRequestMethod, - params: elicitation_request, - extensions: Default::default(), - }); + let _server_request = + ServerRequest::CreateElicitationRequest(CreateElicitationRequest::new(elicitation_request)); // Test that client can respond with elicitation results let client_result = ClientResult::CreateElicitationResult(CreateElicitationResult { @@ -889,15 +872,13 @@ async fn test_elicitation_json_rpc_direction() { // 1. Server creates elicitation request let server_request = ServerJsonRpcMessage::request( - ServerRequest::CreateElicitationRequest(CreateElicitationRequest { - method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParams::FormElicitationParams { + ServerRequest::CreateElicitationRequest(CreateElicitationRequest::new( + CreateElicitationRequestParams::FormElicitationParams { meta: None, message: "Do you want to continue?".to_string(), requested_schema: schema, }, - extensions: Default::default(), - }), + )), RequestId::Number(1), ); @@ -1051,15 +1032,14 @@ async fn test_elicitation_capability_structure() { #[tokio::test] async fn test_client_capabilities_with_elicitation() { // Test ClientCapabilities with elicitation capability - let capabilities = ClientCapabilities { - elicitation: Some(ElicitationCapability { + let capabilities = ClientCapabilities::builder() + .enable_elicitation_with(ElicitationCapability { form: Some(FormElicitationCapability { schema_validation: Some(true), }), url: None, - }), - ..Default::default() - }; + }) + .build(); // Verify elicitation capability is present assert!(capabilities.elicitation.is_some()); @@ -1084,10 +1064,7 @@ async fn test_client_capabilities_with_elicitation() { ); // Test ClientCapabilities without elicitation - let capabilities_without = ClientCapabilities { - elicitation: None, - ..Default::default() - }; + let capabilities_without = ClientCapabilities::default(); assert!(capabilities_without.elicitation.is_none()); } @@ -1096,27 +1073,17 @@ async fn test_client_capabilities_with_elicitation() { #[tokio::test] async fn test_initialize_request_with_elicitation() { // Test InitializeRequestParam with elicitation capability - let init_param = InitializeRequestParams { - meta: None, - protocol_version: ProtocolVersion::LATEST, - capabilities: ClientCapabilities { - elicitation: Some(ElicitationCapability { + let init_param = InitializeRequestParams::new( + ClientCapabilities::builder() + .enable_elicitation_with(ElicitationCapability { form: Some(FormElicitationCapability { schema_validation: Some(true), }), url: None, - }), - ..Default::default() - }, - client_info: Implementation { - name: "test-client".to_string(), - version: "1.0.0".to_string(), - title: None, - description: None, - website_url: None, - icons: None, - }, - }; + }) + .build(), + Implementation::new("test-client", "1.0.0"), + ); // Verify the structure assert!(init_param.capabilities.elicitation.is_some()); @@ -1148,49 +1115,27 @@ async fn test_capability_checking_logic() { // Simulate the logic that would be used in supports_elicitation() // Case 1: Client with elicitation capability - let client_with_capability = InitializeRequestParams { - meta: None, - protocol_version: ProtocolVersion::LATEST, - capabilities: ClientCapabilities { - elicitation: Some(ElicitationCapability { + let client_with_capability = InitializeRequestParams::new( + ClientCapabilities::builder() + .enable_elicitation_with(ElicitationCapability { form: Some(FormElicitationCapability { schema_validation: Some(true), }), url: None, - }), - ..Default::default() - }, - client_info: Implementation { - name: "test-client".to_string(), - version: "1.0.0".to_string(), - title: None, - description: None, - website_url: None, - icons: None, - }, - }; + }) + .build(), + Implementation::new("test-client", "1.0.0"), + ); // Simulate supports_elicitation() logic let supports_elicitation = client_with_capability.capabilities.elicitation.is_some(); assert!(supports_elicitation); // Case 2: Client without elicitation capability - let client_without_capability = InitializeRequestParams { - meta: None, - protocol_version: ProtocolVersion::LATEST, - capabilities: ClientCapabilities { - elicitation: None, - ..Default::default() - }, - client_info: Implementation { - name: "test-client".to_string(), - version: "1.0.0".to_string(), - title: None, - description: None, - website_url: None, - icons: None, - }, - }; + let client_without_capability = InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("test-client", "1.0.0"), + ); let supports_elicitation = client_without_capability.capabilities.elicitation.is_some(); assert!(!supports_elicitation); } @@ -1910,16 +1855,14 @@ async fn test_url_elicitation_json_rpc_protocol() { let request = JsonRpcRequest { jsonrpc: JsonRpcVersion2_0, id: RequestId::Number(1), - request: CreateElicitationRequest { - method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParams::UrlElicitationParams { + request: CreateElicitationRequest::new( + CreateElicitationRequestParams::UrlElicitationParams { meta: None, message: "Please authorize this action at the following URL".to_string(), url: "https://auth.example.com/authorize/abc123".to_string(), elicitation_id: "auth-request-456".to_string(), }, - extensions: Default::default(), - }, + ), }; // Test serialization of complete request @@ -1977,11 +1920,7 @@ async fn test_elicitation_completion_notification() { assert_eq!(deserialized.elicitation_id, "elicit-789"); // Test complete notification structure - let notification = ElicitationCompletionNotification { - method: ElicitationCompletionNotificationMethod, - params: notification_params, - extensions: Default::default(), - }; + let notification = ElicitationCompletionNotification::new(notification_params); let json = serde_json::to_value(¬ification).unwrap(); assert_eq!(json["method"], "notifications/elicitation/complete"); @@ -2142,15 +2081,14 @@ async fn test_url_elicitation_required_error_code() { #[tokio::test] async fn test_client_capabilities_elicitation_modes() { // Test with form-only capability - let form_only_caps = ClientCapabilities { - elicitation: Some(ElicitationCapability { + let form_only_caps = ClientCapabilities::builder() + .enable_elicitation_with(ElicitationCapability { form: Some(FormElicitationCapability { schema_validation: Some(true), }), url: None, - }), - ..Default::default() - }; + }) + .build(); let json = serde_json::to_value(&form_only_caps).unwrap(); assert!(json["elicitation"]["form"].is_object()); @@ -2160,13 +2098,12 @@ async fn test_client_capabilities_elicitation_modes() { ); // Test with URL-only capability - let url_only_caps = ClientCapabilities { - elicitation: Some(ElicitationCapability { + let url_only_caps = ClientCapabilities::builder() + .enable_elicitation_with(ElicitationCapability { form: None, url: Some(UrlElicitationCapability::default()), - }), - ..Default::default() - }; + }) + .build(); let json = serde_json::to_value(&url_only_caps).unwrap(); assert!(json["elicitation"]["url"].is_object()); @@ -2179,15 +2116,14 @@ async fn test_client_capabilities_elicitation_modes() { ); // Test with both capabilities - let both_caps = ClientCapabilities { - elicitation: Some(ElicitationCapability { + let both_caps = ClientCapabilities::builder() + .enable_elicitation_with(ElicitationCapability { form: Some(FormElicitationCapability { schema_validation: Some(false), }), url: Some(UrlElicitationCapability::default()), - }), - ..Default::default() - }; + }) + .build(); let json = serde_json::to_value(&both_caps).unwrap(); assert!(json["elicitation"]["form"].is_object()); @@ -2201,11 +2137,8 @@ async fn test_elicitation_completion_in_server_notification() { elicitation_id: "notify-123".to_string(), }; - let completion_notification = ElicitationCompletionNotification { - method: ElicitationCompletionNotificationMethod, - params: notification_param.clone(), - extensions: Default::default(), - }; + let completion_notification = + ElicitationCompletionNotification::new(notification_param.clone()); // Test that it's part of ServerNotification let server_notification = diff --git a/crates/rmcp/tests/test_handler_wrappers.rs b/crates/rmcp/tests/test_handler_wrappers.rs index e1faddc91..18ec242ba 100644 --- a/crates/rmcp/tests/test_handler_wrappers.rs +++ b/crates/rmcp/tests/test_handler_wrappers.rs @@ -4,8 +4,8 @@ mod common; use std::sync::Arc; -use common::handlers::{TestClientHandler, TestServer}; -use rmcp::{ClientHandler, ServerHandler}; +use common::handlers::TestServer; +use rmcp::ServerHandler; #[test] fn test_wrapped_server_handlers() { @@ -16,8 +16,11 @@ fn test_wrapped_server_handlers() { accepts_server_handler(Arc::new(TestServer::new())); } +#[cfg(feature = "client")] #[test] fn test_wrapped_client_handlers() { + use common::handlers::TestClientHandler; + use rmcp::ClientHandler; // This test asserts that, when T: ClientHandler, both Box and Arc also implement ClientHandler. fn accepts_client_handler(_handler: H) {} diff --git a/crates/rmcp/tests/test_logging.rs b/crates/rmcp/tests/test_logging.rs index be63b24fb..11efd84c9 100644 --- a/crates/rmcp/tests/test_logging.rs +++ b/crates/rmcp/tests/test_logging.rs @@ -63,7 +63,7 @@ async fn test_logging_spec_compliance() -> anyhow::Result<()> { ] { client .peer() - .set_level(SetLevelRequestParams { meta: None, level }) + .set_level(SetLevelRequestParams::new(level)) .await?; // Wait for each message response @@ -121,10 +121,7 @@ async fn test_logging_user_scenarios() -> anyhow::Result<()> { // Test 1: Error reporting scenario client .peer() - .set_level(SetLevelRequestParams { - meta: None, - level: LoggingLevel::Error, - }) + .set_level(SetLevelRequestParams::new(LoggingLevel::Error)) .await?; receive_signal.notified().await; // Wait for response { @@ -148,10 +145,7 @@ async fn test_logging_user_scenarios() -> anyhow::Result<()> { // Test 2: Debug scenario client .peer() - .set_level(SetLevelRequestParams { - meta: None, - level: LoggingLevel::Debug, - }) + .set_level(SetLevelRequestParams::new(LoggingLevel::Debug)) .await?; receive_signal.notified().await; // Wait for response { @@ -172,10 +166,7 @@ async fn test_logging_user_scenarios() -> anyhow::Result<()> { // Test 3: Production monitoring scenario client .peer() - .set_level(SetLevelRequestParams { - meta: None, - level: LoggingLevel::Info, - }) + .set_level(SetLevelRequestParams::new(LoggingLevel::Info)) .await?; receive_signal.notified().await; // Wait for response { @@ -259,7 +250,7 @@ async fn test_logging_edge_cases() -> anyhow::Result<()> { ] { client .peer() - .set_level(SetLevelRequestParams { meta: None, level }) + .set_level(SetLevelRequestParams::new(level)) .await?; receive_signal.notified().await; @@ -319,7 +310,7 @@ async fn test_logging_optional_fields() -> anyhow::Result<()> { for level in [LoggingLevel::Info, LoggingLevel::Debug] { client .peer() - .set_level(SetLevelRequestParams { meta: None, level }) + .set_level(SetLevelRequestParams::new(level)) .await?; // Wait for each message response diff --git a/crates/rmcp/tests/test_message_protocol.rs b/crates/rmcp/tests/test_message_protocol.rs index 7ec3258c0..073486ff9 100644 --- a/crates/rmcp/tests/test_message_protocol.rs +++ b/crates/rmcp/tests/test_message_protocol.rs @@ -39,24 +39,10 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { let client = handler.clone().serve(client_transport).await?; // Test ThisServer context inclusion - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("test message")], - include_context: Some(ContextInclusion::ThisServer), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::ThisServer), + )); let result = handler .handle_request( @@ -90,24 +76,10 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { } // Test AllServers context inclusion - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("test message")], - include_context: Some(ContextInclusion::AllServers), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::AllServers), + )); let result = handler .handle_request( @@ -141,24 +113,10 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { } // Test No context inclusion - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("test message")], - include_context: Some(ContextInclusion::None), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::None), + )); let result = handler .handle_request( @@ -212,24 +170,10 @@ async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> { let client = handler.clone().serve(client_transport).await?; // Test that context requests are ignored - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("test message")], - include_context: Some(ContextInclusion::ThisServer), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::ThisServer), + )); let result = handler .handle_request( @@ -282,27 +226,16 @@ async fn test_message_sequence_integration() -> anyhow::Result<()> { let handler = TestClientHandler::new(true, true); let client = handler.clone().serve(client_transport).await?; - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![ + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![ SamplingMessage::user_text("first message"), SamplingMessage::assistant_text("second message"), ], - include_context: Some(ContextInclusion::ThisServer), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + 100, + ) + .with_include_context(ContextInclusion::ThisServer), + )); let result = handler .handle_request( @@ -359,28 +292,16 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { let client = handler.clone().serve(client_transport).await?; // Test valid sequence: User -> Assistant -> User - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![ + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![ SamplingMessage::user_text("first user message"), SamplingMessage::assistant_text("first assistant response"), SamplingMessage::user_text("second user message"), ], - include_context: None, - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + 100, + ), + )); let result = handler .handle_request( @@ -398,24 +319,12 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { assert!(matches!(result, ClientResult::CreateMessageResult(_))); // Test invalid: No user message - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::assistant_text("assistant message")], - include_context: None, - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![SamplingMessage::assistant_text("assistant message")], + 100, + ), + )); let result = handler .handle_request( @@ -452,24 +361,10 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { let client = handler.clone().serve(client_transport).await?; // Test ThisServer is honored - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("test message")], - include_context: Some(ContextInclusion::ThisServer), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::ThisServer), + )); let result = handler .handle_request( @@ -501,24 +396,10 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { } // Test AllServers is ignored - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("test message")], - include_context: Some(ContextInclusion::AllServers), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test message")], 100) + .with_include_context(ContextInclusion::AllServers), + )); let result = handler .handle_request( @@ -567,24 +448,10 @@ async fn test_context_inclusion() -> anyhow::Result<()> { let client = handler.clone().serve(client_transport).await?; // Test context handling - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("test")], - include_context: Some(ContextInclusion::ThisServer), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("test")], 100) + .with_include_context(ContextInclusion::ThisServer), + )); let result = handler .handle_request( diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index 018374212..7d930678e 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -15,14 +15,13 @@ pub struct Server {} impl ServerHandler for Server { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_resources() .enable_resources_subscribe() .enable_resources_list_changed() .build(), - ..Default::default() - } + ) } async fn subscribe( @@ -87,10 +86,7 @@ async fn test_server_notification() -> anyhow::Result<()> { .serve(client_transport) .await?; client - .subscribe(SubscribeRequestParams { - meta: None, - uri: "test://test-resource".to_owned(), - }) + .subscribe(SubscribeRequestParams::new("test://test-resource")) .await?; receive_signal.notified().await; client.cancel().await?; diff --git a/crates/rmcp/tests/test_progress_subscriber.rs b/crates/rmcp/tests/test_progress_subscriber.rs index 521219a3b..092f35747 100644 --- a/crates/rmcp/tests/test_progress_subscriber.rs +++ b/crates/rmcp/tests/test_progress_subscriber.rs @@ -107,12 +107,9 @@ async fn test_progress_subscriber() -> anyhow::Result<()> { let client_service = client.serve(transport_client).await?; let handle = client_service .send_cancellable_request( - ClientRequest::CallToolRequest(Request::new(CallToolRequestParams { - meta: None, - name: "some_progress".into(), - arguments: None, - task: None, - })), + ClientRequest::CallToolRequest(Request::new(CallToolRequestParams::new( + "some_progress", + ))), PeerRequestOptions::no_options(), ) .await?; diff --git a/crates/rmcp/tests/test_prompt_macro_annotations.rs b/crates/rmcp/tests/test_prompt_macro_annotations.rs index f313927f5..caa017936 100644 --- a/crates/rmcp/tests/test_prompt_macro_annotations.rs +++ b/crates/rmcp/tests/test_prompt_macro_annotations.rs @@ -109,13 +109,11 @@ async fn complex_args_prompt( _server: &TestServer, _args: Parameters, ) -> GetPromptResult { - GetPromptResult { - description: Some("Complex args result".to_string()), - messages: vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - "Complex response", - )], - } + GetPromptResult::new(vec![PromptMessage::new_text( + PromptMessageRole::Assistant, + "Complex response", + )]) + .with_description("Complex args result") } // Test sync prompt diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs index 2407571d7..a41d2e7e5 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -107,22 +107,20 @@ impl GenericServer { #[prompt(description = "Get contextual help from the service")] async fn get_help(&self) -> GetPromptResult { let context = self.data_service.get_context(); - GetPromptResult { - description: Some("Contextual help based on service data".to_string()), - messages: vec![ - PromptMessage::new_text( - PromptMessageRole::User, - "I need help with the current context.".to_string(), - ), - PromptMessage::new_text( - PromptMessageRole::Assistant, - format!( - "Based on the context '{}', here's how I can help...", - context - ), + GetPromptResult::new(vec![ + PromptMessage::new_text( + PromptMessageRole::User, + "I need help with the current context.".to_string(), + ), + PromptMessage::new_text( + PromptMessageRole::Assistant, + format!( + "Based on the context '{}', here's how I can help...", + context ), - ], - } + ), + ]) + .with_description("Contextual help based on service data") } } @@ -250,13 +248,11 @@ impl OptionalSchemaTester { None => "Received null count".to_string(), }; - GetPromptResult { - description: Some("Test result for optional i64".to_string()), - messages: vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - message, - )], - } + GetPromptResult::new(vec![PromptMessage::new_text( + PromptMessageRole::Assistant, + message, + )]) + .with_description("Test result for optional i64") } } @@ -327,10 +323,8 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { // Test null case let result = client - .get_prompt(GetPromptRequestParams { - meta: None, - name: "test_optional_i64".into(), - arguments: Some( + .get_prompt( + GetPromptRequestParams::new("test_optional_i64").with_arguments( serde_json::json!({ "count": null, "mandatory_field": "test_null" @@ -339,7 +333,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { .unwrap() .clone(), ), - }) + ) .await?; let result_text = match &result.messages.first().unwrap().content { @@ -354,10 +348,8 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { // Test Some case let some_result = client - .get_prompt(GetPromptRequestParams { - meta: None, - name: "test_optional_i64".into(), - arguments: Some( + .get_prompt( + GetPromptRequestParams::new("test_optional_i64").with_arguments( serde_json::json!({ "count": 42, "mandatory_field": "test_some" @@ -366,7 +358,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { .unwrap() .clone(), ), - }) + ) .await?; let some_result_text = match &some_result.messages.first().unwrap().content { diff --git a/crates/rmcp/tests/test_prompt_routers.rs b/crates/rmcp/tests/test_prompt_routers.rs index 0917a7f1d..53b13b131 100644 --- a/crates/rmcp/tests/test_prompt_routers.rs +++ b/crates/rmcp/tests/test_prompt_routers.rs @@ -64,13 +64,11 @@ async fn async_function(Parameters(Request { fields }): Parameters) -> #[rmcp::prompt] fn async_function2(_callee: &TestHandler) -> BoxFuture<'_, GetPromptResult> { Box::pin(async move { - GetPromptResult { - description: Some("Async function 2".to_string()), - messages: vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - "Async function 2 response", - )], - } + GetPromptResult::new(vec![PromptMessage::new_text( + PromptMessageRole::Assistant, + "Async function 2 response", + )]) + .with_description("Async function 2") }) } diff --git a/crates/rmcp/tests/test_resource_link_integration.rs b/crates/rmcp/tests/test_resource_link_integration.rs index ab6635258..7507d71e0 100644 --- a/crates/rmcp/tests/test_resource_link_integration.rs +++ b/crates/rmcp/tests/test_resource_link_integration.rs @@ -82,10 +82,10 @@ fn test_resource_link_roundtrip() { } // Test with prompt message - let prompt_message = PromptMessage { - role: PromptMessageRole::User, - content: PromptMessageContent::resource_link(resource.no_annotation()), - }; + let prompt_message = PromptMessage::new( + PromptMessageRole::User, + PromptMessageContent::resource_link(resource.no_annotation()), + ); let prompt_json = serde_json::to_string(&prompt_message).unwrap(); let prompt_deserialized: PromptMessage = serde_json::from_str(&prompt_json).unwrap(); diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index e5191d3c1..d885e46ce 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -23,27 +23,20 @@ async fn test_basic_sampling_message_creation() -> Result<()> { #[tokio::test] async fn test_sampling_request_params() -> Result<()> { - let params = CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("Hello, world!")], - model_preferences: Some(ModelPreferences { - hints: Some(vec![ModelHint { - name: Some("claude".to_string()), - }]), - cost_priority: Some(0.5), - speed_priority: Some(0.8), - intelligence_priority: Some(0.7), - }), - system_prompt: Some("You are a helpful assistant.".to_string()), - temperature: Some(0.7), - max_tokens: 100, - stop_sequences: Some(vec!["STOP".to_string()]), - include_context: Some(ContextInclusion::None), - metadata: Some(serde_json::json!({"test": "value"})), - tools: None, - tool_choice: None, - }; + let params = + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("Hello, world!")], 100) + .with_model_preferences( + ModelPreferences::new() + .with_hints(vec![ModelHint::new("claude")]) + .with_cost_priority(0.5) + .with_speed_priority(0.8) + .with_intelligence_priority(0.7), + ) + .with_system_prompt("You are a helpful assistant.") + .with_temperature(0.7) + .with_stop_sequences(vec!["STOP".to_string()]) + .with_include_context(ContextInclusion::None) + .with_metadata(serde_json::json!({"test": "value"})); let json = serde_json::to_string(¶ms)?; let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; @@ -58,11 +51,11 @@ async fn test_sampling_request_params() -> Result<()> { #[tokio::test] async fn test_sampling_result_structure() -> Result<()> { - let result = CreateMessageResult { - message: SamplingMessage::assistant_text("The capital of France is Paris."), - model: "test-model".to_string(), - stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), - }; + let result = CreateMessageResult::new( + SamplingMessage::assistant_text("The capital of France is Paris."), + "test-model".to_string(), + ) + .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string())); let json = serde_json::to_string(&result)?; let deserialized: CreateMessageResult = serde_json::from_str(&json)?; @@ -112,31 +105,22 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("What is the capital of France?")], - include_context: Some(ContextInclusion::ThisServer), - model_preferences: Some(ModelPreferences { - hints: Some(vec![ModelHint { - name: Some("test-model".to_string()), - }]), - cost_priority: Some(0.5), - speed_priority: Some(0.8), - intelligence_priority: Some(0.7), - }), - system_prompt: Some("You are a helpful assistant.".to_string()), - temperature: Some(0.7), - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("What is the capital of France?")], + 100, + ) + .with_include_context(ContextInclusion::ThisServer) + .with_model_preferences( + ModelPreferences::new() + .with_hints(vec![ModelHint::new("test-model")]) + .with_cost_priority(0.5) + .with_speed_priority(0.8) + .with_intelligence_priority(0.7), + ) + .with_system_prompt("You are a helpful assistant.") + .with_temperature(0.7), + )); let result = handler .handle_request( @@ -196,24 +180,10 @@ async fn test_sampling_no_context_inclusion() -> Result<()> { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("Hello")], - include_context: Some(ContextInclusion::None), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 50, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("Hello")], 50) + .with_include_context(ContextInclusion::None), + )); let result = handler .handle_request( @@ -269,26 +239,15 @@ async fn test_sampling_error_invalid_message_sequence() -> Result<()> { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let request = ServerRequest::CreateMessageRequest(CreateMessageRequest { - method: Default::default(), - params: CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::assistant_text( + let request = ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new( + vec![SamplingMessage::assistant_text( "I'm an assistant message without a user message", )], - include_context: Some(ContextInclusion::None), - model_preferences: None, - system_prompt: None, - temperature: None, - max_tokens: 50, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }, - extensions: Default::default(), - }); + 50, + ) + .with_include_context(ContextInclusion::None), + )); let result = handler .handle_request( @@ -357,22 +316,14 @@ async fn test_sampling_with_tools() -> Result<()> { ), ); - let params = CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text( + let params = CreateMessageRequestParams::new( + vec![SamplingMessage::user_text( "What's the weather in San Francisco?", )], - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: Some(vec![tool]), - tool_choice: Some(ToolChoice::auto()), - }; + 100, + ) + .with_tools(vec![tool]) + .with_tool_choice(ToolChoice::auto()); let json = serde_json::to_string(¶ms)?; let deserialized: CreateMessageRequestParams = serde_json::from_str(&json)?; @@ -472,8 +423,8 @@ async fn test_sampling_message_with_tool_result() -> Result<()> { #[tokio::test] async fn test_create_message_result_tool_use_stop_reason() -> Result<()> { - let result = CreateMessageResult { - message: SamplingMessage::assistant_tool_use( + let result = CreateMessageResult::new( + SamplingMessage::assistant_tool_use( "call_123", "get_weather", serde_json::json!({ @@ -483,9 +434,9 @@ async fn test_create_message_result_tool_use_stop_reason() -> Result<()> { .unwrap() .clone(), ), - model: "test-model".to_string(), - stop_reason: Some(CreateMessageResult::STOP_REASON_TOOL_USE.to_string()), - }; + "test-model".to_string(), + ) + .with_stop_reason(Some(CreateMessageResult::STOP_REASON_TOOL_USE.to_string())); let json = serde_json::to_string(&result)?; let deserialized: CreateMessageResult = serde_json::from_str(&json)?; @@ -623,23 +574,13 @@ async fn test_content_conversion_unsupported_variants() { #[tokio::test] async fn test_validate_rejects_tool_use_in_user_message() { - let params = CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::new( + let params = CreateMessageRequestParams::new( + vec![SamplingMessage::new( Role::User, SamplingMessageContent::tool_use("call_1", "some_tool", Default::default()), )], - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }; + 100, + ); let err = params.validate().unwrap_err(); assert!( @@ -650,23 +591,13 @@ async fn test_validate_rejects_tool_use_in_user_message() { #[tokio::test] async fn test_validate_rejects_tool_result_in_assistant_message() { - let params = CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::new( + let params = CreateMessageRequestParams::new( + vec![SamplingMessage::new( Role::Assistant, SamplingMessageContent::tool_result("call_1", vec![Content::text("result")]), )], - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }; + 100, + ); let err = params.validate().unwrap_err(); assert!( @@ -677,26 +608,16 @@ async fn test_validate_rejects_tool_result_in_assistant_message() { #[tokio::test] async fn test_validate_rejects_mixed_content_with_tool_result() { - let params = CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::new_multiple( + let params = CreateMessageRequestParams::new( + vec![SamplingMessage::new_multiple( Role::User, vec![ SamplingMessageContent::tool_result("call_1", vec![Content::text("result")]), SamplingMessageContent::text("some extra text"), ], )], - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }; + 100, + ); let err = params.validate().unwrap_err(); assert!( @@ -707,23 +628,13 @@ async fn test_validate_rejects_mixed_content_with_tool_result() { #[tokio::test] async fn test_validate_rejects_unbalanced_tool_use_result() { - let params = CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![ + let params = CreateMessageRequestParams::new( + vec![ SamplingMessage::user_text("Hello"), SamplingMessage::assistant_tool_use("call_1", "some_tool", Default::default()), ], - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }; + 100, + ); let err = params.validate().unwrap_err(); assert!( @@ -734,23 +645,13 @@ async fn test_validate_rejects_unbalanced_tool_use_result() { #[tokio::test] async fn test_validate_rejects_tool_result_without_matching_use() { - let params = CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![ + let params = CreateMessageRequestParams::new( + vec![ SamplingMessage::user_text("Hello"), SamplingMessage::user_tool_result("nonexistent_call", vec![Content::text("result")]), ], - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }; + 100, + ); let err = params.validate().unwrap_err(); assert!( @@ -761,10 +662,8 @@ async fn test_validate_rejects_tool_result_without_matching_use() { #[tokio::test] async fn test_validate_accepts_valid_tool_conversation() { - let params = CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![ + let params = CreateMessageRequestParams::new( + vec![ SamplingMessage::user_text("What's the weather?"), SamplingMessage::assistant_tool_use( "call_1", @@ -777,27 +676,19 @@ async fn test_validate_accepts_valid_tool_conversation() { SamplingMessage::user_tool_result("call_1", vec![Content::text("72°F and sunny")]), SamplingMessage::assistant_text("It's 72°F and sunny in SF."), ], - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - max_tokens: 100, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }; + 100, + ); assert!(params.validate().is_ok()); } #[tokio::test] async fn test_create_message_result_validate_rejects_user_role() { - let result = CreateMessageResult { - message: SamplingMessage::user_text("This should not be a user message"), - model: "test-model".to_string(), - stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), - }; + let result = CreateMessageResult::new( + SamplingMessage::user_text("This should not be a user message"), + "test-model".to_string(), + ) + .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string())); let err = result.validate().unwrap_err(); assert!( @@ -808,11 +699,11 @@ async fn test_create_message_result_validate_rejects_user_role() { #[tokio::test] async fn test_create_message_result_validate_accepts_assistant_role() { - let result = CreateMessageResult { - message: SamplingMessage::assistant_text("Hello!"), - model: "test-model".to_string(), - stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), - }; + let result = CreateMessageResult::new( + SamplingMessage::assistant_text("Hello!"), + "test-model".to_string(), + ) + .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string())); assert!(result.validate().is_ok()); } diff --git a/crates/rmcp/tests/test_sse_concurrent_streams.rs b/crates/rmcp/tests/test_sse_concurrent_streams.rs index b54ed5562..33625a741 100644 --- a/crates/rmcp/tests/test_sse_concurrent_streams.rs +++ b/crates/rmcp/tests/test_sse_concurrent_streams.rs @@ -17,7 +17,7 @@ use std::time::Duration; use futures::StreamExt; use rmcp::{ RoleServer, ServerHandler, - model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo, ToolsCapability}, + model::{Implementation, ServerCapabilities, ServerInfo, ToolsCapability}, service::NotificationContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -45,20 +45,14 @@ impl TestServer { impl ServerHandler for TestServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::LATEST, - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools_with(ToolsCapability { list_changed: Some(true), }) .build(), - server_info: Implementation { - name: "test-server".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - instructions: None, - } + ) + .with_server_info(Implementation::new("test-server", "1.0.0")) } async fn on_initialized(&self, context: NotificationContext) { diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index 0edb8bce8..082d3e439 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -327,12 +327,7 @@ async fn test_empty_content_deserializes_as_call_tool_result_variant() { #[tokio::test] async fn test_empty_content_roundtrip() { - let result = CallToolResult { - content: vec![], - structured_content: None, - is_error: Some(false), - meta: None, - }; + let result = CallToolResult::success(vec![]); let v = serde_json::to_value(&result).unwrap(); assert_eq!(v["content"], json!([])); let deserialized: CallToolResult = serde_json::from_value(v).unwrap(); diff --git a/crates/rmcp/tests/test_task_support_validation.rs b/crates/rmcp/tests/test_task_support_validation.rs index 016ed2403..cd9997684 100644 --- a/crates/rmcp/tests/test_task_support_validation.rs +++ b/crates/rmcp/tests/test_task_support_validation.rs @@ -5,6 +5,7 @@ //! - `Required`: MUST be invoked as a task, returns -32601 otherwise //! - `Forbidden`: MUST NOT be invoked as a task, returns error otherwise //! - `Optional`: MAY be invoked either way +#![cfg(feature = "client")] use rmcp::{ ClientHandler, ServerHandler, ServiceError, ServiceExt, @@ -93,12 +94,7 @@ async fn test_required_task_tool_without_task_returns_method_not_found() -> anyh // Call the task-required tool without a task - should fail with -32601 let result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "required_task_tool".into(), - arguments: None, - task: None, // No task provided! - }) + .call_tool(CallToolRequestParams::new("required_task_tool")) .await; // Should be an error with code -32601 (METHOD_NOT_FOUND) @@ -147,12 +143,7 @@ async fn test_forbidden_task_tool_with_task_returns_error() -> anyhow::Result<() // Call the forbidden task tool WITH a task - should fail let result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "forbidden_task_tool".into(), - arguments: None, - task: make_task(), // Task provided but forbidden! - }) + .call_tool(CallToolRequestParams::new("forbidden_task_tool").with_task(make_task())) .await; // Should be an error with code INVALID_PARAMS @@ -201,12 +192,7 @@ async fn test_forbidden_task_tool_without_task_succeeds() -> anyhow::Result<()> // Call the forbidden task tool WITHOUT a task - should succeed let result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "forbidden_task_tool".into(), - arguments: None, - task: None, // No task - allowed for forbidden - }) + .call_tool(CallToolRequestParams::new("forbidden_task_tool")) .await; assert!( @@ -242,12 +228,7 @@ async fn test_optional_task_tool_without_task_succeeds() -> anyhow::Result<()> { // Call the optional task tool WITHOUT a task - should succeed let result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "optional_task_tool".into(), - arguments: None, - task: None, // No task - allowed for optional - }) + .call_tool(CallToolRequestParams::new("optional_task_tool")) .await; assert!( diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index 837198cbb..bd06ca6ea 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -308,10 +308,8 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { // Test null case let result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "test_optional_i64".into(), - arguments: Some( + .call_tool( + CallToolRequestParams::new("test_optional_i64").with_arguments( serde_json::json!({ "count": null, "mandatory_field": "test_null" @@ -320,8 +318,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { .unwrap() .clone(), ), - task: None, - }) + ) .await?; let result_text = result @@ -338,10 +335,8 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { // Test Some case let some_result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "test_optional_i64".into(), - arguments: Some( + .call_tool( + CallToolRequestParams::new("test_optional_i64").with_arguments( serde_json::json!({ "count": 42, "mandatory_field": "test_some" @@ -350,8 +345,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { .unwrap() .clone(), ), - task: None, - }) + ) .await?; let some_result_text = some_result diff --git a/crates/rmcp/tests/test_tool_result_meta.rs b/crates/rmcp/tests/test_tool_result_meta.rs index 78e1809ef..f64d8e3f1 100644 --- a/crates/rmcp/tests/test_tool_result_meta.rs +++ b/crates/rmcp/tests/test_tool_result_meta.rs @@ -6,12 +6,7 @@ fn serialize_tool_result_with_meta() { let content = vec![Content::text("ok")]; let mut meta = Meta::new(); meta.insert("foo".to_string(), json!("bar")); - let result = CallToolResult { - content, - structured_content: None, - is_error: Some(false), - meta: Some(meta), - }; + let result = CallToolResult::success(content).with_meta(Some(meta)); let v = serde_json::to_value(&result).unwrap(); let expected = json!({ "content": [{"type":"text","text":"ok"}], diff --git a/examples/clients/src/collection.rs b/examples/clients/src/collection.rs index a4c734824..62088e48d 100644 --- a/examples/clients/src/collection.rs +++ b/examples/clients/src/collection.rs @@ -46,12 +46,14 @@ async fn main() -> Result<()> { // Call tool 'git_status' with arguments = {"repo_path": "."} let _tool_result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "git_status".into(), - arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(), - task: None, - }) + .call_tool( + CallToolRequestParams::new("git_status").with_arguments( + serde_json::json!({ "repo_path": "." }) + .as_object() + .unwrap() + .clone(), + ), + ) .await?; } for (_, service) in clients_map { diff --git a/examples/clients/src/everything_stdio.rs b/examples/clients/src/everything_stdio.rs index 763a880a6..8a7fce7de 100644 --- a/examples/clients/src/everything_stdio.rs +++ b/examples/clients/src/everything_stdio.rs @@ -37,23 +37,19 @@ async fn main() -> Result<()> { // Call tool echo let tool_result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "echo".into(), - arguments: Some(object!({ "message": "hi from rmcp" })), - task: None, - }) + .call_tool( + CallToolRequestParams::new("echo") + .with_arguments(object!({ "message": "hi from rmcp" })), + ) .await?; tracing::info!("Tool result for echo: {tool_result:#?}"); // Call tool longRunningOperation let tool_result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "longRunningOperation".into(), - arguments: Some(object!({ "duration": 3, "steps": 1 })), - task: None, - }) + .call_tool( + CallToolRequestParams::new("longRunningOperation") + .with_arguments(object!({ "duration": 3, "steps": 1 })), + ) .await?; tracing::info!("Tool result for longRunningOperation: {tool_result:#?}"); @@ -63,10 +59,7 @@ async fn main() -> Result<()> { // Read resource let resource = client - .read_resource(ReadResourceRequestParams { - meta: None, - uri: "test://static/resource/3".into(), - }) + .read_resource(ReadResourceRequestParams::new("test://static/resource/3")) .await?; tracing::info!("Resource: {resource:#?}"); @@ -76,21 +69,16 @@ async fn main() -> Result<()> { // Get simple prompt let prompt = client - .get_prompt(GetPromptRequestParams { - meta: None, - name: "simple_prompt".into(), - arguments: None, - }) + .get_prompt(GetPromptRequestParams::new("simple_prompt")) .await?; tracing::info!("Prompt - simple: {prompt:#?}"); // Get complex prompt (returns text & image) let prompt = client - .get_prompt(GetPromptRequestParams { - meta: None, - name: "complex_prompt".into(), - arguments: Some(object!({ "temperature": "0.5", "style": "formal" })), - }) + .get_prompt( + GetPromptRequestParams::new("complex_prompt") + .with_arguments(object!({ "temperature": "0.5", "style": "formal" })), + ) .await?; tracing::info!("Prompt - complex: {prompt:#?}"); diff --git a/examples/clients/src/git_stdio.rs b/examples/clients/src/git_stdio.rs index 9960c16b9..703258b16 100644 --- a/examples/clients/src/git_stdio.rs +++ b/examples/clients/src/git_stdio.rs @@ -39,12 +39,14 @@ async fn main() -> Result<(), RmcpError> { // Call tool 'git_status' with arguments = {"repo_path": "."} let tool_result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "git_status".into(), - arguments: serde_json::json!({ "repo_path": "." }).as_object().cloned(), - task: None, - }) + .call_tool( + CallToolRequestParams::new("git_status").with_arguments( + serde_json::json!({ "repo_path": "." }) + .as_object() + .unwrap() + .clone(), + ), + ) .await?; tracing::info!("Tool result: {tool_result:#?}"); client.cancel().await?; diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index db66a8ed6..89c48738d 100644 --- a/examples/clients/src/progress_client.rs +++ b/examples/clients/src/progress_client.rs @@ -122,16 +122,10 @@ impl ClientHandler for ProgressAwareClient { } fn get_info(&self) -> ClientInfo { - ClientInfo { - meta: None, - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "progress-test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - } + ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("progress-test-client", "1.0.0"), + ) } } @@ -182,12 +176,7 @@ async fn test_stdio_transport(records: u32) -> Result<()> { // Call stream processor tool tracing::info!("Starting to process {} records...", records); let tool_result = service - .call_tool(CallToolRequestParams { - meta: None, - name: "stream_processor".into(), - arguments: None, - task: None, - }) + .call_tool(CallToolRequestParams::new("stream_processor")) .await?; if let Some(content) = tool_result.content.first() { @@ -238,12 +227,7 @@ async fn test_http_transport(http_url: &str, records: u32) -> Result<()> { // Call stream processor tool tracing::info!("Starting to process {} records...", records); let tool_result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "stream_processor".into(), - arguments: None, - task: None, - }) + .call_tool(CallToolRequestParams::new("stream_processor")) .await?; if let Some(content) = tool_result.content.first() { diff --git a/examples/clients/src/sampling_stdio.rs b/examples/clients/src/sampling_stdio.rs index e2a7a6d51..27b9273c0 100644 --- a/examples/clients/src/sampling_stdio.rs +++ b/examples/clients/src/sampling_stdio.rs @@ -40,11 +40,11 @@ impl ClientHandler for SamplingDemoClient { let response_text = self.mock_llm_response(¶ms.messages, params.system_prompt.as_deref()); - Ok(CreateMessageResult { - message: SamplingMessage::assistant_text(response_text), - model: "mock_llm".to_string(), - stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()), - }) + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text(response_text), + "mock_llm".to_string(), + ) + .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()))) } } @@ -98,14 +98,11 @@ async fn main() -> Result<()> { // Test the ask_llm tool tracing::info!("Testing ask_llm tool..."); match client - .call_tool(CallToolRequestParams { - meta: None, - name: "ask_llm".into(), - arguments: Some(object!({ + .call_tool( + CallToolRequestParams::new("ask_llm").with_arguments(object!({ "question": "Hello world" })), - task: None, - }) + ) .await { Ok(result) => tracing::info!("Ask LLM result: {result:#?}"), diff --git a/examples/clients/src/streamable_http.rs b/examples/clients/src/streamable_http.rs index baf1838a3..0c27fd358 100644 --- a/examples/clients/src/streamable_http.rs +++ b/examples/clients/src/streamable_http.rs @@ -17,19 +17,10 @@ async fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); - let client_info = ClientInfo { - meta: None, - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "test sse client".to_string(), - title: None, - version: "0.0.1".to_string(), - description: None, - website_url: None, - icons: None, - }, - }; + let client_info = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("test sse client", "0.0.1"), + ); let client = client_info.serve(transport).await.inspect_err(|e| { tracing::error!("client error: {:?}", e); })?; @@ -43,12 +34,10 @@ async fn main() -> Result<()> { tracing::info!("Available tools: {tools:#?}"); let tool_result = client - .call_tool(CallToolRequestParams { - meta: None, - name: "increment".into(), - arguments: serde_json::json!({}).as_object().cloned(), - task: None, - }) + .call_tool( + CallToolRequestParams::new("increment") + .with_arguments(serde_json::json!({}).as_object().cloned().unwrap()), + ) .await?; tracing::info!("Tool result: {tool_result:#?}"); client.cancel().await?; diff --git a/examples/rig-integration/src/mcp_adaptor.rs b/examples/rig-integration/src/mcp_adaptor.rs index af57935ee..41de15768 100644 --- a/examples/rig-integration/src/mcp_adaptor.rs +++ b/examples/rig-integration/src/mcp_adaptor.rs @@ -41,13 +41,11 @@ impl RigTool for McpToolAdaptor { let server = self.server.clone(); Box::pin(async move { let call_mcp_tool_result = server - .call_tool(CallToolRequestParams { - meta: None, - name: self.tool.name.clone(), - arguments: serde_json::from_str(&args) - .map_err(rig::tool::ToolError::JsonError)?, - task: None, - }) + .call_tool( + CallToolRequestParams::new(self.tool.name.clone()).with_arguments( + serde_json::from_str(&args).map_err(rig::tool::ToolError::JsonError)?, + ), + ) .await .inspect(|result| tracing::info!(?result)) .inspect_err(|error| tracing::error!(%error)) diff --git a/examples/servers/src/common/calculator.rs b/examples/servers/src/common/calculator.rs index e6f97ce0f..2b0ab8e33 100644 --- a/examples/servers/src/common/calculator.rs +++ b/examples/servers/src/common/calculator.rs @@ -49,10 +49,7 @@ impl Calculator { #[tool_handler] impl ServerHandler for Calculator { fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some("A simple calculator".into()), - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("A simple calculator".to_string()) } } diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index e92b142af..1806a9fa3 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -171,10 +171,10 @@ impl Counter { "This is an example prompt with your message here: '{}'", args.message ); - Ok(vec![PromptMessage { - role: PromptMessageRole::User, - content: PromptMessageContent::text(prompt), - }]) + Ok(vec![PromptMessage::new_text( + PromptMessageRole::User, + prompt, + )]) } /// Analyze the current counter value and suggest next steps @@ -202,13 +202,10 @@ impl Counter { ), ]; - Ok(GetPromptResult { - description: Some(format!( - "Counter analysis for reaching {} from {}", - args.goal, current_value - )), - messages, - }) + Ok(GetPromptResult::new(messages).with_description(format!( + "Counter analysis for reaching {} from {}", + args.goal, current_value + ))) } } @@ -217,16 +214,16 @@ impl Counter { #[task_handler] impl ServerHandler for Counter { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::V_2024_11_05, - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_prompts() .enable_resources() .enable_tools() .build(), - server_info: Implementation::from_build_env(), - instructions: Some("This server provides counter tools and prompts. Tools: increment, decrement, get_value, say_hello, echo, sum. Prompts: example_prompt (takes a message), counter_analysis (analyzes counter state with a goal).".to_string()), - } + ) + .with_server_info(Implementation::from_build_env()) + .with_protocol_version(ProtocolVersion::V_2024_11_05) + .with_instructions("This server provides counter tools and prompts. Tools: increment, decrement, get_value, say_hello, echo, sum. Prompts: example_prompt (takes a message), counter_analysis (analyzes counter state with a goal).".to_string()) } async fn list_resources( @@ -246,21 +243,24 @@ impl ServerHandler for Counter { async fn read_resource( &self, - ReadResourceRequestParams { meta: _, uri }: ReadResourceRequestParams, + request: ReadResourceRequestParams, _: RequestContext, ) -> Result { + let uri = &request.uri; match uri.as_str() { "str:////Users/to/some/path/" => { let cwd = "/Users/to/some/path/"; - Ok(ReadResourceResult { - contents: vec![ResourceContents::text(cwd, uri)], - }) + Ok(ReadResourceResult::new(vec![ResourceContents::text( + cwd, + uri.clone(), + )])) } "memo://insights" => { let memo = "Business Intelligence Memo\n\nAnalysis has revealed 5 key insights ..."; - Ok(ReadResourceResult { - contents: vec![ResourceContents::text(memo, uri)], - }) + Ok(ReadResourceResult::new(vec![ResourceContents::text( + memo, + uri.clone(), + )])) } _ => Err(McpError::resource_not_found( "resource_not_found", @@ -364,12 +364,7 @@ mod tests { "source".into(), serde_json::Value::String("integration-test".into()), ); - let params = CallToolRequestParams { - meta: None, - name: "long_task".into(), - arguments: None, - task: Some(task_meta), - }; + let params = CallToolRequestParams::new("long_task").with_task(Some(task_meta)); let response = client_service .send_request(ClientRequest::CallToolRequest(Request::new(params.clone()))) .await?; diff --git a/examples/servers/src/common/generic_service.rs b/examples/servers/src/common/generic_service.rs index de1b9c184..8034d5214 100644 --- a/examples/servers/src/common/generic_service.rs +++ b/examples/servers/src/common/generic_service.rs @@ -77,10 +77,7 @@ impl GenericService { #[tool_handler] impl ServerHandler for GenericService { fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some("generic data service".into()), - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("generic data service".to_string()) } } diff --git a/examples/servers/src/common/progress_demo.rs b/examples/servers/src/common/progress_demo.rs index 8fec1179d..1a613e0c7 100644 --- a/examples/servers/src/common/progress_demo.rs +++ b/examples/servers/src/common/progress_demo.rs @@ -118,15 +118,13 @@ impl ProgressDemo { #[tool_handler] impl ServerHandler for ProgressDemo { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::V_2024_11_05, - capabilities: ServerCapabilities::builder().enable_tools().build(), - server_info: Implementation::from_build_env(), - instructions: Some( + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_protocol_version(ProtocolVersion::V_2024_11_05) + .with_server_info(Implementation::from_build_env()) + .with_instructions( "This server demonstrates progress notifications during long-running operations. \ Use the tools to see real-time progress updates for batch processing" .to_string(), - ), - } + ) } } diff --git a/examples/servers/src/completion_stdio.rs b/examples/servers/src/completion_stdio.rs index e4365cadc..7caa8e8c3 100644 --- a/examples/servers/src/completion_stdio.rs +++ b/examples/servers/src/completion_stdio.rs @@ -292,47 +292,41 @@ impl SqlQueryServer { ] }; - Ok(GetPromptResult { - description: Some(format!( - "SQL Query: {} on {}", - if args.operation.is_empty() { - "Unknown" - } else { - &args.operation - }, - if args.table.is_empty() { - "table" - } else { - &args.table - } - )), - messages, - }) + Ok(GetPromptResult::new(messages).with_description(format!( + "SQL Query: {} on {}", + if args.operation.is_empty() { + "Unknown" + } else { + &args.operation + }, + if args.table.is_empty() { + "table" + } else { + &args.table + } + ))) } } #[prompt_handler] impl ServerHandler for SqlQueryServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_completions() .enable_prompts() .build(), - server_info: Implementation::from_build_env(), - instructions: Some( - "Smart SQL query builder with progressive completion that adapts based on your choices:\n\n\ - Step 1: Choose operation type ('sel' → SELECT, 'ins' → INSERT, 'upd' → UPDATE, 'del' → DELETE)\n\ - Step 2: Specify table name ('users', 'orders', 'products')\n\ - Step 3: Add relevant fields based on operation type:\n\ - • SELECT/UPDATE: columns ('name', 'email', 'id')\n\ - • INSERT: values to insert\n\ - • All: optional WHERE clause\n\n\ - The completion adapts - only relevant fields appear based on your SQL operation!" - .to_string(), - ), - ..Default::default() - } + ) + .with_instructions( + "Smart SQL query builder with progressive completion that adapts based on your choices:\n\n\ + Step 1: Choose operation type ('sel' → SELECT, 'ins' → INSERT, 'upd' → UPDATE, 'del' → DELETE)\n\ + Step 2: Specify table name ('users', 'orders', 'products')\n\ + Step 3: Add relevant fields based on operation type:\n\ + • SELECT/UPDATE: columns ('name', 'email', 'id')\n\ + • INSERT: values to insert\n\ + • All: optional WHERE clause\n\n\ + The completion adapts - only relevant fields appear based on your SQL operation!", + ) } async fn complete( @@ -417,7 +411,7 @@ impl ServerHandler for SqlQueryServer { has_more: Some(false), }; - Ok(CompleteResult { completion }) + Ok(CompleteResult::new(completion)) } } diff --git a/examples/servers/src/elicitation_enum_inference.rs b/examples/servers/src/elicitation_enum_inference.rs index 2ecec3115..27bde508e 100644 --- a/examples/servers/src/elicitation_enum_inference.rs +++ b/examples/servers/src/elicitation_enum_inference.rs @@ -156,14 +156,11 @@ impl ElicitationEnumFormServer { #[tool_handler] impl ServerHandler for ElicitationEnumFormServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - server_info: Implementation::from_build_env(), - instructions: Some( + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::from_build_env()) + .with_instructions( "Simple server demonstrating elicitation for enum selection".to_string(), - ), - ..Default::default() - } + ) } } diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 82f8d696a..3bf38056e 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -154,14 +154,11 @@ impl ElicitationServer { #[tool_handler] impl ServerHandler for ElicitationServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - server_info: Implementation::from_build_env(), - instructions: Some( + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::from_build_env()) + .with_instructions( "Simple server demonstrating elicitation for user name collection".to_string(), - ), - ..Default::default() - } + ) } } diff --git a/examples/servers/src/prompt_stdio.rs b/examples/servers/src/prompt_stdio.rs index 0937c3e2c..812ce0e1b 100644 --- a/examples/servers/src/prompt_stdio.rs +++ b/examples/servers/src/prompt_stdio.rs @@ -174,14 +174,11 @@ impl PromptServer { ), ]; - Ok(GetPromptResult { - description: Some(format!( - "Code review for {} file focusing on {}", - args.language, - focus_areas.join(", ") - )), - messages, - }) + Ok(GetPromptResult::new(messages).with_description(format!( + "Code review for {} file focusing on {}", + args.language, + focus_areas.join(", ") + ))) } /// Data analysis prompt demonstrating context usage @@ -270,13 +267,10 @@ impl PromptServer { )); } - GetPromptResult { - description: Some(format!( - "Writing {} for {} audience with {} tone", - args.content_type, args.audience, tone - )), - messages, - } + GetPromptResult::new(messages).with_description(format!( + "Writing {} for {} audience with {} tone", + args.content_type, args.audience, tone + )) } /// Debug assistant demonstrating error handling patterns @@ -332,14 +326,11 @@ impl PromptServer { "Let's debug this systematically. First, let me understand the error context better.", )); - Ok(GetPromptResult { - description: Some(format!( - "Debugging {} error in {}", - args.error_message.chars().take(50).collect::(), - args.stack.first().map(|s| s.as_str()).unwrap_or("unknown") - )), - messages, - }) + Ok(GetPromptResult::new(messages).with_description(format!( + "Debugging {} error in {}", + args.error_message.chars().take(50).collect::(), + args.stack.first().map(|s| s.as_str()).unwrap_or("unknown") + ))) } /// Learning path prompt that uses server state @@ -376,17 +367,11 @@ impl PromptServer { #[prompt_handler] impl ServerHandler for PromptServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_prompts().build(), - server_info: Implementation::from_build_env(), - instructions: Some( - "This server provides various prompt templates for code review, data analysis, \ + ServerInfo::new(ServerCapabilities::builder().enable_prompts().build()).with_instructions( + "This server provides various prompt templates for code review, data analysis, \ writing assistance, debugging help, and personalized learning paths. \ - All prompts are designed to provide structured, context-aware assistance." - .to_string(), - ), - ..Default::default() - } + All prompts are designed to provide structured, context-aware assistance.", + ) } } diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 297af9d03..bd244d871 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -18,17 +18,12 @@ pub struct SamplingDemoServer; impl ServerHandler for SamplingDemoServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some(concat!( + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions(concat!( "This is a demo server that requests sampling from clients. It provides tools that use LLM capabilities.\n\n", "IMPORTANT: This server requires a client that supports the 'sampling/createMessage' method. ", "Without sampling support, the tools will return errors." - ).into()), - capabilities: ServerCapabilities::builder() - .enable_tools() - .build(), - ..Default::default() - } + )) } async fn call_tool( @@ -48,27 +43,22 @@ impl ServerHandler for SamplingDemoServer { let response = context .peer - .create_message(CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text(question)], - model_preferences: Some(ModelPreferences { - hints: Some(vec![ModelHint { - name: Some("claude".to_string()), - }]), - cost_priority: Some(0.3), - speed_priority: Some(0.8), - intelligence_priority: Some(0.7), - }), - system_prompt: Some("You are a helpful assistant.".to_string()), - include_context: Some(ContextInclusion::None), - temperature: Some(0.7), - max_tokens: 150, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }) + .create_message( + CreateMessageRequestParams::new( + vec![SamplingMessage::user_text(question)], + 150, + ) + .with_model_preferences( + ModelPreferences::new() + .with_hints(vec![ModelHint::new("claude")]) + .with_cost_priority(0.3) + .with_speed_priority(0.8) + .with_intelligence_priority(0.7), + ) + .with_system_prompt("You are a helpful assistant.") + .with_include_context(ContextInclusion::None) + .with_temperature(0.7), + ) .await .map_err(|e| { ErrorData::new( @@ -105,11 +95,10 @@ impl ServerHandler for SamplingDemoServer { _context: RequestContext, ) -> Result { Ok(ListToolsResult { - tools: vec![Tool { - name: "ask_llm".into(), - title: None, - description: Some("Ask a question to the LLM through sampling".into()), - input_schema: Arc::new( + tools: vec![Tool::new( + "ask_llm", + "Ask a question to the LLM through sampling", + Arc::new( serde_json::from_value(serde_json::json!({ "type": "object", "properties": { @@ -122,12 +111,7 @@ impl ServerHandler for SamplingDemoServer { })) .unwrap(), ), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }], + )], meta: None, next_cursor: None, }) diff --git a/examples/simple-chat-client/src/tool.rs b/examples/simple-chat-client/src/tool.rs index 14f073a24..173a0a260 100644 --- a/examples/simple-chat-client/src/tool.rs +++ b/examples/simple-chat-client/src/tool.rs @@ -57,15 +57,11 @@ impl Tool for McpToolAdapter { _ => None, }; println!("arguments: {:?}", arguments); - let call_result = self - .server - .call_tool(CallToolRequestParams { - meta: None, - name: self.tool.name.clone(), - arguments, - task: None, - }) - .await?; + let mut params = CallToolRequestParams::new(self.tool.name.clone()); + if let Some(args) = arguments { + params = params.with_arguments(args); + } + let call_result = self.server.call_tool(params).await?; Ok(call_result) } diff --git a/examples/transport/src/common/calculator.rs b/examples/transport/src/common/calculator.rs index 9ae475dd9..f6d4c2a74 100644 --- a/examples/transport/src/common/calculator.rs +++ b/examples/transport/src/common/calculator.rs @@ -53,10 +53,7 @@ impl Calculator { #[tool_handler] impl ServerHandler for Calculator { fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some("A simple calculator".into()), - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("A simple calculator") } } diff --git a/examples/transport/src/named-pipe.rs b/examples/transport/src/named-pipe.rs index 1231059bc..6f08ef221 100644 --- a/examples/transport/src/named-pipe.rs +++ b/examples/transport/src/named-pipe.rs @@ -48,15 +48,14 @@ async fn main() -> anyhow::Result<()> { println!("Calling sum tool: {}", sum_tool.name); let result = client .peer() - .call_tool(rmcp::model::CallToolRequestParams { - meta: None, - name: sum_tool.name.clone(), - arguments: Some(rmcp::object!({ - "a": 10, - "b": 20 - })), - task: None, - }) + .call_tool( + rmcp::model::CallToolRequestParams::new(sum_tool.name.clone()).with_arguments( + rmcp::object!({ + "a": 10, + "b": 20 + }), + ), + ) .await?; println!("Result: {:?}", result); diff --git a/examples/transport/src/unix_socket.rs b/examples/transport/src/unix_socket.rs index 666a61f3a..a8eb6271d 100644 --- a/examples/transport/src/unix_socket.rs +++ b/examples/transport/src/unix_socket.rs @@ -46,15 +46,14 @@ async fn main() -> anyhow::Result<()> { println!("Calling sum tool: {}", sum_tool.name); let result = client .peer() - .call_tool(rmcp::model::CallToolRequestParams { - meta: None, - name: sum_tool.name.clone(), - arguments: Some(rmcp::object!({ - "a": 10, - "b": 20 - })), - task: None, - }) + .call_tool( + rmcp::model::CallToolRequestParams::new(sum_tool.name.clone()).with_arguments( + rmcp::object!({ + "a": 10, + "b": 20 + }), + ), + ) .await?; println!("Result: {:?}", result); diff --git a/examples/wasi/src/calculator.rs b/examples/wasi/src/calculator.rs index 1806aeffd..a6f63fbe5 100644 --- a/examples/wasi/src/calculator.rs +++ b/examples/wasi/src/calculator.rs @@ -60,10 +60,7 @@ impl Calculator { #[tool_handler] impl ServerHandler for Calculator { fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some("A simple calculator".into()), - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("A simple calculator") } } From 6842f9cc3cee4085b898d1029f5870b49dc03f9b Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 3 Mar 2026 11:14:30 -0500 Subject: [PATCH 075/333] feat: docs update (#718) --- README.md | 768 ++++++++++++++++++++++++++++++++++- crates/rmcp-macros/README.md | 6 +- crates/rmcp/README.md | 323 ++------------- docs/FEATURES.md | 758 ---------------------------------- docs/readme/README.zh-cn.md | 768 ++++++++++++++++++++++++++++++++++- 5 files changed, 1573 insertions(+), 1050 deletions(-) delete mode 100644 docs/FEATURES.md diff --git a/README.md b/README.md index b2d17c084..8f2bbcf1e 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,32 @@ An official Rust Model Context Protocol SDK implementation with tokio async runtime. +> **Migrating to 1.x?** See the [migration guide](https://github.com/modelcontextprotocol/rust-sdk/discussions/716) for breaking changes and upgrade instructions. + This repository contains the following crates: - [rmcp](crates/rmcp): The core crate providing the RMCP protocol implementation - see [rmcp](crates/rmcp/README.md) - [rmcp-macros](crates/rmcp-macros): A procedural macro crate for generating RMCP tool implementations - see [rmcp-macros](crates/rmcp-macros/README.md) +For the full MCP specification, see [modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2025-11-25). + +## Table of Contents + +- [Usage](#usage) +- [Resources](#resources) +- [Prompts](#prompts) +- [Sampling](#sampling) +- [Roots](#roots) +- [Logging](#logging) +- [Completions](#completions) +- [Notifications](#notifications) +- [Subscriptions](#subscriptions) +- [Examples](#examples) +- [OAuth Support](#oauth-support) +- [Related Resources](#related-resources) +- [Related Projects](#related-projects) +- [Development](#development) + ## Usage ### Import the crate @@ -106,14 +127,753 @@ let quit_reason = server.cancel().await?; ``` +--- + +## Resources + +Resources let servers expose data (files, database records, API responses) that clients can read. Each resource is identified by a URI and returns content as text or binary (base64-encoded) data. Resource templates allow servers to declare URI patterns with dynamic parameters. + +**MCP Spec:** [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) + +### Server-side + +Implement `list_resources()`, `read_resource()`, and optionally `list_resource_templates()` on the `ServerHandler` trait. Enable the resources capability in `get_info()`. + +```rust +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + model::*, + service::RequestContext, + transport::stdio, +}; +use serde_json::json; + +#[derive(Clone)] +struct MyServer; + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_resources() + .build(), + ..Default::default() + } + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![ + RawResource::new("file:///config.json", "config").no_annotation(), + RawResource::new("memo://insights", "insights").no_annotation(), + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + match request.uri.as_str() { + "file:///config.json" => Ok(ReadResourceResult { + contents: vec![ResourceContents::text(r#"{"key": "value"}"#, &request.uri)], + }), + "memo://insights" => Ok(ReadResourceResult { + contents: vec![ResourceContents::text("Analysis results...", &request.uri)], + }), + _ => Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": request.uri })), + )), + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: vec![], + next_cursor: None, + meta: None, + }) + } +} +``` -## Examples +### Client-side -See [examples](examples/README.md). +```rust +use rmcp::model::{ReadResourceRequestParams}; + +// List all resources (handles pagination automatically) +let resources = client.list_all_resources().await?; + +// Read a specific resource by URI +let result = client.read_resource(ReadResourceRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; + +// List resource templates +let templates = client.list_all_resource_templates().await?; +``` + +### Notifications + +Servers can notify clients when the resource list changes or when a specific resource is updated: + +```rust +// Notify that the resource list has changed (clients should re-fetch) +context.peer.notify_resource_list_changed().await?; + +// Notify that a specific resource was updated +context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { + uri: "file:///config.json".into(), +}).await?; +``` + +Clients handle these via `ClientHandler`: + +```rust +impl ClientHandler for MyClient { + async fn on_resource_list_changed( + &self, + _context: NotificationContext, + ) { + // Re-fetch the resource list + } + + async fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + _context: NotificationContext, + ) { + // Re-read the updated resource at params.uri + } +} +``` + +**Example:** [`examples/servers/src/common/counter.rs`](examples/servers/src/common/counter.rs) (server), [`examples/clients/src/everything_stdio.rs`](examples/clients/src/everything_stdio.rs) (client) + +--- + +## Prompts + +Prompts are reusable message templates that servers expose to clients. They accept typed arguments and return conversation messages. The `#[prompt]` macro handles argument validation and routing automatically. + +**MCP Spec:** [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) + +### Server-side + +Use the `#[prompt_router]`, `#[prompt]`, and `#[prompt_handler]` macros to define prompts declaratively. Arguments are defined as structs deriving `JsonSchema`. + +```rust +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, + model::*, + prompt, prompt_handler, prompt_router, + schemars::JsonSchema, + service::RequestContext, + transport::stdio, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CodeReviewArgs { + #[schemars(description = "Programming language of the code")] + pub language: String, + #[schemars(description = "Focus areas for the review")] + pub focus_areas: Option>, +} + +#[derive(Clone)] +pub struct MyServer { + prompt_router: PromptRouter, +} + +#[prompt_router] +impl MyServer { + fn new() -> Self { + Self { prompt_router: Self::prompt_router() } + } + + /// Simple prompt without parameters + #[prompt(name = "greeting", description = "A simple greeting")] + async fn greeting(&self) -> Vec { + vec![PromptMessage::new_text( + PromptMessageRole::User, + "Hello! How can you help me today?", + )] + } + + /// Prompt with typed arguments + #[prompt(name = "code_review", description = "Review code in a given language")] + async fn code_review( + &self, + Parameters(args): Parameters, + ) -> Result { + let focus = args.focus_areas + .unwrap_or_else(|| vec!["correctness".into()]); + + Ok(GetPromptResult { + description: Some(format!("Code review for {}", args.language)), + messages: vec![ + PromptMessage::new_text( + PromptMessageRole::User, + format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), + ), + ], + }) + } +} + +#[prompt_handler] +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_prompts().build(), + ..Default::default() + } + } +} +``` + +Prompt functions support several return types: +- `Vec` -- simple message list +- `GetPromptResult` -- messages with an optional description +- `Result` -- either of the above, with error handling + +### Client-side + +```rust +use rmcp::model::GetPromptRequestParams; + +// List all prompts +let prompts = client.list_all_prompts().await?; + +// Get a prompt with arguments +let result = client.get_prompt(GetPromptRequestParams { + meta: None, + name: "code_review".into(), + arguments: Some(rmcp::object!({ + "language": "Rust", + "focus_areas": ["performance", "safety"] + })), +}).await?; +``` + +### Notifications + +```rust +// Server: notify that available prompts have changed +context.peer.notify_prompt_list_changed().await?; +``` + +**Example:** [`examples/servers/src/prompt_stdio.rs`](examples/servers/src/prompt_stdio.rs) (server), [`examples/clients/src/everything_stdio.rs`](examples/clients/src/everything_stdio.rs) (client) + +--- + +## Sampling + +Sampling flips the usual direction: the server asks the client to run an LLM completion. The server sends a `create_message` request, the client processes it through its LLM, and returns the result. + +**MCP Spec:** [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) + +### Server-side (requesting sampling) + +Access the client's sampling capability through `context.peer.create_message()`: + +```rust +use rmcp::model::*; + +// Inside a ServerHandler method (e.g., call_tool): +let response = context.peer.create_message(CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::user_text("Explain this error: connection refused")], + model_preferences: Some(ModelPreferences { + hints: Some(vec![ModelHint { name: Some("claude".into()) }]), + cost_priority: Some(0.3), + speed_priority: Some(0.8), + intelligence_priority: Some(0.7), + }), + system_prompt: Some("You are a helpful assistant.".into()), + include_context: Some(ContextInclusion::None), + temperature: Some(0.7), + max_tokens: 150, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, +}).await?; + +// Extract the response text +let text = response.message.content + .first() + .and_then(|c| c.as_text()) + .map(|t| &t.text); +``` + +### Client-side (handling sampling) + +On the client side, implement `ClientHandler::create_message()`. This is where you'd call your actual LLM: + +```rust +use rmcp::{ClientHandler, model::*, service::{RequestContext, RoleClient}}; + +#[derive(Clone, Default)] +struct MyClient; + +impl ClientHandler for MyClient { + async fn create_message( + &self, + params: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + // Forward to your LLM, or return a mock response: + let response_text = call_your_llm(¶ms.messages).await; + + Ok(CreateMessageResult { + message: SamplingMessage::assistant_text(response_text), + model: "my-model".into(), + stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.into()), + }) + } +} +``` + +**Example:** [`examples/servers/src/sampling_stdio.rs`](examples/servers/src/sampling_stdio.rs) (server), [`examples/clients/src/sampling_stdio.rs`](examples/clients/src/sampling_stdio.rs) (client) + +--- + +## Roots + +Roots tell servers which directories or projects the client is working in. A root is a URI (typically `file://`) pointing to a workspace or repository. Servers can query roots to know where to look for files and how to scope their work. + +**MCP Spec:** [Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) + +### Server-side + +Ask the client for its root list, and handle change notifications: + +```rust +use rmcp::{ServerHandler, model::*, service::{NotificationContext, RoleServer}}; + +impl ServerHandler for MyServer { + // Query the client for its roots + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let roots = context.peer.list_roots().await?; + // Use roots.roots to understand workspace boundaries + // ... + } + + // Called when the client's root list changes + async fn on_roots_list_changed( + &self, + _context: NotificationContext, + ) { + // Re-fetch roots to stay current + } +} +``` + +### Client-side + +Clients declare roots capability and implement `list_roots()`: + +```rust +use rmcp::{ClientHandler, model::*}; + +impl ClientHandler for MyClient { + async fn list_roots( + &self, + _context: RequestContext, + ) -> Result { + Ok(ListRootsResult { + roots: vec![ + Root { + uri: "file:///home/user/project".into(), + name: Some("My Project".into()), + }, + ], + }) + } +} +``` + +Clients notify the server when roots change: + +```rust +// After adding or removing a workspace root: +client.notify_roots_list_changed().await?; +``` + +--- + +## Logging + +Servers can send structured log messages to clients. The client sets a minimum severity level, and the server sends messages through the peer notification interface. + +**MCP Spec:** [Logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) + +### Server-side + +Enable the logging capability, handle level changes from the client, and send log messages via the peer: -## Feature Documentation +```rust +use rmcp::{ServerHandler, model::*, service::RequestContext}; -See [docs/FEATURES.md](docs/FEATURES.md) for detailed documentation on core MCP features: resources, prompts, sampling, roots, logging, completions, notifications, and subscriptions. +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_logging() + .build(), + ..Default::default() + } + } + + // Client sets the minimum log level + async fn set_level( + &self, + request: SetLevelRequestParams, + _context: RequestContext, + ) -> Result<(), ErrorData> { + // Store request.level and filter future log messages accordingly + Ok(()) + } +} + +// Send a log message from any handler with access to the peer: +context.peer.notify_logging_message(LoggingMessageNotificationParam { + level: LoggingLevel::Info, + logger: Some("my-server".into()), + data: serde_json::json!({ + "message": "Processing completed", + "items_processed": 42 + }), +}).await?; +``` + +Available log levels (from least to most severe): `Debug`, `Info`, `Notice`, `Warning`, `Error`, `Critical`, `Alert`, `Emergency`. + +### Client-side + +Clients handle incoming log messages via `ClientHandler`: + +```rust +impl ClientHandler for MyClient { + async fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + _context: NotificationContext, + ) { + println!("[{}] {}: {}", params.level, + params.logger.unwrap_or_default(), params.data); + } +} +``` + +Clients can also set the server's log level: + +```rust +client.set_level(SetLevelRequestParams { + level: LoggingLevel::Warning, + meta: None, +}).await?; +``` + +--- + +## Completions + +Completions give auto-completion suggestions for prompt or resource template arguments. As a user fills in arguments, the client can ask the server for suggestions based on what's already been entered. + +**MCP Spec:** [Completions](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) + +### Server-side + +Enable the completions capability and implement the `complete()` handler. Use `request.context` to inspect previously filled arguments: + +```rust +use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_completions() + .enable_prompts() + .build(), + ..Default::default() + } + } + + async fn complete( + &self, + request: CompleteRequestParams, + _context: RequestContext, + ) -> Result { + let values = match &request.r#ref { + Reference::Prompt(prompt_ref) if prompt_ref.name == "sql_query" => { + match request.argument.name.as_str() { + "operation" => vec!["SELECT", "INSERT", "UPDATE", "DELETE"], + "table" => vec!["users", "orders", "products"], + "columns" => { + // Adapt suggestions based on previously filled arguments + if let Some(ctx) = &request.context { + if let Some(op) = ctx.get_argument("operation") { + match op.to_uppercase().as_str() { + "SELECT" | "UPDATE" => { + vec!["id", "name", "email", "created_at"] + } + _ => vec![], + } + } else { vec![] } + } else { vec![] } + } + _ => vec![], + } + } + _ => vec![], + }; + + // Filter by the user's partial input + let filtered: Vec = values.into_iter() + .map(String::from) + .filter(|v| v.to_lowercase().contains(&request.argument.value.to_lowercase())) + .collect(); + + Ok(CompleteResult { + completion: CompletionInfo { + values: filtered, + total: None, + has_more: Some(false), + }, + }) + } +} +``` + +### Client-side + +```rust +use rmcp::model::*; + +let result = client.complete(CompleteRequestParams { + meta: None, + r#ref: Reference::Prompt(PromptReference { + name: "sql_query".into(), + }), + argument: ArgumentInfo { + name: "operation".into(), + value: "SEL".into(), + }, + context: None, +}).await?; + +// result.completion.values contains suggestions like ["SELECT"] +``` + +**Example:** [`examples/servers/src/completion_stdio.rs`](examples/servers/src/completion_stdio.rs) + +--- + +## Notifications + +Notifications are fire-and-forget messages -- no response is expected. They cover progress updates, cancellation, and lifecycle events. Both sides can send and receive them. + +**MCP Spec:** [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/notifications) + +### Progress notifications + +Servers can report progress during long-running operations: + +```rust +use rmcp::model::*; + +// Inside a tool handler: +for i in 0..total_items { + process_item(i).await; + + context.peer.notify_progress(ProgressNotificationParam { + progress_token: ProgressToken(NumberOrString::Number(i as i64)), + progress: i as f64, + total: Some(total_items as f64), + message: Some(format!("Processing item {}/{}", i + 1, total_items)), + }).await?; +} +``` + +### Cancellation + +Either side can cancel an in-progress request: + +```rust +// Send a cancellation +context.peer.notify_cancelled(CancelledNotificationParam { + request_id: the_request_id, + reason: Some("User requested cancellation".into()), +}).await?; +``` + +Handle cancellation in `ServerHandler` or `ClientHandler`: + +```rust +impl ServerHandler for MyServer { + async fn on_cancelled( + &self, + params: CancelledNotificationParam, + _context: NotificationContext, + ) { + // Abort work for params.request_id + } +} +``` + +### Initialized notification + +Clients send `initialized` after the handshake completes: + +```rust +// Sent automatically by rmcp during the serve() handshake. +// Servers handle it via: +impl ServerHandler for MyServer { + async fn on_initialized( + &self, + _context: NotificationContext, + ) { + // Server is ready to receive requests + } +} +``` + +### List-changed notifications + +When available tools, prompts, or resources change, tell the client: + +```rust +context.peer.notify_tool_list_changed().await?; +context.peer.notify_prompt_list_changed().await?; +context.peer.notify_resource_list_changed().await?; +``` + +**Example:** [`examples/servers/src/common/progress_demo.rs`](examples/servers/src/common/progress_demo.rs) + +--- + +## Subscriptions + +Clients can subscribe to specific resources. When a subscribed resource changes, the server sends a notification and the client can re-read it. + +**MCP Spec:** [Resources - Subscriptions](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions) + +### Server-side + +Enable subscriptions in the resources capability and implement the `subscribe()` / `unsubscribe()` handlers: + +```rust +use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; +use std::sync::Arc; +use tokio::sync::Mutex; +use std::collections::HashSet; + +#[derive(Clone)] +struct MyServer { + subscriptions: Arc>>, +} + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_resources() + .enable_resources_subscribe() + .build(), + ..Default::default() + } + } + + async fn subscribe( + &self, + request: SubscribeRequestParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.subscriptions.lock().await.insert(request.uri); + Ok(()) + } + + async fn unsubscribe( + &self, + request: UnsubscribeRequestParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.subscriptions.lock().await.remove(&request.uri); + Ok(()) + } +} +``` + +When a subscribed resource changes, notify the client: + +```rust +// Check if the resource has subscribers, then notify +context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { + uri: "file:///config.json".into(), +}).await?; +``` + +### Client-side + +```rust +use rmcp::model::*; + +// Subscribe to updates for a resource +client.subscribe(SubscribeRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; + +// Unsubscribe when no longer needed +client.unsubscribe(UnsubscribeRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; +``` + +Handle update notifications in `ClientHandler`: + +```rust +impl ClientHandler for MyClient { + async fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + _context: NotificationContext, + ) { + // Re-read the resource at params.uri + } +} +``` + +--- + +## Examples + +See [examples](examples/README.md). ## OAuth Support diff --git a/crates/rmcp-macros/README.md b/crates/rmcp-macros/README.md index ca137b13c..3aa759aa1 100644 --- a/crates/rmcp-macros/README.md +++ b/crates/rmcp-macros/README.md @@ -11,7 +11,9 @@ -`rmcp-macros` is a procedural macro library for the Rust Model Context Protocol (RMCP) SDK, providing macros that facilitate the development of RMCP applications. +Procedural macros for the [RMCP](../rmcp) SDK. Most users should depend on `rmcp` with the `macros` feature (enabled by default) rather than using this crate directly. + +For **getting started** and **full MCP feature documentation**, see the [main README](../../README.md). ## Available Macros @@ -63,4 +65,4 @@ See the [full documentation](https://docs.rs/rmcp-macros) for detailed usage of ## License -Please refer to the LICENSE file in the project root directory. +This project is licensed under the terms specified in the repository's [LICENSE](../../LICENSE) file. diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index ebc1db336..24deade15 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -4,304 +4,63 @@
-# RMCP: Rust Model Context Protocol +# rmcp [![Crates.io](https://img.shields.io/crates/v/rmcp.svg)](https://crates.io/crates/rmcp) [![Documentation](https://docs.rs/rmcp/badge.svg)](https://docs.rs/rmcp)
-`rmcp` is the official Rust implementation of the Model Context Protocol (MCP), a protocol designed for AI assistants to communicate with other services. This library can be used to build both servers that expose capabilities to AI assistants and clients that interact with such servers. +The official Rust SDK for the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25). Build MCP servers that expose tools, resources, and prompts to AI assistants — or build clients that connect to them. -## Quick Start - -### Server Implementation - -Creating a server with tools is simple using the `#[tool]` macro: - -```rust,ignore -use rmcp::{ - ServerHandler, ServiceExt, - handler::server::tool::ToolRouter, - model::*, - tool, tool_handler, tool_router, - transport::stdio, - ErrorData as McpError, -}; -use std::sync::Arc; -use tokio::sync::Mutex; - -#[derive(Clone)] -pub struct Counter { - counter: Arc>, - tool_router: ToolRouter, -} - -#[tool_router] -impl Counter { - fn new() -> Self { - Self { - counter: Arc::new(Mutex::new(0)), - tool_router: Self::tool_router(), - } - } - - #[tool(description = "Increment the counter by 1")] - async fn increment(&self) -> Result { - let mut counter = self.counter.lock().await; - *counter += 1; - Ok(CallToolResult::success(vec![Content::text( - counter.to_string(), - )])) - } - - #[tool(description = "Get the current counter value")] - async fn get(&self) -> Result { - let counter = self.counter.lock().await; - Ok(CallToolResult::success(vec![Content::text( - counter.to_string(), - )])) - } -} - -// Implement the server handler -#[tool_handler] -impl ServerHandler for Counter { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_instructions("A simple counter that tallies the number of times the increment tool has been used") - } -} - -// Run the server -#[tokio::main] -async fn main() -> Result<(), Box> { - // Create and run the server with STDIO transport - let service = Counter::new().serve(stdio()).await.inspect_err(|e| { - println!("Error starting server: {}", e); - })?; - service.waiting().await?; - Ok(()) -} -``` - -### Structured Output - -Tools can return structured JSON data with schemas. Use the [`Json`] wrapper: - -```rust -# use rmcp::{tool, tool_router, handler::server::{tool::ToolRouter, wrapper::Parameters}, Json}; -# use schemars::JsonSchema; -# use serde::{Serialize, Deserialize}; -# -#[derive(Serialize, Deserialize, JsonSchema)] -struct CalculationRequest { - a: i32, - b: i32, - operation: String, -} - -#[derive(Serialize, Deserialize, JsonSchema)] -struct CalculationResult { - result: i32, - operation: String, -} - -# #[derive(Clone)] -# struct Calculator { -# tool_router: ToolRouter, -# } -# -# #[tool_router] -# impl Calculator { -#[tool(name = "calculate", description = "Perform a calculation")] -async fn calculate(&self, params: Parameters) -> Result, String> { - let result = match params.0.operation.as_str() { - "add" => params.0.a + params.0.b, - "multiply" => params.0.a * params.0.b, - _ => return Err("Unknown operation".to_string()), - }; - - Ok(Json(CalculationResult { result, operation: params.0.operation })) -} -# } -``` - -The `#[tool]` macro automatically generates an output schema from the `CalculationResult` type. See the [documentation of `tool` module](crate::handler::server::router::tool) for more instructions. - -## Tasks - -RMCP implements the task lifecycle from SEP-1686 so long-running or asynchronous tool calls can be queued and polled safely. - -- **Create:** set the `task` field on `CallToolRequestParam` to ask the server to enqueue the tool call. The response is a `CreateTaskResult` that includes the generated `task.task_id`. -- **Inspect:** use `tasks/get` (`GetTaskInfoRequest`) to retrieve metadata such as status, timestamps, TTL, and poll interval. -- **Await results:** call `tasks/result` (`GetTaskResultRequest`) to block until the task completes and receive either the final `CallToolResult` payload or a protocol error. -- **Cancel:** call `tasks/cancel` (`CancelTaskRequest`) to request termination of a running task. - -To expose task support, enable the `tasks` capability when building `ServerCapabilities`. The `#[task_handler]` macro and `OperationProcessor` utility provide reference implementations for enqueuing, tracking, and collecting task results. - -### Client Implementation - -Creating a client to interact with a server: - -```rust,ignore -use rmcp::{ - ServiceExt, - model::CallToolRequestParams, - transport::{ConfigureCommandExt, TokioChildProcess}, -}; -use tokio::process::Command; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Connect to a server running as a child process - let service = () - .serve(TokioChildProcess::new(Command::new("uvx").configure( - |cmd| { - cmd.arg("mcp-server-git"); - }, - ))?) - .await?; - - // Get server information - let server_info = service.peer_info(); - println!("Connected to server: {server_info:#?}"); - - // List available tools - let tools = service.list_tools(Default::default()).await?; - println!("Available tools: {tools:#?}"); - - // Call a tool - let result = service - .call_tool( - CallToolRequestParams::new("git_status") - .with_arguments(serde_json::json!({ "repo_path": "." }).as_object().cloned().unwrap_or_default()) - ) - .await?; - println!("Result: {result:#?}"); - - // Gracefully close the connection - service.cancel().await?; - Ok(()) -} -``` - -For more examples, see the [examples directory](https://github.com/modelcontextprotocol/rust-sdk/tree/main/examples) in the repository. - -For detailed documentation on core MCP features (resources, prompts, sampling, roots, logging, completions, notifications, subscriptions), see [FEATURES.md](https://github.com/modelcontextprotocol/rust-sdk/blob/main/docs/FEATURES.md). - -## Transport Options - -RMCP supports multiple transport mechanisms, each suited for different use cases: - -### `transport-async-rw` -Low-level interface for asynchronous read/write operations. This is the foundation for many other transports. - -### `transport-io` -For working directly with I/O streams (`tokio::io::AsyncRead` and `tokio::io::AsyncWrite`). - -### `transport-child-process` -Run MCP servers as child processes and communicate via standard I/O. - -Example: -```rust,ignore -use rmcp::transport::TokioChildProcess; -use tokio::process::Command; - -let transport = TokioChildProcess::new(Command::new("mcp-server"))?; -let service = client.serve(transport).await?; -``` - -## Access with peer interface when handling message - -You can get the [`Peer`](crate::service::Peer) struct from [`NotificationContext`](crate::service::NotificationContext) and [`RequestContext`](crate::service::RequestContext). - -```rust, ignore -# use rmcp::{ -# ServerHandler, -# model::{LoggingLevel, LoggingMessageNotificationParam, ProgressNotificationParam}, -# service::{NotificationContext, RoleServer}, -# }; -# pub struct Handler; - -impl ServerHandler for Handler { - async fn on_progress( - &self, - notification: ProgressNotificationParam, - context: NotificationContext, - ) { - let peer = context.peer; - let _ = peer - .notify_logging_message(LoggingMessageNotificationParam { - level: LoggingLevel::Info, - logger: None, - data: serde_json::json!({ - "message": format!("Progress: {}", notification.progress), - }), - }) - .await; - } -} -``` - - -## Manage Multi Services - -For many cases you need to manage several service in a collection, you can call `into_dyn` to convert services into the same type. -```rust, ignore -let service = service.into_dyn(); -``` +For **getting started**, **usage guides**, and **full MCP feature documentation** (resources, prompts, sampling, roots, logging, completions, subscriptions, etc.), see the [main README](../../README.md). ## Feature Flags -RMCP uses feature flags to control which components are included: - -- `client`: Enable client functionality -- `server`: Enable server functionality and the tool system -- `macros`: Enable the `#[tool]` macro (enabled by default) -- Transport-specific features: - - `transport-async-rw`: Async read/write support - - `transport-io`: I/O stream support - - `transport-child-process`: Child process support - - `transport-streamable-http-client` / `transport-streamable-http-server`: HTTP streaming (client agnostic, see [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) for details) - - `transport-streamable-http-client-reqwest`: a default `reqwest` implementation of the streamable http client -- `auth`: OAuth2 authentication support -- `schemars`: JSON Schema generation (for tool definitions) -- TLS backend options (for HTTP transports): - - `reqwest`: Uses rustls (pure Rust TLS, recommended default) - - `reqwest-native-tls`: Uses platform native TLS (OpenSSL on Linux, Secure Transport on macOS, SChannel on Windows) - - `reqwest-tls-no-provider`: Uses rustls without a default crypto provider (bring your own) - +| Feature | Description | Default | +|---------|-------------|---------| +| `server` | Server functionality and the tool system | ✅ | +| `client` | Client functionality | | +| `macros` | `#[tool]` / `#[prompt]` macros (re-exports [`rmcp-macros`](../rmcp-macros)) | ✅ | +| `schemars` | JSON Schema generation for tool definitions | | +| `auth` | OAuth 2.0 authentication support | | +| `elicitation` | Elicitation support | | + +### Transport features + +| Feature | Description | +|---------|-------------| +| `transport-io` | Server-side stdio transport | +| `transport-child-process` | Client-side stdio transport (spawns a child process) | +| `transport-async-rw` | Generic async read/write transport | +| `transport-streamable-http-client` | Streamable HTTP client (transport-agnostic) | +| `transport-streamable-http-client-reqwest` | Streamable HTTP client with default `reqwest` backend | +| `transport-streamable-http-server` | Streamable HTTP server transport | + +### TLS backend options (for HTTP transports) + +| Feature | Description | +|---------|-------------| +| `reqwest` | Uses rustls — pure Rust TLS (recommended default) | +| `reqwest-native-tls` | Uses platform-native TLS (OpenSSL / Secure Transport / SChannel) | +| `reqwest-tls-no-provider` | Uses rustls without a default crypto provider (bring your own) | ## Transports -- `transport-io`: Server stdio transport -- `transport-child-process`: Client stdio transport -- `transport-streamable-http-server` streamable http server transport -- `transport-streamable-http-client` streamable http client transport - -
-Transport - -The transport type must implement the [`Transport`](crate::transport::Transport) trait, which allows it to send messages concurrently and receive messages sequentially. -There are 2 pairs of standard transport types: - -| transport | client | server | -|:---------------:|:-----------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------:| -| std IO | [`TokioChildProcess`](crate::transport::TokioChildProcess) | [`stdio`](crate::transport::stdio) | -| streamable http | [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) | [`StreamableHttpService`](crate::transport::StreamableHttpService) | +The transport layer is pluggable. Two built-in pairs cover the most common cases: -#### [`IntoTransport`](crate::transport::IntoTransport) trait -[`IntoTransport`](crate::transport::IntoTransport) is a helper trait that implicitly converts a type into a transport type. +| | Client | Server | +|:-:|:-:|:-:| +| **stdio** | [`TokioChildProcess`](crate::transport::TokioChildProcess) | [`stdio`](crate::transport::stdio) | +| **Streamable HTTP** | [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) | [`StreamableHttpService`](crate::transport::StreamableHttpService) | -These types automatically implement [`IntoTransport`](crate::transport::IntoTransport): -1. A type that implements both `futures::Sink` and `futures::Stream`, or a tuple `(Tx, Rx)` where `Tx` is `futures::Sink` and `Rx` is `futures::Stream`. -2. A type that implements both `tokio::io::AsyncRead` and `tokio::io::AsyncWrite`, or a tuple `(R, W)` where `R` is `tokio::io::AsyncRead` and `W` is `tokio::io::AsyncWrite`. -3. A type that implements the [`Worker`](crate::transport::worker::Worker) trait. -4. A type that implements the [`Transport`](crate::transport::Transport) trait. +Any type that implements the [`Transport`](crate::transport::Transport) trait can be used. The [`IntoTransport`](crate::transport::IntoTransport) helper trait provides automatic conversions from: -
+1. `(Sink, Stream)` or a combined `Sink + Stream` +2. `(AsyncRead, AsyncWrite)` or a combined `AsyncRead + AsyncWrite` +3. A [`Worker`](crate::transport::worker::Worker) implementation +4. A [`Transport`](crate::transport::Transport) implementation directly ## License -This project is licensed under the terms specified in the repository's LICENSE file. +This project is licensed under the terms specified in the repository's [LICENSE](../../LICENSE) file. diff --git a/docs/FEATURES.md b/docs/FEATURES.md deleted file mode 100644 index f27a152d6..000000000 --- a/docs/FEATURES.md +++ /dev/null @@ -1,758 +0,0 @@ -# RMCP Feature Documentation - -This document covers the core MCP features supported by `rmcp`, with server and client code examples for each. - -For the full MCP specification, see [modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2025-11-25). - -## Table of Contents - -- [Resources](#resources) -- [Prompts](#prompts) -- [Sampling](#sampling) -- [Roots](#roots) -- [Logging](#logging) -- [Completions](#completions) -- [Notifications](#notifications) -- [Subscriptions](#subscriptions) - ---- - -## Resources - -Resources let servers expose data (files, database records, API responses) that clients can read. Each resource is identified by a URI and returns content as text or binary (base64-encoded) data. Resource templates allow servers to declare URI patterns with dynamic parameters. - -**MCP Spec:** [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) - -### Server-side - -Implement `list_resources()`, `read_resource()`, and optionally `list_resource_templates()` on the `ServerHandler` trait. Enable the resources capability in `get_info()`. - -```rust -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, - model::*, - service::RequestContext, - transport::stdio, -}; -use serde_json::json; - -#[derive(Clone)] -struct MyServer; - -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() - .enable_resources() - .build(), - ..Default::default() - } - } - - async fn list_resources( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListResourcesResult { - resources: vec![ - RawResource::new("file:///config.json", "config").no_annotation(), - RawResource::new("memo://insights", "insights").no_annotation(), - ], - next_cursor: None, - meta: None, - }) - } - - async fn read_resource( - &self, - request: ReadResourceRequestParams, - _context: RequestContext, - ) -> Result { - match request.uri.as_str() { - "file:///config.json" => Ok(ReadResourceResult { - contents: vec![ResourceContents::text(r#"{"key": "value"}"#, &request.uri)], - }), - "memo://insights" => Ok(ReadResourceResult { - contents: vec![ResourceContents::text("Analysis results...", &request.uri)], - }), - _ => Err(McpError::resource_not_found( - "resource_not_found", - Some(json!({ "uri": request.uri })), - )), - } - } - - async fn list_resource_templates( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListResourceTemplatesResult { - resource_templates: vec![], - next_cursor: None, - meta: None, - }) - } -} -``` - -### Client-side - -```rust -use rmcp::model::{ReadResourceRequestParams}; - -// List all resources (handles pagination automatically) -let resources = client.list_all_resources().await?; - -// Read a specific resource by URI -let result = client.read_resource(ReadResourceRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; - -// List resource templates -let templates = client.list_all_resource_templates().await?; -``` - -### Notifications - -Servers can notify clients when the resource list changes or when a specific resource is updated: - -```rust -// Notify that the resource list has changed (clients should re-fetch) -context.peer.notify_resource_list_changed().await?; - -// Notify that a specific resource was updated -context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { - uri: "file:///config.json".into(), -}).await?; -``` - -Clients handle these via `ClientHandler`: - -```rust -impl ClientHandler for MyClient { - async fn on_resource_list_changed( - &self, - _context: NotificationContext, - ) { - // Re-fetch the resource list - } - - async fn on_resource_updated( - &self, - params: ResourceUpdatedNotificationParam, - _context: NotificationContext, - ) { - // Re-read the updated resource at params.uri - } -} -``` - -**Example:** [`examples/servers/src/common/counter.rs`](../examples/servers/src/common/counter.rs) (server), [`examples/clients/src/everything_stdio.rs`](../examples/clients/src/everything_stdio.rs) (client) - ---- - -## Prompts - -Prompts are reusable message templates that servers expose to clients. They accept typed arguments and return conversation messages. The `#[prompt]` macro handles argument validation and routing automatically. - -**MCP Spec:** [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) - -### Server-side - -Use the `#[prompt_router]`, `#[prompt]`, and `#[prompt_handler]` macros to define prompts declaratively. Arguments are defined as structs deriving `JsonSchema`. - -```rust -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, - handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, - model::*, - prompt, prompt_handler, prompt_router, - schemars::JsonSchema, - service::RequestContext, - transport::stdio, -}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct CodeReviewArgs { - #[schemars(description = "Programming language of the code")] - pub language: String, - #[schemars(description = "Focus areas for the review")] - pub focus_areas: Option>, -} - -#[derive(Clone)] -pub struct MyServer { - prompt_router: PromptRouter, -} - -#[prompt_router] -impl MyServer { - fn new() -> Self { - Self { prompt_router: Self::prompt_router() } - } - - /// Simple prompt without parameters - #[prompt(name = "greeting", description = "A simple greeting")] - async fn greeting(&self) -> Vec { - vec![PromptMessage::new_text( - PromptMessageRole::User, - "Hello! How can you help me today?", - )] - } - - /// Prompt with typed arguments - #[prompt(name = "code_review", description = "Review code in a given language")] - async fn code_review( - &self, - Parameters(args): Parameters, - ) -> Result { - let focus = args.focus_areas - .unwrap_or_else(|| vec!["correctness".into()]); - - Ok(GetPromptResult { - description: Some(format!("Code review for {}", args.language)), - messages: vec![ - PromptMessage::new_text( - PromptMessageRole::User, - format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), - ), - ], - }) - } -} - -#[prompt_handler] -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_prompts().build(), - ..Default::default() - } - } -} -``` - -Prompt functions support several return types: -- `Vec` -- simple message list -- `GetPromptResult` -- messages with an optional description -- `Result` -- either of the above, with error handling - -### Client-side - -```rust -use rmcp::model::GetPromptRequestParams; - -// List all prompts -let prompts = client.list_all_prompts().await?; - -// Get a prompt with arguments -let result = client.get_prompt(GetPromptRequestParams { - meta: None, - name: "code_review".into(), - arguments: Some(rmcp::object!({ - "language": "Rust", - "focus_areas": ["performance", "safety"] - })), -}).await?; -``` - -### Notifications - -```rust -// Server: notify that available prompts have changed -context.peer.notify_prompt_list_changed().await?; -``` - -**Example:** [`examples/servers/src/prompt_stdio.rs`](../examples/servers/src/prompt_stdio.rs) (server), [`examples/clients/src/everything_stdio.rs`](../examples/clients/src/everything_stdio.rs) (client) - ---- - -## Sampling - -Sampling flips the usual direction: the server asks the client to run an LLM completion. The server sends a `create_message` request, the client processes it through its LLM, and returns the result. - -**MCP Spec:** [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) - -### Server-side (requesting sampling) - -Access the client's sampling capability through `context.peer.create_message()`: - -```rust -use rmcp::model::*; - -// Inside a ServerHandler method (e.g., call_tool): -let response = context.peer.create_message(CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("Explain this error: connection refused")], - model_preferences: Some(ModelPreferences { - hints: Some(vec![ModelHint { name: Some("claude".into()) }]), - cost_priority: Some(0.3), - speed_priority: Some(0.8), - intelligence_priority: Some(0.7), - }), - system_prompt: Some("You are a helpful assistant.".into()), - include_context: Some(ContextInclusion::None), - temperature: Some(0.7), - max_tokens: 150, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, -}).await?; - -// Extract the response text -let text = response.message.content - .first() - .and_then(|c| c.as_text()) - .map(|t| &t.text); -``` - -### Client-side (handling sampling) - -On the client side, implement `ClientHandler::create_message()`. This is where you'd call your actual LLM: - -```rust -use rmcp::{ClientHandler, model::*, service::{RequestContext, RoleClient}}; - -#[derive(Clone, Default)] -struct MyClient; - -impl ClientHandler for MyClient { - async fn create_message( - &self, - params: CreateMessageRequestParams, - _context: RequestContext, - ) -> Result { - // Forward to your LLM, or return a mock response: - let response_text = call_your_llm(¶ms.messages).await; - - Ok(CreateMessageResult { - message: SamplingMessage::assistant_text(response_text), - model: "my-model".into(), - stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.into()), - }) - } -} -``` - -**Example:** [`examples/servers/src/sampling_stdio.rs`](../examples/servers/src/sampling_stdio.rs) (server), [`examples/clients/src/sampling_stdio.rs`](../examples/clients/src/sampling_stdio.rs) (client) - ---- - -## Roots - -Roots tell servers which directories or projects the client is working in. A root is a URI (typically `file://`) pointing to a workspace or repository. Servers can query roots to know where to look for files and how to scope their work. - -**MCP Spec:** [Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) - -### Server-side - -Ask the client for its root list, and handle change notifications: - -```rust -use rmcp::{ServerHandler, model::*, service::{NotificationContext, RoleServer}}; - -impl ServerHandler for MyServer { - // Query the client for its roots - async fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - let roots = context.peer.list_roots().await?; - // Use roots.roots to understand workspace boundaries - // ... - } - - // Called when the client's root list changes - async fn on_roots_list_changed( - &self, - _context: NotificationContext, - ) { - // Re-fetch roots to stay current - } -} -``` - -### Client-side - -Clients declare roots capability and implement `list_roots()`: - -```rust -use rmcp::{ClientHandler, model::*}; - -impl ClientHandler for MyClient { - async fn list_roots( - &self, - _context: RequestContext, - ) -> Result { - Ok(ListRootsResult { - roots: vec![ - Root { - uri: "file:///home/user/project".into(), - name: Some("My Project".into()), - }, - ], - }) - } -} -``` - -Clients notify the server when roots change: - -```rust -// After adding or removing a workspace root: -client.notify_roots_list_changed().await?; -``` - ---- - -## Logging - -Servers can send structured log messages to clients. The client sets a minimum severity level, and the server sends messages through the peer notification interface. - -**MCP Spec:** [Logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) - -### Server-side - -Enable the logging capability, handle level changes from the client, and send log messages via the peer: - -```rust -use rmcp::{ServerHandler, model::*, service::RequestContext}; - -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() - .enable_logging() - .build(), - ..Default::default() - } - } - - // Client sets the minimum log level - async fn set_level( - &self, - request: SetLevelRequestParams, - _context: RequestContext, - ) -> Result<(), ErrorData> { - // Store request.level and filter future log messages accordingly - Ok(()) - } -} - -// Send a log message from any handler with access to the peer: -context.peer.notify_logging_message(LoggingMessageNotificationParam { - level: LoggingLevel::Info, - logger: Some("my-server".into()), - data: serde_json::json!({ - "message": "Processing completed", - "items_processed": 42 - }), -}).await?; -``` - -Available log levels (from least to most severe): `Debug`, `Info`, `Notice`, `Warning`, `Error`, `Critical`, `Alert`, `Emergency`. - -### Client-side - -Clients handle incoming log messages via `ClientHandler`: - -```rust -impl ClientHandler for MyClient { - async fn on_logging_message( - &self, - params: LoggingMessageNotificationParam, - _context: NotificationContext, - ) { - println!("[{}] {}: {}", params.level, - params.logger.unwrap_or_default(), params.data); - } -} -``` - -Clients can also set the server's log level: - -```rust -client.set_level(SetLevelRequestParams { - level: LoggingLevel::Warning, - meta: None, -}).await?; -``` - ---- - -## Completions - -Completions give auto-completion suggestions for prompt or resource template arguments. As a user fills in arguments, the client can ask the server for suggestions based on what's already been entered. - -**MCP Spec:** [Completions](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) - -### Server-side - -Enable the completions capability and implement the `complete()` handler. Use `request.context` to inspect previously filled arguments: - -```rust -use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; - -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() - .enable_completions() - .enable_prompts() - .build(), - ..Default::default() - } - } - - async fn complete( - &self, - request: CompleteRequestParams, - _context: RequestContext, - ) -> Result { - let values = match &request.r#ref { - Reference::Prompt(prompt_ref) if prompt_ref.name == "sql_query" => { - match request.argument.name.as_str() { - "operation" => vec!["SELECT", "INSERT", "UPDATE", "DELETE"], - "table" => vec!["users", "orders", "products"], - "columns" => { - // Adapt suggestions based on previously filled arguments - if let Some(ctx) = &request.context { - if let Some(op) = ctx.get_argument("operation") { - match op.to_uppercase().as_str() { - "SELECT" | "UPDATE" => { - vec!["id", "name", "email", "created_at"] - } - _ => vec![], - } - } else { vec![] } - } else { vec![] } - } - _ => vec![], - } - } - _ => vec![], - }; - - // Filter by the user's partial input - let filtered: Vec = values.into_iter() - .map(String::from) - .filter(|v| v.to_lowercase().contains(&request.argument.value.to_lowercase())) - .collect(); - - Ok(CompleteResult { - completion: CompletionInfo { - values: filtered, - total: None, - has_more: Some(false), - }, - }) - } -} -``` - -### Client-side - -```rust -use rmcp::model::*; - -let result = client.complete(CompleteRequestParams { - meta: None, - r#ref: Reference::Prompt(PromptReference { - name: "sql_query".into(), - }), - argument: ArgumentInfo { - name: "operation".into(), - value: "SEL".into(), - }, - context: None, -}).await?; - -// result.completion.values contains suggestions like ["SELECT"] -``` - -**Example:** [`examples/servers/src/completion_stdio.rs`](../examples/servers/src/completion_stdio.rs) - ---- - -## Notifications - -Notifications are fire-and-forget messages -- no response is expected. They cover progress updates, cancellation, and lifecycle events. Both sides can send and receive them. - -**MCP Spec:** [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/notifications) - -### Progress notifications - -Servers can report progress during long-running operations: - -```rust -use rmcp::model::*; - -// Inside a tool handler: -for i in 0..total_items { - process_item(i).await; - - context.peer.notify_progress(ProgressNotificationParam { - progress_token: ProgressToken(NumberOrString::Number(i as i64)), - progress: i as f64, - total: Some(total_items as f64), - message: Some(format!("Processing item {}/{}", i + 1, total_items)), - }).await?; -} -``` - -### Cancellation - -Either side can cancel an in-progress request: - -```rust -// Send a cancellation -context.peer.notify_cancelled(CancelledNotificationParam { - request_id: the_request_id, - reason: Some("User requested cancellation".into()), -}).await?; -``` - -Handle cancellation in `ServerHandler` or `ClientHandler`: - -```rust -impl ServerHandler for MyServer { - async fn on_cancelled( - &self, - params: CancelledNotificationParam, - _context: NotificationContext, - ) { - // Abort work for params.request_id - } -} -``` - -### Initialized notification - -Clients send `initialized` after the handshake completes: - -```rust -// Sent automatically by rmcp during the serve() handshake. -// Servers handle it via: -impl ServerHandler for MyServer { - async fn on_initialized( - &self, - _context: NotificationContext, - ) { - // Server is ready to receive requests - } -} -``` - -### List-changed notifications - -When available tools, prompts, or resources change, tell the client: - -```rust -context.peer.notify_tool_list_changed().await?; -context.peer.notify_prompt_list_changed().await?; -context.peer.notify_resource_list_changed().await?; -``` - -**Example:** [`examples/servers/src/common/progress_demo.rs`](../examples/servers/src/common/progress_demo.rs) - ---- - -## Subscriptions - -Clients can subscribe to specific resources. When a subscribed resource changes, the server sends a notification and the client can re-read it. - -**MCP Spec:** [Resources - Subscriptions](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions) - -### Server-side - -Enable subscriptions in the resources capability and implement the `subscribe()` / `unsubscribe()` handlers: - -```rust -use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; -use std::sync::Arc; -use tokio::sync::Mutex; -use std::collections::HashSet; - -#[derive(Clone)] -struct MyServer { - subscriptions: Arc>>, -} - -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() - .enable_resources() - .enable_resources_subscribe() - .build(), - ..Default::default() - } - } - - async fn subscribe( - &self, - request: SubscribeRequestParams, - _context: RequestContext, - ) -> Result<(), McpError> { - self.subscriptions.lock().await.insert(request.uri); - Ok(()) - } - - async fn unsubscribe( - &self, - request: UnsubscribeRequestParams, - _context: RequestContext, - ) -> Result<(), McpError> { - self.subscriptions.lock().await.remove(&request.uri); - Ok(()) - } -} -``` - -When a subscribed resource changes, notify the client: - -```rust -// Check if the resource has subscribers, then notify -context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { - uri: "file:///config.json".into(), -}).await?; -``` - -### Client-side - -```rust -use rmcp::model::*; - -// Subscribe to updates for a resource -client.subscribe(SubscribeRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; - -// Unsubscribe when no longer needed -client.unsubscribe(UnsubscribeRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; -``` - -Handle update notifications in `ClientHandler`: - -```rust -impl ClientHandler for MyClient { - async fn on_resource_updated( - &self, - params: ResourceUpdatedNotificationParam, - _context: NotificationContext, - ) { - // Re-read the resource at params.uri - } -} -``` diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md index f666928f2..ecdf8f564 100644 --- a/docs/readme/README.zh-cn.md +++ b/docs/readme/README.zh-cn.md @@ -10,11 +10,32 @@ 一个基于 tokio 异步运行时的官方 Rust Model Context Protocol SDK 实现。 +> **迁移到 1.x?** 请参阅 [迁移指南](https://github.com/modelcontextprotocol/rust-sdk/discussions/716) 了解破坏性变更和升级说明。 + 本仓库包含以下 crate: - [rmcp](../../crates/rmcp):实现 RMCP 协议的核心库 - 详见 [rmcp](../../crates/rmcp/README.md) - [rmcp-macros](../../crates/rmcp-macros):用于生成 RMCP 工具实现的过程宏库 - 详见 [rmcp-macros](../../crates/rmcp-macros/README.md) +完整的 MCP 规范请参阅 [modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2025-11-25)。 + +## 目录 + +- [使用](#使用) +- [资源](#资源) +- [提示词](#提示词) +- [采样](#采样) +- [根目录](#根目录) +- [日志](#日志) +- [补全](#补全) +- [通知](#通知) +- [订阅](#订阅) +- [示例](#示例) +- [OAuth 支持](#oauth-支持) +- [相关资源](#相关资源) +- [相关项目](#相关项目) +- [开发](#开发) + ## 使用 ### 导入 @@ -106,14 +127,753 @@ let quit_reason = server.cancel().await?; ``` +--- + +## 资源 + +资源允许服务端向客户端暴露数据(文件、数据库记录、API 响应)供其读取。每个资源通过 URI 标识,返回文本或二进制(base64 编码)内容。资源模板允许服务端声明带有动态参数的 URI 模式。 + +**MCP 规范:** [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) + +### 服务端 + +在 `ServerHandler` trait 上实现 `list_resources()`、`read_resource()`,以及可选的 `list_resource_templates()`。在 `get_info()` 中启用资源能力。 + +```rust +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + model::*, + service::RequestContext, + transport::stdio, +}; +use serde_json::json; + +#[derive(Clone)] +struct MyServer; + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_resources() + .build(), + ..Default::default() + } + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![ + RawResource::new("file:///config.json", "config").no_annotation(), + RawResource::new("memo://insights", "insights").no_annotation(), + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + match request.uri.as_str() { + "file:///config.json" => Ok(ReadResourceResult { + contents: vec![ResourceContents::text(r#"{"key": "value"}"#, &request.uri)], + }), + "memo://insights" => Ok(ReadResourceResult { + contents: vec![ResourceContents::text("Analysis results...", &request.uri)], + }), + _ => Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": request.uri })), + )), + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: vec![], + next_cursor: None, + meta: None, + }) + } +} +``` -## 示例 +### 客户端 -查看 [examples](../../examples/README.md)。 +```rust +use rmcp::model::{ReadResourceRequestParams}; + +// 列出所有资源(自动处理分页) +let resources = client.list_all_resources().await?; + +// 通过 URI 读取特定资源 +let result = client.read_resource(ReadResourceRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; + +// 列出资源模板 +let templates = client.list_all_resource_templates().await?; +``` + +### 通知 + +服务端可以在资源列表变更或特定资源更新时通知客户端: + +```rust +// 通知资源列表已变更(客户端应重新获取) +context.peer.notify_resource_list_changed().await?; + +// 通知特定资源已更新 +context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { + uri: "file:///config.json".into(), +}).await?; +``` + +客户端通过 `ClientHandler` 处理这些通知: + +```rust +impl ClientHandler for MyClient { + async fn on_resource_list_changed( + &self, + _context: NotificationContext, + ) { + // 重新获取资源列表 + } + + async fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + _context: NotificationContext, + ) { + // 重新读取 params.uri 对应的资源 + } +} +``` + +**示例:** [`examples/servers/src/common/counter.rs`](../../examples/servers/src/common/counter.rs)(服务端),[`examples/clients/src/everything_stdio.rs`](../../examples/clients/src/everything_stdio.rs)(客户端) + +--- + +## 提示词 + +提示词是服务端向客户端暴露的可复用消息模板。它们接受类型化参数并返回对话消息。`#[prompt]` 宏自动处理参数验证和路由。 + +**MCP 规范:** [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) + +### 服务端 + +使用 `#[prompt_router]`、`#[prompt]` 和 `#[prompt_handler]` 宏以声明式方式定义提示词。参数定义为派生 `JsonSchema` 的结构体。 + +```rust +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, + model::*, + prompt, prompt_handler, prompt_router, + schemars::JsonSchema, + service::RequestContext, + transport::stdio, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct CodeReviewArgs { + #[schemars(description = "Programming language of the code")] + pub language: String, + #[schemars(description = "Focus areas for the review")] + pub focus_areas: Option>, +} + +#[derive(Clone)] +pub struct MyServer { + prompt_router: PromptRouter, +} + +#[prompt_router] +impl MyServer { + fn new() -> Self { + Self { prompt_router: Self::prompt_router() } + } + + /// 无参数的简单提示词 + #[prompt(name = "greeting", description = "A simple greeting")] + async fn greeting(&self) -> Vec { + vec![PromptMessage::new_text( + PromptMessageRole::User, + "Hello! How can you help me today?", + )] + } + + /// 带类型化参数的提示词 + #[prompt(name = "code_review", description = "Review code in a given language")] + async fn code_review( + &self, + Parameters(args): Parameters, + ) -> Result { + let focus = args.focus_areas + .unwrap_or_else(|| vec!["correctness".into()]); + + Ok(GetPromptResult { + description: Some(format!("Code review for {}", args.language)), + messages: vec![ + PromptMessage::new_text( + PromptMessageRole::User, + format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), + ), + ], + }) + } +} + +#[prompt_handler] +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_prompts().build(), + ..Default::default() + } + } +} +``` + +提示词函数支持以下返回类型: +- `Vec` -- 简单消息列表 +- `GetPromptResult` -- 带可选描述的消息 +- `Result` -- 以上任一类型,附带错误处理 + +### 客户端 + +```rust +use rmcp::model::GetPromptRequestParams; + +// 列出所有提示词 +let prompts = client.list_all_prompts().await?; + +// 带参数获取提示词 +let result = client.get_prompt(GetPromptRequestParams { + meta: None, + name: "code_review".into(), + arguments: Some(rmcp::object!({ + "language": "Rust", + "focus_areas": ["performance", "safety"] + })), +}).await?; +``` + +### 通知 + +```rust +// 服务端:通知可用提示词已变更 +context.peer.notify_prompt_list_changed().await?; +``` + +**示例:** [`examples/servers/src/prompt_stdio.rs`](../../examples/servers/src/prompt_stdio.rs)(服务端),[`examples/clients/src/everything_stdio.rs`](../../examples/clients/src/everything_stdio.rs)(客户端) + +--- + +## 采样 + +采样反转了通常的方向:服务端请求客户端执行 LLM 补全。服务端发送 `create_message` 请求,客户端通过其 LLM 处理并返回结果。 + +**MCP 规范:** [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) + +### 服务端(请求采样) + +通过 `context.peer.create_message()` 访问客户端的采样能力: + +```rust +use rmcp::model::*; + +// 在 ServerHandler 方法内部(例如 call_tool): +let response = context.peer.create_message(CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::user_text("Explain this error: connection refused")], + model_preferences: Some(ModelPreferences { + hints: Some(vec![ModelHint { name: Some("claude".into()) }]), + cost_priority: Some(0.3), + speed_priority: Some(0.8), + intelligence_priority: Some(0.7), + }), + system_prompt: Some("You are a helpful assistant.".into()), + include_context: Some(ContextInclusion::None), + temperature: Some(0.7), + max_tokens: 150, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, +}).await?; + +// 提取响应文本 +let text = response.message.content + .first() + .and_then(|c| c.as_text()) + .map(|t| &t.text); +``` + +### 客户端(处理采样) + +在客户端实现 `ClientHandler::create_message()`。这是你调用实际 LLM 的地方: + +```rust +use rmcp::{ClientHandler, model::*, service::{RequestContext, RoleClient}}; + +#[derive(Clone, Default)] +struct MyClient; + +impl ClientHandler for MyClient { + async fn create_message( + &self, + params: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + // 转发到你的 LLM,或返回模拟响应: + let response_text = call_your_llm(¶ms.messages).await; + + Ok(CreateMessageResult { + message: SamplingMessage::assistant_text(response_text), + model: "my-model".into(), + stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.into()), + }) + } +} +``` + +**示例:** [`examples/servers/src/sampling_stdio.rs`](../../examples/servers/src/sampling_stdio.rs)(服务端),[`examples/clients/src/sampling_stdio.rs`](../../examples/clients/src/sampling_stdio.rs)(客户端) + +--- + +## 根目录 + +根目录告诉服务端客户端正在使用哪些目录或项目。根目录是一个 URI(通常为 `file://`),指向工作区或代码仓库。服务端可以查询根目录以了解在哪里查找文件以及如何限定工作范围。 + +**MCP 规范:** [Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) + +### 服务端 + +向客户端请求根目录列表,并处理变更通知: + +```rust +use rmcp::{ServerHandler, model::*, service::{NotificationContext, RoleServer}}; + +impl ServerHandler for MyServer { + // 向客户端查询根目录 + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let roots = context.peer.list_roots().await?; + // 使用 roots.roots 了解工作区边界 + // ... + } + + // 当客户端的根目录列表变更时调用 + async fn on_roots_list_changed( + &self, + _context: NotificationContext, + ) { + // 重新获取根目录以保持最新 + } +} +``` + +### 客户端 + +客户端声明根目录能力并实现 `list_roots()`: + +```rust +use rmcp::{ClientHandler, model::*}; + +impl ClientHandler for MyClient { + async fn list_roots( + &self, + _context: RequestContext, + ) -> Result { + Ok(ListRootsResult { + roots: vec![ + Root { + uri: "file:///home/user/project".into(), + name: Some("My Project".into()), + }, + ], + }) + } +} +``` + +客户端在根目录变更时通知服务端: + +```rust +// 添加或移除工作区根目录后: +client.notify_roots_list_changed().await?; +``` + +--- + +## 日志 + +服务端可以向客户端发送结构化日志消息。客户端设置最低严重级别,服务端通过对等通知接口发送消息。 + +**MCP 规范:** [Logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) + +### 服务端 + +启用日志能力,处理客户端的级别变更,并通过对等端发送日志消息: -## 功能文档 +```rust +use rmcp::{ServerHandler, model::*, service::RequestContext}; -查看 [docs/FEATURES.md](../FEATURES.md) 了解核心 MCP 功能的详细文档:资源、提示词、采样、根目录、日志、补全、通知和订阅。 +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_logging() + .build(), + ..Default::default() + } + } + + // 客户端设置最低日志级别 + async fn set_level( + &self, + request: SetLevelRequestParams, + _context: RequestContext, + ) -> Result<(), ErrorData> { + // 存储 request.level 并据此过滤后续日志消息 + Ok(()) + } +} + +// 在任何可以访问 peer 的处理器中发送日志消息: +context.peer.notify_logging_message(LoggingMessageNotificationParam { + level: LoggingLevel::Info, + logger: Some("my-server".into()), + data: serde_json::json!({ + "message": "Processing completed", + "items_processed": 42 + }), +}).await?; +``` + +可用日志级别(从低到高):`Debug`、`Info`、`Notice`、`Warning`、`Error`、`Critical`、`Alert`、`Emergency`。 + +### 客户端 + +客户端通过 `ClientHandler` 处理传入的日志消息: + +```rust +impl ClientHandler for MyClient { + async fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + _context: NotificationContext, + ) { + println!("[{}] {}: {}", params.level, + params.logger.unwrap_or_default(), params.data); + } +} +``` + +客户端也可以设置服务端的日志级别: + +```rust +client.set_level(SetLevelRequestParams { + level: LoggingLevel::Warning, + meta: None, +}).await?; +``` + +--- + +## 补全 + +补全为提示词或资源模板参数提供自动补全建议。当用户填写参数时,客户端可以根据已输入的内容向服务端请求建议。 + +**MCP 规范:** [Completions](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) + +### 服务端 + +启用补全能力并实现 `complete()` 处理器。使用 `request.context` 检查已填写的参数: + +```rust +use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_completions() + .enable_prompts() + .build(), + ..Default::default() + } + } + + async fn complete( + &self, + request: CompleteRequestParams, + _context: RequestContext, + ) -> Result { + let values = match &request.r#ref { + Reference::Prompt(prompt_ref) if prompt_ref.name == "sql_query" => { + match request.argument.name.as_str() { + "operation" => vec!["SELECT", "INSERT", "UPDATE", "DELETE"], + "table" => vec!["users", "orders", "products"], + "columns" => { + // 根据已填写的参数调整建议 + if let Some(ctx) = &request.context { + if let Some(op) = ctx.get_argument("operation") { + match op.to_uppercase().as_str() { + "SELECT" | "UPDATE" => { + vec!["id", "name", "email", "created_at"] + } + _ => vec![], + } + } else { vec![] } + } else { vec![] } + } + _ => vec![], + } + } + _ => vec![], + }; + + // 根据用户的部分输入进行过滤 + let filtered: Vec = values.into_iter() + .map(String::from) + .filter(|v| v.to_lowercase().contains(&request.argument.value.to_lowercase())) + .collect(); + + Ok(CompleteResult { + completion: CompletionInfo { + values: filtered, + total: None, + has_more: Some(false), + }, + }) + } +} +``` + +### 客户端 + +```rust +use rmcp::model::*; + +let result = client.complete(CompleteRequestParams { + meta: None, + r#ref: Reference::Prompt(PromptReference { + name: "sql_query".into(), + }), + argument: ArgumentInfo { + name: "operation".into(), + value: "SEL".into(), + }, + context: None, +}).await?; + +// result.completion.values 包含建议,例如 ["SELECT"] +``` + +**示例:** [`examples/servers/src/completion_stdio.rs`](../../examples/servers/src/completion_stdio.rs) + +--- + +## 通知 + +通知是即发即忘的消息——不需要响应。它们涵盖进度更新、取消和生命周期事件。双方都可以发送和接收通知。 + +**MCP 规范:** [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/notifications) + +### 进度通知 + +服务端可以在长时间运行的操作中报告进度: + +```rust +use rmcp::model::*; + +// 在工具处理器内部: +for i in 0..total_items { + process_item(i).await; + + context.peer.notify_progress(ProgressNotificationParam { + progress_token: ProgressToken(NumberOrString::Number(i as i64)), + progress: i as f64, + total: Some(total_items as f64), + message: Some(format!("Processing item {}/{}", i + 1, total_items)), + }).await?; +} +``` + +### 取消 + +任一方都可以取消正在进行的请求: + +```rust +// 发送取消通知 +context.peer.notify_cancelled(CancelledNotificationParam { + request_id: the_request_id, + reason: Some("User requested cancellation".into()), +}).await?; +``` + +在 `ServerHandler` 或 `ClientHandler` 中处理取消: + +```rust +impl ServerHandler for MyServer { + async fn on_cancelled( + &self, + params: CancelledNotificationParam, + _context: NotificationContext, + ) { + // 中止 params.request_id 对应的工作 + } +} +``` + +### 初始化通知 + +客户端在握手完成后发送 `initialized` 通知: + +```rust +// 在 serve() 握手过程中由 rmcp 自动发送。 +// 服务端通过以下方式处理: +impl ServerHandler for MyServer { + async fn on_initialized( + &self, + _context: NotificationContext, + ) { + // 服务端已准备好接收请求 + } +} +``` + +### 列表变更通知 + +当可用的工具、提示词或资源发生变更时,通知客户端: + +```rust +context.peer.notify_tool_list_changed().await?; +context.peer.notify_prompt_list_changed().await?; +context.peer.notify_resource_list_changed().await?; +``` + +**示例:** [`examples/servers/src/common/progress_demo.rs`](../../examples/servers/src/common/progress_demo.rs) + +--- + +## 订阅 + +客户端可以订阅特定资源。当订阅的资源发生变更时,服务端发送通知,客户端可以重新读取该资源。 + +**MCP 规范:** [Resources - Subscriptions](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions) + +### 服务端 + +在资源能力中启用订阅,并实现 `subscribe()` / `unsubscribe()` 处理器: + +```rust +use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; +use std::sync::Arc; +use tokio::sync::Mutex; +use std::collections::HashSet; + +#[derive(Clone)] +struct MyServer { + subscriptions: Arc>>, +} + +impl ServerHandler for MyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_resources() + .enable_resources_subscribe() + .build(), + ..Default::default() + } + } + + async fn subscribe( + &self, + request: SubscribeRequestParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.subscriptions.lock().await.insert(request.uri); + Ok(()) + } + + async fn unsubscribe( + &self, + request: UnsubscribeRequestParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.subscriptions.lock().await.remove(&request.uri); + Ok(()) + } +} +``` + +当订阅的资源发生变更时,通知客户端: + +```rust +// 检查资源是否有订阅者,然后通知 +context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { + uri: "file:///config.json".into(), +}).await?; +``` + +### 客户端 + +```rust +use rmcp::model::*; + +// 订阅资源更新 +client.subscribe(SubscribeRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; + +// 不再需要时取消订阅 +client.unsubscribe(UnsubscribeRequestParams { + meta: None, + uri: "file:///config.json".into(), +}).await?; +``` + +在 `ClientHandler` 中处理更新通知: + +```rust +impl ClientHandler for MyClient { + async fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + _context: NotificationContext, + ) { + // 重新读取 params.uri 对应的资源 + } +} +``` + +--- + +## 示例 + +查看 [examples](../../examples/README.md)。 ## OAuth 支持 From 28beb9528bb235512d8d9e3a35e3d6c9df0cb0e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:26:31 -0500 Subject: [PATCH 076/333] chore: release v1.0.0-alpha (#719) * chore: release v0.18.0 * chore: bump to 1.0.0-alpha --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jack Amadeo --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 10 ++++++++++ crates/rmcp/CHANGELOG.md | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fae59e7c5..60bb16fae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "0.17.0", path = "./crates/rmcp" } -rmcp-macros = { version = "0.17.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.0.0-alpha", path = "./crates/rmcp" } +rmcp-macros = { version = "1.0.0-alpha", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "0.17.0" +version = "1.0.0-alpha" authors = ["4t145 "] license = "Apache-2.0" license-file = "LICENSE" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 7313439e7..546f8f24a 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-alpha](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.17.0...rmcp-macros-v1.0.0-alpha) - 2026-03-03 + +### Added + +- docs update ([#718](https://github.com/modelcontextprotocol/rust-sdk/pull/718)) + +### Other + +- add #[non_exhaustive] and mutation methods to improve compatibility ([#715](https://github.com/modelcontextprotocol/rust-sdk/pull/715)) + ## [0.17.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.16.0...rmcp-macros-v0.17.0) - 2026-02-27 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 0f4a3a400..3dea6f108 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-alpha](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.17.0...rmcp-v1.0.0-alpha) - 2026-03-03 + +### Added + +- docs update ([#718](https://github.com/modelcontextprotocol/rust-sdk/pull/718)) +- *(auth)* [**breaking**] support returning extra fields from token exchange ([#700](https://github.com/modelcontextprotocol/rust-sdk/pull/700)) + +### Fixed + +- downgrade logging of message to `TRACE` to avoid spamming logs ([#699](https://github.com/modelcontextprotocol/rust-sdk/pull/699)) + +### Other + +- add #[non_exhaustive] and mutation methods to improve compatibility ([#715](https://github.com/modelcontextprotocol/rust-sdk/pull/715)) + ## [0.17.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.16.0...rmcp-v0.17.0) - 2026-02-27 ### Added From 1fe5d1e1cdaeeb34a11a2b5b306006d2bcdcaf57 Mon Sep 17 00:00:00 2001 From: Adam Kowalski Date: Tue, 3 Mar 2026 09:01:14 -0800 Subject: [PATCH 077/333] fix(streamable-http): map stale session 401 to status-aware error (#709) * fix(streamable-http): map stale session 401 to status-aware error * test(streamable-http): expect 404 for stale session --- .../common/reqwest/streamable_http_client.rs | 9 ++ .../test_streamable_http_stale_session.rs | 97 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 crates/rmcp/tests/test_streamable_http_stale_session.rs diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index ae70f72fa..a3b85da1b 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -186,6 +186,15 @@ impl StreamableHttpClient for reqwest::Client { ) { return Ok(StreamableHttpPostResponse::Accepted); } + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_owned()); + return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + format!("HTTP {status}: {body}"), + ))); + } let content_type = response.headers().get(reqwest::header::CONTENT_TYPE); let session_id = response.headers().get(HEADER_SESSION_ID); let session_id = session_id diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs new file mode 100644 index 000000000..a37a0895f --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -0,0 +1,97 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + feature = "transport-streamable-http-server" +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::{ + streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }, +}; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +#[tokio::test] +async fn test_stale_session_id_returns_status_aware_error() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Calculator::new()), + Default::default(), + StreamableHttpServerConfig { + stateful_mode: true, + sse_keep_alive: None, + cancellation_token: ct.child_token(), + ..Default::default() + }, + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let uri = Arc::::from(format!("http://{addr}/mcp")); + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let client = reqwest::Client::new(); + let result = client + .post_message( + uri.clone(), + message, + Some(Arc::from("stale-session-id")), + None, + HashMap::new(), + ) + .await; + + let raw_response = reqwest::Client::new() + .post(uri.as_ref()) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("mcp-session-id", "stale-session-id") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#) + .send() + .await?; + + assert_eq!(raw_response.status(), reqwest::StatusCode::NOT_FOUND); + match result { + Err(StreamableHttpError::UnexpectedServerResponse(message)) => { + let message = message.to_string(); + assert!( + message.contains("404"), + "error should include HTTP status code, got: {message}" + ); + assert!( + message.to_ascii_lowercase().contains("session not found"), + "error should include session-not-found hint, got: {message}" + ); + } + other => panic!("expected UnexpectedServerResponse, got: {other:?}"), + } + + ct.cancel(); + handle.await?; + + Ok(()) +} From 2d90b76501793437adcb711a83ac2b352d052050 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:05:32 -0500 Subject: [PATCH 078/333] fix: api ergonomics follow-up (#720) * fix: builder with_* methods take T instead of Option * fix: emit conditional builder calls for optional fields in macros * fix: convert with_task, with_stop_reason, with_logger, with_content to proper builders * fix: update test callers for new builder signatures * fix: simplify make_task helper and remove unused import * fix: update sampling_stdio example for new with_stop_reason signature * fix: make annotations and execution Option consistent with other fields * fix: remove unused none_expr import --- crates/rmcp-macros/src/prompt.rs | 28 +++----- crates/rmcp-macros/src/tool.rs | 70 +++++++++---------- crates/rmcp/src/model.rs | 29 ++++---- crates/rmcp/src/model/prompt.rs | 12 ++-- crates/rmcp/src/model/tool.rs | 24 +++---- crates/rmcp/tests/common/handlers.rs | 2 +- crates/rmcp/tests/test_sampling.rs | 8 +-- .../tests/test_task_support_validation.rs | 5 +- examples/clients/src/sampling_stdio.rs | 2 +- 9 files changed, 81 insertions(+), 99 deletions(-) diff --git a/crates/rmcp-macros/src/prompt.rs b/crates/rmcp-macros/src/prompt.rs index a7a13f450..20492a668 100644 --- a/crates/rmcp-macros/src/prompt.rs +++ b/crates/rmcp-macros/src/prompt.rs @@ -46,21 +46,13 @@ impl ResolvedPromptAttribute { } else { quote! { None:: } }; - let title = if let Some(title) = title { - quote! { Some(#title.into()) } - } else { - quote! { None } - }; - let icons = if let Some(icons) = icons { - quote! { Some(#icons) } - } else { - quote! { None } - }; - let meta = if let Some(meta) = meta { - quote! { Some(#meta) } - } else { - quote! { None } - }; + let title_call = title + .map(|t| quote! { .with_title(#t) }) + .unwrap_or_default(); + let icons_call = icons + .map(|i| quote! { .with_icons(#i) }) + .unwrap_or_default(); + let meta_call = meta.map(|m| quote! { .with_meta(#m) }).unwrap_or_default(); let tokens = quote! { pub fn #fn_ident() -> rmcp::model::Prompt { rmcp::model::Prompt::from_raw( @@ -68,9 +60,9 @@ impl ResolvedPromptAttribute { #description, #arguments, ) - .with_title(#title) - .with_icons(#icons) - .with_meta(#meta) + #title_call + #icons_call + #meta_call } }; syn::parse2::(tokens) diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index 0e36a889a..56bf65a14 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -3,7 +3,7 @@ use proc_macro2::{Span, TokenStream}; use quote::{ToTokens, format_ident, quote}; use syn::{Expr, Ident, ImplItemFn, LitStr, ReturnType, parse_quote}; -use crate::common::{extract_doc_line, none_expr}; +use crate::common::extract_doc_line; /// Check if a type is Json and extract the inner type T fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> { @@ -110,8 +110,8 @@ pub struct ResolvedToolAttribute { pub description: Option, pub input_schema: Expr, pub output_schema: Option, - pub annotations: Expr, - pub execution: Expr, + pub annotations: Option, + pub execution: Option, pub icons: Option, pub meta: Option, } @@ -134,26 +134,22 @@ impl ResolvedToolAttribute { } else { quote! { None } }; - let output_schema = if let Some(output_schema) = output_schema { - quote! { Some(#output_schema) } - } else { - quote! { None } - }; - let title = if let Some(title) = title { - quote! { Some(#title.into()) } - } else { - quote! { None } - }; - let icons = if let Some(icons) = icons { - quote! { Some(#icons) } - } else { - quote! { None } - }; - let meta = if let Some(meta) = meta { - quote! { Some(#meta) } - } else { - quote! { None } - }; + let title_call = title + .map(|t| quote! { .with_title(#t) }) + .unwrap_or_default(); + let output_schema_call = output_schema + .map(|s| quote! { .with_raw_output_schema(#s) }) + .unwrap_or_default(); + let annotations_call = annotations + .map(|a| quote! { .with_annotations(#a) }) + .unwrap_or_default(); + let execution_call = execution + .map(|e| quote! { .with_execution(#e) }) + .unwrap_or_default(); + let icons_call = icons + .map(|i| quote! { .with_icons(#i) }) + .unwrap_or_default(); + let meta_call = meta.map(|m| quote! { .with_meta(#m) }).unwrap_or_default(); let doc_comment = format!("Generated tool metadata function for {name}"); let doc_attr: syn::Attribute = parse_quote!(#[doc = #doc_comment]); let tokens = quote! { @@ -164,12 +160,12 @@ impl ResolvedToolAttribute { #description, #input_schema, ) - .with_title(#title) - .with_raw_output_schema(#output_schema) - .with_annotations(#annotations) - .with_execution(#execution) - .with_icons(#icons) - .with_meta(#meta) + #title_call + #output_schema_call + #annotations_call + #execution_call + #icons_call + #meta_call } }; syn::parse2::(tokens) @@ -260,17 +256,17 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { let idempotent_hint = wrap_option(idempotent_hint); let open_world_hint = wrap_option(open_world_hint); let token_stream = quote! { - Some(rmcp::model::ToolAnnotations::from_raw( + rmcp::model::ToolAnnotations::from_raw( #title, #read_only_hint, #destructive_hint, #idempotent_hint, #open_world_hint, - )) + ) }; - syn::parse2::(token_stream)? + Some(syn::parse2::(token_stream)?) } else { - none_expr()? + None }; let execution_expr = if let Some(execution) = attribute.execution { let ToolExecutionAttribute { task_support } = execution; @@ -296,13 +292,13 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { }; let token_stream = quote! { - Some(rmcp::model::ToolExecution::from_raw( + rmcp::model::ToolExecution::from_raw( #task_support_expr, - )) + ) }; - syn::parse2::(token_stream)? + Some(syn::parse2::(token_stream)?) } else { - none_expr()? + None }; // Handle output_schema - either explicit or generated from return type let output_schema_expr = attribute.output_schema.or_else(|| { diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index c0b3dc436..538061516 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1455,13 +1455,10 @@ impl LoggingMessageNotificationParam { } } - /// Create with a logger name. - pub fn with_logger(level: LoggingLevel, logger: impl Into, data: Value) -> Self { - Self { - level, - logger: Some(logger.into()), - data, - } + /// Set the logger name. + pub fn with_logger(mut self, logger: impl Into) -> Self { + self.logger = Some(logger.into()); + self } } @@ -2605,12 +2602,10 @@ impl CreateElicitationResult { } } - /// Create with content. - pub fn with_content(action: ElicitationAction, content: Value) -> Self { - Self { - action, - content: Some(content), - } + /// Set the content on this result. + pub fn with_content(mut self, content: Value) -> Self { + self.content = Some(content); + self } } @@ -2822,8 +2817,8 @@ impl CallToolRequestParams { } /// Sets the task metadata for this tool call. - pub fn with_task(mut self, task: Option) -> Self { - self.task = task; + pub fn with_task(mut self, task: JsonObject) -> Self { + self.task = Some(task); self } } @@ -2889,8 +2884,8 @@ impl CreateMessageResult { pub const STOP_REASON_TOOL_USE: &str = "toolUse"; /// Set the stop reason. - pub fn with_stop_reason(mut self, stop_reason: Option) -> Self { - self.stop_reason = stop_reason; + pub fn with_stop_reason(mut self, stop_reason: impl Into) -> Self { + self.stop_reason = Some(stop_reason.into()); self } diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index 4d491d0e1..531a86d25 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -68,20 +68,20 @@ impl Prompt { } /// Set the human-readable title - pub fn with_title(mut self, title: Option) -> Self { - self.title = title; + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); self } /// Set the icons - pub fn with_icons(mut self, icons: Option>) -> Self { - self.icons = icons; + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); self } /// Set the metadata - pub fn with_meta(mut self, meta: Option) -> Self { - self.meta = meta; + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); self } } diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index 82b762de3..ca6e56915 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -261,32 +261,32 @@ impl Tool { } /// Set the human-readable title - pub fn with_title(mut self, title: Option) -> Self { - self.title = title; + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); self } /// Set the output schema from a raw value - pub fn with_raw_output_schema(mut self, output_schema: Option>) -> Self { - self.output_schema = output_schema; + pub fn with_raw_output_schema(mut self, output_schema: Arc) -> Self { + self.output_schema = Some(output_schema); self } /// Set the annotations - pub fn with_annotations(mut self, annotations: Option) -> Self { - self.annotations = annotations; + pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self { + self.annotations = Some(annotations); self } /// Set the icons - pub fn with_icons(mut self, icons: Option>) -> Self { - self.icons = icons; + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); self } /// Set the metadata - pub fn with_meta(mut self, meta: Option) -> Self { - self.meta = meta; + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); self } @@ -298,8 +298,8 @@ impl Tool { } /// Set the execution configuration for this tool. - pub fn with_execution(mut self, execution: Option) -> Self { - self.execution = execution; + pub fn with_execution(mut self, execution: ToolExecution) -> Self { + self.execution = Some(execution); self } diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index 2084981a2..811bd824b 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -78,7 +78,7 @@ impl ClientHandler for TestClientHandler { SamplingMessage::assistant_text(response.to_string()), "test-model".to_string(), ) - .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()))) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) } fn on_logging_message( diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index d885e46ce..02da06cf7 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -55,7 +55,7 @@ async fn test_sampling_result_structure() -> Result<()> { SamplingMessage::assistant_text("The capital of France is Paris."), "test-model".to_string(), ) - .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string())); + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); let json = serde_json::to_string(&result)?; let deserialized: CreateMessageResult = serde_json::from_str(&json)?; @@ -436,7 +436,7 @@ async fn test_create_message_result_tool_use_stop_reason() -> Result<()> { ), "test-model".to_string(), ) - .with_stop_reason(Some(CreateMessageResult::STOP_REASON_TOOL_USE.to_string())); + .with_stop_reason(CreateMessageResult::STOP_REASON_TOOL_USE); let json = serde_json::to_string(&result)?; let deserialized: CreateMessageResult = serde_json::from_str(&json)?; @@ -688,7 +688,7 @@ async fn test_create_message_result_validate_rejects_user_role() { SamplingMessage::user_text("This should not be a user message"), "test-model".to_string(), ) - .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string())); + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); let err = result.validate().unwrap_err(); assert!( @@ -703,7 +703,7 @@ async fn test_create_message_result_validate_accepts_assistant_role() { SamplingMessage::assistant_text("Hello!"), "test-model".to_string(), ) - .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string())); + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN); assert!(result.validate().is_ok()); } diff --git a/crates/rmcp/tests/test_task_support_validation.rs b/crates/rmcp/tests/test_task_support_validation.rs index cd9997684..88d2ed519 100644 --- a/crates/rmcp/tests/test_task_support_validation.rs +++ b/crates/rmcp/tests/test_task_support_validation.rs @@ -13,7 +13,6 @@ use rmcp::{ model::{CallToolRequestParams, ClientInfo, ErrorCode, JsonObject}, tool, tool_handler, tool_router, }; -use serde_json::json; /// Server with tools having different task support modes. #[derive(Debug, Clone)] @@ -75,8 +74,8 @@ impl ClientHandler for DummyClientHandler { } /// Helper to create a task object for tool calls -fn make_task() -> Option { - Some(json!({}).as_object().unwrap().clone()) +fn make_task() -> JsonObject { + serde_json::Map::new() } #[tokio::test] diff --git a/examples/clients/src/sampling_stdio.rs b/examples/clients/src/sampling_stdio.rs index 27b9273c0..cc7c5f153 100644 --- a/examples/clients/src/sampling_stdio.rs +++ b/examples/clients/src/sampling_stdio.rs @@ -44,7 +44,7 @@ impl ClientHandler for SamplingDemoClient { SamplingMessage::assistant_text(response_text), "mock_llm".to_string(), ) - .with_stop_reason(Some(CreateMessageResult::STOP_REASON_END_TURN.to_string()))) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) } } From 434ccb7812ce275f1a9b8ebbb79ee54a0d8a4be5 Mon Sep 17 00:00:00 2001 From: Peter Siska Date: Tue, 3 Mar 2026 18:43:31 +0100 Subject: [PATCH 079/333] fix(auth): pass WWW-Authenticate scopes to DCR registration request (#705) * fix(auth): pass WWW-Authenticate scopes to DCR registration request When an MCP server returns a 401 with `WWW-Authenticate: Bearer scope="..."`, the scopes are parsed but never included in the Dynamic Client Registration (DCR) request. Per RFC 7591, the DCR request should include a `scope` field so the authorization server knows what scopes the client intends to use. Servers that enforce scope-matching between registration and authorization will reject the flow without this. Changes: - Add optional `scope` field to `ClientRegistrationRequest` with `skip_serializing_if` for backward compatibility - Update `register_client()` to accept scopes parameter and include them in the DCR request body and returned `OAuthClientConfig` - Thread scopes from `AuthorizationSession::new()` into both `register_client()` call sites - Re-export `oauth2::TokenResponse` trait so consumers can extract scopes from token responses - Add serialization tests for the new `scope` field * refactor(auth): change register_client to accept &[&str] instead of &[String] Avoids unnecessary Vec allocation in callers that already have &[&str]. * fix(auth): make ClientRegistrationRequest crate-private * refactor(auth): stop re-exporting oauth2 TokenResponse trait * style(auth): merge TokenResponse into grouped oauth2 import Fix nightly rustfmt check by consolidating the separate `use oauth2::TokenResponse` into the existing `use oauth2::{...}` block. --- crates/rmcp/src/transport/auth.rs | 46 ++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 6578b5c3b..23e946a10 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -438,12 +438,14 @@ pub struct AuthorizationManager { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClientRegistrationRequest { +pub(crate) struct ClientRegistrationRequest { pub client_name: String, pub redirect_uris: Vec, pub grant_types: Vec, pub token_endpoint_auth_method: String, pub response_types: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -683,6 +685,7 @@ impl AuthorizationManager { &mut self, name: &str, redirect_uri: &str, + scopes: &[&str], ) -> Result { if self.metadata.is_none() { return Err(AuthError::NoAuthorizationSupport); @@ -705,6 +708,11 @@ impl AuthorizationManager { ], token_endpoint_auth_method: "none".to_string(), // public client response_types: vec!["code".to_string()], + scope: if scopes.is_empty() { + None + } else { + Some(scopes.join(" ")) + }, }; let response = match self @@ -758,7 +766,7 @@ impl AuthorizationManager { // as a password, which is not a goal of the client secret. client_secret: reg_response.client_secret.filter(|s| !s.is_empty()), redirect_uri: redirect_uri.to_string(), - scopes: vec![], + scopes: scopes.iter().map(|s| s.to_string()).collect(), }; self.configure_client(config.clone())?; @@ -1526,7 +1534,7 @@ impl AuthorizationSession { } else { // Fallback to dynamic registration auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri) + .register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes) .await .map_err(|e| { AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e)) @@ -1535,7 +1543,7 @@ impl AuthorizationSession { } else { // Fallback to dynamic registration match auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri) + .register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes) .await { Ok(config) => config, @@ -2831,4 +2839,34 @@ mod tests { "expected InternalError when OAuth client is not configured, got: {err:?}" ); } + + // -- ClientRegistrationRequest serialization -- + + #[test] + fn client_registration_request_includes_scope_when_present() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: Some("read write".to_string()), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["scope"], "read write"); + } + + #[test] + fn client_registration_request_omits_scope_when_none() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert!(!json.as_object().unwrap().contains_key("scope")); + } } From e223b53812de1fc1b42e88d003bf65e7f6e350aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:03:37 -0500 Subject: [PATCH 080/333] chore: release v1.0.0 (#721) * chore: release v1.0.0-alpha.1 * chore: version 1.0.0 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Alex Hancock --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 8 ++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 60bb16fae..fd9897ead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.0.0-alpha", path = "./crates/rmcp" } -rmcp-macros = { version = "1.0.0-alpha", path = "./crates/rmcp-macros" } +rmcp = { version = "1.0.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.0.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.0.0-alpha" +version = "1.0.0" authors = ["4t145 "] license = "Apache-2.0" license-file = "LICENSE" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 546f8f24a..06a3b33a3 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.0.0-alpha...rmcp-macros-v1.0.0) - 2026-03-03 + +### Fixed + +- api ergonomics follow-up ([#720](https://github.com/modelcontextprotocol/rust-sdk/pull/720)) + ## [1.0.0-alpha](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v0.17.0...rmcp-macros-v1.0.0-alpha) - 2026-03-03 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 3dea6f108..87c5e6da8 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.0.0-alpha...rmcp-v1.0.0) - 2026-03-03 + +### Fixed + +- *(auth)* pass WWW-Authenticate scopes to DCR registration request ([#705](https://github.com/modelcontextprotocol/rust-sdk/pull/705)) +- api ergonomics follow-up ([#720](https://github.com/modelcontextprotocol/rust-sdk/pull/720)) +- *(streamable-http)* map stale session 401 to status-aware error ([#709](https://github.com/modelcontextprotocol/rust-sdk/pull/709)) + ## [1.0.0-alpha](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v0.17.0...rmcp-v1.0.0-alpha) - 2026-03-03 ### Added From 60a5518efb5fcfae0e8ce3829ee47c3638171dfb Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 3 Mar 2026 17:07:55 -0800 Subject: [PATCH 081/333] docs: add McpMux to Built with rmcp section (#717) Signed-off-by: Mohammod Al Amin Ashik --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8f2bbcf1e..10378b607 100644 --- a/README.md +++ b/README.md @@ -906,6 +906,7 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. - [spreadsheet-mcp](https://github.com/PSU3D0/spreadsheet-mcp) - Token-efficient MCP server for spreadsheet analysis with automatic region detection, recalculation, screenshot, and editing support for LLM agents - [hyper-mcp](https://github.com/hyper-mcp-rs/hyper-mcp) - A fast, secure MCP server that extends its capabilities through WebAssembly (WASM) plugins - [rudof-mcp](https://github.com/rudof-project/rudof/tree/master/rudof_mcp) - RDF validation and data processing MCP server with ShEx/SHACL validation, SPARQL queries, and format conversion. Supports stdio and streamable HTTP transports with full MCP capabilities (tools, prompts, resources, logging, completions, tasks) +- [McpMux](https://github.com/mcpmux/mcp-mux) - Desktop app to configure MCP servers once at McpMux, connect every AI client (Cursor, Claude Desktop, VS Code, Windsurf) through a single encrypted local gateway with Spaces for project organization, FeatureSets to switch toolsets per client, and a built-in server registry ## Development From bb6c8043bf065860fd84a07680201ba75e718e8a Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:53:51 -0500 Subject: [PATCH 082/333] feat: implement OAuth 2.0 Client Credentials flow (#707) * feat: implement OAuth 2.0 Client Credentials flow * fix: address SEP-1046 review findings * fix: validate HTTPS on JWT token endpoint --- crates/rmcp/Cargo.toml | 9 + crates/rmcp/src/transport.rs | 5 +- crates/rmcp/src/transport/auth.rs | 711 ++++++++++++++++++ crates/rmcp/tests/test_client_credentials.rs | 197 +++++ examples/clients/Cargo.toml | 4 + .../clients/src/auth/client_credentials.rs | 97 +++ 6 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 crates/rmcp/tests/test_client_credentials.rs create mode 100644 examples/clients/src/auth/client_credentials.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 96c319dc4..c5d919ae7 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -27,6 +27,8 @@ pin-project-lite = "0.2" pastey = { version = "0.2.0", optional = true } # oauth2 support oauth2 = { version = "5.0", optional = true, default-features = false } +# JWT signing for client credentials (private_key_jwt) +jsonwebtoken = { version = "9", optional = true } # for auto generate schema schemars = { version = "1.0", optional = true, features = ["chrono04"] } @@ -130,12 +132,14 @@ transport-streamable-http-server-session = [ # transport-ws = ["transport-io", "dep:tokio-tungstenite"] tower = ["dep:tower-service"] auth = ["dep:oauth2", "__reqwest", "dep:url"] +auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"] schemars = ["dep:schemars"] [dev-dependencies] tokio = { version = "1", features = ["full"] } schemars = { version = "1.1.0", features = ["chrono04"] } axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +url = "2.4" anyhow = "1.0" tracing-subscriber = { version = "0.3", features = [ "env-filter", @@ -251,3 +255,8 @@ path = "tests/test_custom_headers.rs" name = "test_sse_concurrent_streams" required-features = ["server", "client", "transport-streamable-http-server", "transport-streamable-http-client", "reqwest"] path = "tests/test_sse_concurrent_streams.rs" + +[[test]] +name = "test_client_credentials" +required-features = ["auth"] +path = "tests/test_client_credentials.rs" diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index d7dfa9790..683f6880f 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -93,10 +93,13 @@ pub use io::stdio; #[cfg(feature = "auth")] pub mod auth; +#[cfg(feature = "auth-client-credentials-jwt")] +pub use auth::JwtSigningAlgorithm; #[cfg(feature = "auth")] pub use auth::{ AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, - CredentialStore, InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore, + ClientCredentialsConfig, CredentialStore, EXTENSION_OAUTH_CLIENT_CREDENTIALS, + InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams, }; diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 23e946a10..afea70f24 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -315,6 +315,13 @@ pub enum AuthError { required_scope: String, upgrade_url: Option, }, + + #[error("Client credentials error: {0}")] + ClientCredentialsError(String), + + #[cfg(feature = "auth-client-credentials-jwt")] + #[error("JWT signing error: {0}")] + JwtSigningError(String), } /// oauth2 metadata @@ -402,6 +409,105 @@ type OAuthClient = oauth2::Client< >; type Credentials = (String, Option); +/// OAuth 2.0 extension identifier for client credentials flow (SEP-1046) +pub const EXTENSION_OAUTH_CLIENT_CREDENTIALS: &str = + "io.modelcontextprotocol/oauth-client-credentials"; + +/// JWT signing algorithm for private_key_jwt authentication (SEP-1046) +#[cfg(feature = "auth-client-credentials-jwt")] +#[derive(Debug, Clone, Copy)] +pub enum JwtSigningAlgorithm { + RS256, + RS384, + RS512, + ES256, + ES384, +} + +#[cfg(feature = "auth-client-credentials-jwt")] +impl JwtSigningAlgorithm { + fn to_jsonwebtoken_algorithm(self) -> jsonwebtoken::Algorithm { + match self { + JwtSigningAlgorithm::RS256 => jsonwebtoken::Algorithm::RS256, + JwtSigningAlgorithm::RS384 => jsonwebtoken::Algorithm::RS384, + JwtSigningAlgorithm::RS512 => jsonwebtoken::Algorithm::RS512, + JwtSigningAlgorithm::ES256 => jsonwebtoken::Algorithm::ES256, + JwtSigningAlgorithm::ES384 => jsonwebtoken::Algorithm::ES384, + } + } + + fn as_str(self) -> &'static str { + match self { + JwtSigningAlgorithm::RS256 => "RS256", + JwtSigningAlgorithm::RS384 => "RS384", + JwtSigningAlgorithm::RS512 => "RS512", + JwtSigningAlgorithm::ES256 => "ES256", + JwtSigningAlgorithm::ES384 => "ES384", + } + } +} + +/// Configuration for OAuth 2.0 Client Credentials flow (SEP-1046) +/// +/// This supports two authentication methods: +/// - `ClientSecret`: credentials sent in the request body +/// - `PrivateKeyJwt`: RFC 7523 signed JWT assertion (requires `auth-client-credentials-jwt` feature) +#[derive(Debug, Clone)] +pub enum ClientCredentialsConfig { + /// Client secret authentication (credentials in request body) + ClientSecret { + client_id: String, + client_secret: String, + scopes: Vec, + resource: Option, + }, + /// Private key JWT authentication (RFC 7523) + #[cfg(feature = "auth-client-credentials-jwt")] + PrivateKeyJwt { + client_id: String, + signing_key: Vec, + signing_algorithm: JwtSigningAlgorithm, + /// Override the `aud` claim in the JWT assertion; defaults to token_endpoint + token_endpoint_audience: Option, + scopes: Vec, + resource: Option, + }, +} + +impl ClientCredentialsConfig { + fn client_id(&self) -> &str { + match self { + ClientCredentialsConfig::ClientSecret { client_id, .. } => client_id, + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { client_id, .. } => client_id, + } + } + + fn scopes(&self) -> &[String] { + match self { + ClientCredentialsConfig::ClientSecret { scopes, .. } => scopes, + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { scopes, .. } => scopes, + } + } + + fn resource(&self) -> Option<&str> { + match self { + ClientCredentialsConfig::ClientSecret { resource, .. } => resource.as_deref(), + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { resource, .. } => resource.as_deref(), + } + } + + fn auth_method(&self) -> &str { + match self { + ClientCredentialsConfig::ClientSecret { .. } => "client_secret_post", + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { .. } => "private_key_jwt", + } + } +} + /// Configuration for scope upgrade behavior #[derive(Debug, Clone)] pub struct ScopeUpgradeConfig { @@ -1489,6 +1595,394 @@ impl AuthorizationManager { Some((trimmed[..end].to_string(), leading_ws + end)) } } + + // -- Client Credentials flow (SEP-1046) -- + + /// Validate that the authorization server metadata supports the requested + /// client credentials authentication method. + /// + /// For `client_secret_post`, checks `token_endpoint_auth_methods_supported`. + /// For `private_key_jwt`, additionally checks `token_endpoint_auth_signing_alg_values_supported`. + /// When the metadata field is absent, the method is permissive (assumes support). + pub fn validate_client_credentials_metadata( + &self, + config: &ClientCredentialsConfig, + ) -> Result<(), AuthError> { + let Some(metadata) = self.metadata.as_ref() else { + return Ok(()); + }; + + if let Some(methods) = metadata + .additional_fields + .get("token_endpoint_auth_methods_supported") + .and_then(|v| v.as_array()) + { + let is_supported = match config { + ClientCredentialsConfig::ClientSecret { .. } => { + // Accept either client_secret_post (request body) or + // client_secret_basic (HTTP Basic) per the MCP auth spec. + methods.iter().any(|m| { + matches!( + m.as_str(), + Some("client_secret_post") | Some("client_secret_basic") + ) + }) + } + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { .. } => methods + .iter() + .any(|m| m.as_str() == Some("private_key_jwt")), + }; + if !is_supported { + let required_method = config.auth_method(); + let supported: Vec<&str> = methods.iter().filter_map(|m| m.as_str()).collect(); + return Err(AuthError::ClientCredentialsError(format!( + "Authorization server does not support auth method '{}'. Supported: {:?}", + required_method, supported + ))); + } + } + + #[cfg(feature = "auth-client-credentials-jwt")] + if let ClientCredentialsConfig::PrivateKeyJwt { + signing_algorithm, .. + } = config + { + if let Some(algs) = metadata + .additional_fields + .get("token_endpoint_auth_signing_alg_values_supported") + .and_then(|v| v.as_array()) + { + let alg_str = signing_algorithm.as_str(); + if !algs.iter().any(|a| a.as_str() == Some(alg_str)) { + let supported: Vec<&str> = algs.iter().filter_map(|a| a.as_str()).collect(); + return Err(AuthError::ClientCredentialsError(format!( + "Authorization server does not support signing algorithm '{}'. Supported: {:?}", + alg_str, supported + ))); + } + } + } + + Ok(()) + } + + /// Configure the OAuth2 client for the client credentials flow. + /// + /// Selects `client_secret_post` (request body) by default. Switches to + /// `client_secret_basic` (HTTP Basic) only when the server advertises that + /// method exclusively. For `PrivateKeyJwt`, no OAuth client state is needed + /// here; the token request is built manually in `exchange_client_credentials_jwt`. + pub fn configure_client_credentials( + &mut self, + config: &ClientCredentialsConfig, + ) -> Result<(), AuthError> { + let metadata = self + .metadata + .as_ref() + .ok_or(AuthError::NoAuthorizationSupport)?; + + let token_url = TokenUrl::new(metadata.token_endpoint.clone()) + .map_err(|e| AuthError::OAuthError(format!("Invalid token URL: {}", e)))?; + + // auth_url is required by the type but won't be used for client credentials + let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone()) + .map_err(|e| AuthError::OAuthError(format!("Invalid authorization URL: {}", e)))?; + + let client_id = ClientId::new(config.client_id().to_string()); + + let mut client_builder: OAuthClient = oauth2::Client::new(client_id) + .set_auth_uri(auth_url) + .set_token_uri(token_url); + + match config { + ClientCredentialsConfig::ClientSecret { client_secret, .. } => { + client_builder = + client_builder.set_client_secret(ClientSecret::new(client_secret.clone())); + // Use client_secret_basic (HTTP Basic) when that is the only method + // the server advertises; fall back to client_secret_post (request body). + let only_basic = metadata + .additional_fields + .get("token_endpoint_auth_methods_supported") + .and_then(|v| v.as_array()) + .map(|arr| { + let (has_basic, has_post) = + arr.iter() + .fold((false, false), |(b, p), m| match m.as_str() { + Some("client_secret_basic") => (true, p), + Some("client_secret_post") => (b, true), + _ => (b, p), + }); + has_basic && !has_post + }) + .unwrap_or_default(); + if !only_basic { + client_builder = client_builder.set_auth_type(AuthType::RequestBody); + } + } + #[cfg(feature = "auth-client-credentials-jwt")] + ClientCredentialsConfig::PrivateKeyJwt { .. } => { + // For JWT, client identity comes from the assertion's `sub` claim. + // The request is built manually in exchange_client_credentials_jwt to + // ensure client_id is not included in the body per RFC 7523 §3. + } + } + + self.oauth_client = Some(client_builder); + Ok(()) + } + + /// Exchange client credentials for an access token (SEP-1046). + /// + /// For `ClientSecret`: sends credentials in the request body (or Authorization header + /// for `client_secret_basic`) with scopes and resource. + /// For `PrivateKeyJwt`: builds the request manually (no `client_id` in body per RFC 7523 §3). + pub async fn exchange_client_credentials( + &self, + config: &ClientCredentialsConfig, + ) -> Result { + // The MCP auth spec requires the `resource` parameter in all token requests. + if config.resource().is_none() { + return Err(AuthError::ClientCredentialsError( + "resource parameter is required by the MCP auth spec".to_string(), + )); + } + + // For private_key_jwt, use a separate path that omits client_id from the request + // body, as required by RFC 7523 §3 (client is identified by the JWT `sub` claim). + #[cfg(feature = "auth-client-credentials-jwt")] + if matches!(config, ClientCredentialsConfig::PrivateKeyJwt { .. }) { + return self.exchange_client_credentials_jwt(config).await; + } + + let oauth_client = self + .oauth_client + .as_ref() + .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; + + let mut request = oauth_client.exchange_client_credentials(); + + for scope in config.scopes() { + request = request.add_scope(Scope::new(scope.clone())); + } + + if let Some(resource) = config.resource() { + request = request.add_extra_param("resource", resource); + } + + let http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| AuthError::InternalError(e.to_string()))?; + + let token_result = match request + .request_async(&OAuthReqwestClient(http_client)) + .await + { + Ok(token) => token, + Err(RequestTokenError::Parse(_, body)) => { + match serde_json::from_slice::(&body) { + Ok(parsed) => { + warn!( + "client credentials token exchange failed to parse completely but included a valid token response. Accepting it." + ); + parsed + } + Err(parse_err) => { + return Err(AuthError::ClientCredentialsError(format!( + "Token exchange parse error: {}", + parse_err + ))); + } + } + } + Err(e) => { + return Err(AuthError::ClientCredentialsError(format!( + "Token exchange failed: {}", + e + ))); + } + }; + + debug!("client credentials token result: {:?}", token_result); + + let granted_scopes: Vec = token_result + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + *self.current_scopes.write().await = granted_scopes.clone(); + + let client_id = config.client_id().to_string(); + let stored = StoredCredentials { + client_id, + token_response: Some(token_result.clone()), + granted_scopes, + token_received_at: Some(Self::now_epoch_secs()), + }; + self.credential_store.save(stored).await?; + + Ok(token_result) + } + + /// Exchange client credentials using a JWT assertion (RFC 7523). + /// + /// Builds the token request manually so that `client_id` is **not** included in the + /// request body; client identity is conveyed solely by the `sub` claim in the assertion. + #[cfg(feature = "auth-client-credentials-jwt")] + async fn exchange_client_credentials_jwt( + &self, + config: &ClientCredentialsConfig, + ) -> Result { + let ClientCredentialsConfig::PrivateKeyJwt { + client_id, + signing_key, + signing_algorithm, + token_endpoint_audience, + scopes, + resource, + } = config + else { + return Err(AuthError::InternalError( + "expected PrivateKeyJwt config".to_string(), + )); + }; + + let metadata = self + .metadata + .as_ref() + .ok_or(AuthError::NoAuthorizationSupport)?; + + // Validate that the token endpoint uses HTTPS before transmitting sensitive credentials. + let token_endpoint_url = url::Url::parse(&metadata.token_endpoint).map_err(|e| { + AuthError::ClientCredentialsError(format!( + "Invalid token endpoint URL in authorization metadata: {e}" + )) + })?; + if token_endpoint_url.scheme() != "https" { + return Err(AuthError::ClientCredentialsError( + "Insecure token endpoint URL: HTTPS is required for client credentials flow" + .to_string(), + )); + } + + let audience = token_endpoint_audience + .as_deref() + .unwrap_or(&metadata.token_endpoint); + + let assertion = + Self::build_jwt_assertion(client_id, audience, signing_key, *signing_algorithm)?; + + let scope_str = scopes.join(" "); + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("grant_type", "client_credentials"); + serializer.append_pair( + "client_assertion_type", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ); + serializer.append_pair("client_assertion", &assertion); + if !scope_str.is_empty() { + serializer.append_pair("scope", &scope_str); + } + if let Some(res) = resource.as_deref() { + serializer.append_pair("resource", res); + } + let body_str = serializer.finish(); + + let http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| AuthError::InternalError(e.to_string()))?; + + let response = http_client + .post(token_endpoint_url.as_str()) + .header("content-type", "application/x-www-form-urlencoded") + .body(body_str) + .send() + .await + .map_err(|e| { + AuthError::ClientCredentialsError(format!("Token exchange request failed: {e}")) + })?; + + let status = response.status(); + let body = response.bytes().await.map_err(|e| { + AuthError::ClientCredentialsError(format!("Failed to read token response: {e}")) + })?; + + if !status.is_success() { + let msg = if let Ok(v) = serde_json::from_slice::(&body) { + let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"); + let desc = v + .get("error_description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + format!("Token exchange failed: {error}: {desc}") + } else { + format!("Token exchange failed: HTTP {status}") + }; + return Err(AuthError::ClientCredentialsError(msg)); + } + + let token_result = serde_json::from_slice::(&body).map_err(|e| { + AuthError::ClientCredentialsError(format!("Failed to parse token response: {e}")) + })?; + + debug!("client credentials JWT token result: {:?}", token_result); + + let granted_scopes: Vec = token_result + .scopes() + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + *self.current_scopes.write().await = granted_scopes.clone(); + + let stored = StoredCredentials { + client_id: client_id.clone(), + token_response: Some(token_result.clone()), + granted_scopes, + token_received_at: Some(Self::now_epoch_secs()), + }; + self.credential_store.save(stored).await?; + + Ok(token_result) + } + + /// Build a JWT assertion per RFC 7523 for private_key_jwt authentication. + #[cfg(feature = "auth-client-credentials-jwt")] + fn build_jwt_assertion( + client_id: &str, + audience: &str, + signing_key: &[u8], + algorithm: JwtSigningAlgorithm, + ) -> Result { + use serde_json::json; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let jti = uuid::Uuid::new_v4().to_string(); + + let claims = json!({ + "iss": client_id, + "sub": client_id, + "aud": audience, + "iat": now, + "exp": now + 300, // 5 minutes + "jti": jti, + }); + + let header = jsonwebtoken::Header::new(algorithm.to_jsonwebtoken_algorithm()); + let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(signing_key).or_else(|_| { + jsonwebtoken::EncodingKey::from_ec_pem(signing_key).map_err(|e| { + AuthError::JwtSigningError(format!("Failed to parse signing key: {}", e)) + }) + })?; + + jsonwebtoken::encode(&header, &claims, &encoding_key) + .map_err(|e| AuthError::JwtSigningError(format!("Failed to sign JWT: {}", e))) + } } /// oauth2 authorization session, for guiding user to complete the authorization process @@ -1907,6 +2401,41 @@ impl OAuthState { _ => None, } } + + /// Authenticate using OAuth 2.0 Client Credentials flow (SEP-1046). + /// + /// Transitions directly from `Unauthorized` to `Authorized`, skipping the + /// interactive `Session` state entirely. Discovers metadata, configures the + /// client, and exchanges credentials for an access token. + pub async fn authenticate_client_credentials( + &mut self, + config: ClientCredentialsConfig, + ) -> Result<(), AuthError> { + let OAuthState::Unauthorized(mut manager) = std::mem::replace( + self, + OAuthState::Unauthorized(AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?), + ) else { + return Err(AuthError::InternalError( + "Client credentials flow requires Unauthorized state".to_string(), + )); + }; + + // Discover metadata + let metadata = manager.discover_metadata().await?; + manager.metadata = Some(metadata); + + // Validate server supports the requested auth method + manager.validate_client_credentials_metadata(&config)?; + + // Configure OAuth client + manager.configure_client_credentials(&config)?; + + // Exchange credentials for token + manager.exchange_client_credentials(&config).await?; + + *self = OAuthState::Authorized(manager); + Ok(()) + } } #[cfg(test)] @@ -2869,4 +3398,186 @@ mod tests { let json = serde_json::to_value(&req).unwrap(); assert!(!json.as_object().unwrap().contains_key("scope")); } + + // -- client credentials (SEP-1046) -- + + #[tokio::test] + async fn configure_client_credentials_uses_request_body_auth_for_client_secret() { + let mut mgr = manager_with_metadata(None).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "my-m2m-client".to_string(), + client_secret: "super-secret".to_string(), + scopes: vec!["read".to_string()], + resource: None, + }; + mgr.configure_client_credentials(&config).unwrap(); + let oauth_client = mgr.oauth_client.as_ref().unwrap(); + assert!(matches!(oauth_client.auth_type(), AuthType::RequestBody)); + } + + #[tokio::test] + async fn configure_client_credentials_sets_correct_client_id() { + let mut mgr = manager_with_metadata(None).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "my-m2m-client".to_string(), + client_secret: "super-secret".to_string(), + scopes: vec!["read".to_string()], + resource: None, + }; + mgr.configure_client_credentials(&config).unwrap(); + let oauth_client = mgr.oauth_client.as_ref().unwrap(); + assert_eq!(oauth_client.client_id().as_str(), "my-m2m-client"); + } + + #[tokio::test] + async fn configure_client_credentials_returns_error_without_metadata() { + let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap(); + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + let err = mgr.configure_client_credentials(&config).unwrap_err(); + assert!(matches!(err, AuthError::NoAuthorizationSupport)); + } + + #[tokio::test] + async fn validate_client_credentials_metadata_rejects_unsupported_method() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + // Neither client_secret_post nor client_secret_basic — should be rejected. + serde_json::json!(["tls_client_auth", "private_key_jwt"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mgr = manager_with_metadata(Some(meta)).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + let err = mgr + .validate_client_credentials_metadata(&config) + .unwrap_err(); + assert!( + err.to_string().contains("tls_client_auth"), + "expected error to mention unsupported method, got: {err}" + ); + } + + #[tokio::test] + async fn validate_client_credentials_metadata_accepts_supported_method() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_post", "client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mgr = manager_with_metadata(Some(meta)).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + mgr.validate_client_credentials_metadata(&config).unwrap(); + } + + #[tokio::test] + async fn validate_client_credentials_metadata_permits_when_field_absent() { + let mgr = manager_with_metadata(None).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + mgr.validate_client_credentials_metadata(&config).unwrap(); + } + + #[tokio::test] + async fn validate_client_credentials_metadata_accepts_client_secret_basic_only() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mgr = manager_with_metadata(Some(meta)).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + // A server advertising only client_secret_basic must be accepted. + mgr.validate_client_credentials_metadata(&config).unwrap(); + } + + #[tokio::test] + async fn configure_client_credentials_uses_basic_auth_when_server_only_supports_basic() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + scopes: vec![], + resource: None, + }; + mgr.configure_client_credentials(&config).unwrap(); + let oauth_client = mgr.oauth_client.as_ref().unwrap(); + assert!( + !matches!(oauth_client.auth_type(), AuthType::RequestBody), + "expected HTTP Basic auth when server only supports client_secret_basic" + ); + } + + #[test] + fn client_credentials_config_returns_correct_accessor_values() { + let config = super::ClientCredentialsConfig::ClientSecret { + client_id: "test-id".to_string(), + client_secret: "test-secret".to_string(), + scopes: vec!["scope1".to_string(), "scope2".to_string()], + resource: Some("https://example.com".to_string()), + }; + assert_eq!(config.client_id(), "test-id"); + assert_eq!(config.scopes(), &["scope1", "scope2"]); + assert_eq!(config.resource(), Some("https://example.com")); + assert_eq!(config.auth_method(), "client_secret_post"); + } + + #[test] + fn extension_constant_matches_spec() { + assert_eq!( + super::EXTENSION_OAUTH_CLIENT_CREDENTIALS, + "io.modelcontextprotocol/oauth-client-credentials" + ); + } } diff --git a/crates/rmcp/tests/test_client_credentials.rs b/crates/rmcp/tests/test_client_credentials.rs new file mode 100644 index 000000000..a90b8b0e6 --- /dev/null +++ b/crates/rmcp/tests/test_client_credentials.rs @@ -0,0 +1,197 @@ +use std::{convert::Infallible, net::SocketAddr}; + +use axum::{ + Router, + body::Body, + http::{Request, Response, StatusCode}, + routing::{get, post}, +}; +use rmcp::transport::auth::{ClientCredentialsConfig, OAuthState}; + +fn json_response(body: serde_json::Value) -> Response { + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap() +} + +fn json_error(status: StatusCode, body: serde_json::Value) -> Response { + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap() +} + +async fn resource_metadata_handler(req: Request) -> Result, Infallible> { + let host = req.headers().get("host").unwrap().to_str().unwrap(); + let base_url = format!("http://{}", host); + Ok(json_response(serde_json::json!({ + "resource": base_url, + "authorization_servers": [base_url], + "scopes_supported": ["read", "write"] + }))) +} + +async fn auth_server_metadata_handler(req: Request) -> Result, Infallible> { + let host = req.headers().get("host").unwrap().to_str().unwrap(); + let base_url = format!("http://{}", host); + Ok(json_response(serde_json::json!({ + "issuer": base_url, + "authorization_endpoint": format!("{}/authorize", base_url), + "token_endpoint": format!("{}/token", base_url), + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "grant_types_supported": ["client_credentials"], + "scopes_supported": ["read", "write"] + }))) +} + +async fn token_handler(req: Request) -> Result, Infallible> { + let body_bytes = axum::body::to_bytes(req.into_body(), 1024 * 64) + .await + .unwrap(); + let body_str = String::from_utf8(body_bytes.to_vec()).unwrap(); + + // Parse form-urlencoded body + let params: Vec<(String, String)> = url::form_urlencoded::parse(body_str.as_bytes()) + .into_owned() + .collect(); + + let get_param = |key: &str| -> Option { + params + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + }; + + let grant_type = get_param("grant_type").unwrap_or_default(); + if grant_type != "client_credentials" { + return Ok(json_error( + StatusCode::BAD_REQUEST, + serde_json::json!({ + "error": "unsupported_grant_type", + "error_description": "Only client_credentials grant type is supported" + }), + )); + } + + let client_id = get_param("client_id").unwrap_or_default(); + if client_id != "test-m2m-client" { + return Ok(json_error( + StatusCode::UNAUTHORIZED, + serde_json::json!({ + "error": "invalid_client", + "error_description": "Unknown client_id" + }), + )); + } + + let client_secret = get_param("client_secret").unwrap_or_default(); + if client_secret != "test-m2m-secret" { + return Ok(json_error( + StatusCode::UNAUTHORIZED, + serde_json::json!({ + "error": "invalid_client", + "error_description": "Invalid client_secret" + }), + )); + } + + let scope = get_param("scope").unwrap_or_default(); + + let mut response = serde_json::json!({ + "access_token": "m2m-access-token-12345", + "token_type": "Bearer", + "expires_in": 3600 + }); + + if !scope.is_empty() { + response["scope"] = serde_json::Value::String(scope); + } + + Ok(json_response(response)) +} + +async fn start_mock_server() -> (String, SocketAddr) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + + let app = Router::new() + .route( + "/.well-known/oauth-protected-resource", + get(resource_metadata_handler), + ) + .route( + "/.well-known/oauth-authorization-server", + get(auth_server_metadata_handler), + ) + .route("/token", post(token_handler)); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (base_url, addr) +} + +#[tokio::test] +async fn test_client_credentials_flow_client_secret() { + let (base_url, _addr) = start_mock_server().await; + + let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "test-m2m-client".to_string(), + client_secret: "test-m2m-secret".to_string(), + scopes: vec!["read".to_string(), "write".to_string()], + resource: Some(base_url.clone()), + }; + + oauth_state + .authenticate_client_credentials(config) + .await + .unwrap(); + + let manager = oauth_state + .into_authorization_manager() + .expect("Should be in Authorized state"); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "m2m-access-token-12345"); +} + +#[tokio::test] +async fn test_client_credentials_invalid_secret() { + let (base_url, _addr) = start_mock_server().await; + + let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "test-m2m-client".to_string(), + client_secret: "wrong-secret".to_string(), + scopes: vec![], + resource: Some(base_url.clone()), + }; + + let result = oauth_state.authenticate_client_credentials(config).await; + assert!(result.is_err(), "Should fail with invalid credentials"); +} + +#[tokio::test] +async fn test_client_credentials_invalid_client_id() { + let (base_url, _addr) = start_mock_server().await; + + let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "unknown-client".to_string(), + client_secret: "test-m2m-secret".to_string(), + scopes: vec![], + resource: Some(base_url.clone()), + }; + + let result = oauth_state.authenticate_client_credentials(config).await; + assert!(result.is_err(), "Should fail with unknown client_id"); +} diff --git a/examples/clients/Cargo.toml b/examples/clients/Cargo.toml index ea35b0211..f2ac8dd7d 100644 --- a/examples/clients/Cargo.toml +++ b/examples/clients/Cargo.toml @@ -57,3 +57,7 @@ path = "src/sampling_stdio.rs" [[example]] name = "clients_progress_client" path = "src/progress_client.rs" + +[[example]] +name = "clients_client_credentials" +path = "src/auth/client_credentials.rs" diff --git a/examples/clients/src/auth/client_credentials.rs b/examples/clients/src/auth/client_credentials.rs new file mode 100644 index 000000000..55aa61535 --- /dev/null +++ b/examples/clients/src/auth/client_credentials.rs @@ -0,0 +1,97 @@ +use std::env; + +use anyhow::{Context, Result}; +use rmcp::{ + ServiceExt, + model::ClientInfo, + transport::{ + StreamableHttpClientTransport, + auth::{AuthClient, ClientCredentialsConfig, OAuthState}, + streamable_http_client::StreamableHttpClientTransportConfig, + }, +}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +/// Example: OAuth 2.0 Client Credentials flow (SEP-1046) +/// +/// Usage: +/// cargo run -p mcp-client-examples --example clients_client_credentials -- +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let args: Vec = env::args().collect(); + let server_url = args + .get(1) + .context("Usage: ")? + .clone(); + let client_id = args + .get(2) + .context("Usage: ")? + .clone(); + let client_secret = args + .get(3) + .context("Usage: ")? + .clone(); + + tracing::info!("Connecting to MCP server at: {}", server_url); + tracing::info!("Using client_id: {}", client_id); + + // Initialize OAuth state and authenticate with client credentials + let mut oauth_state = OAuthState::new(&server_url, None) + .await + .context("Failed to initialize OAuth state")?; + + let config = ClientCredentialsConfig::ClientSecret { + client_id, + client_secret, + scopes: vec![], + resource: Some(server_url.clone()), + }; + + oauth_state + .authenticate_client_credentials(config) + .await + .context("Client credentials authentication failed")?; + + tracing::info!("Successfully authenticated with client credentials"); + + // Create authorized transport + let manager = oauth_state + .into_authorization_manager() + .context("Failed to get authorization manager")?; + let client = AuthClient::new(reqwest::Client::default(), manager); + let transport = StreamableHttpClientTransport::with_client( + client, + StreamableHttpClientTransportConfig::with_uri(server_url.as_str()), + ); + + // Connect to MCP server and list tools + let client_service = ClientInfo::default(); + let client = client_service.serve(transport).await?; + tracing::info!("Connected to MCP server"); + + match client.peer().list_all_tools().await { + Ok(tools) => { + println!("Available tools ({}):", tools.len()); + for tool in tools { + println!( + " - {} ({})", + tool.name, + tool.description.unwrap_or_default() + ); + } + } + Err(e) => { + tracing::error!("Failed to list tools: {}", e); + } + } + + Ok(()) +} From 53c86d5d9d2f323b5f8044cdf2e575404aff6a6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:57:31 -0500 Subject: [PATCH 083/333] chore: release v1.0.1 (#722) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 10 ++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fd9897ead..38a4711f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.0.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.0.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.1.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.1.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.0.0" +version = "1.1.0" authors = ["4t145 "] license = "Apache-2.0" license-file = "LICENSE" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 06a3b33a3..5ad7241ee 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.0.0...rmcp-macros-v1.1.0) - 2026-03-04 + +### Other + +- add McpMux to Built with rmcp section ([#717](https://github.com/modelcontextprotocol/rust-sdk/pull/717)) + ## [1.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.0.0-alpha...rmcp-macros-v1.0.0) - 2026-03-03 ### Fixed diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 87c5e6da8..c6e1ba0c4 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.0.0...rmcp-v1.1.0) - 2026-03-04 + +### Added + +- implement OAuth 2.0 Client Credentials flow ([#707](https://github.com/modelcontextprotocol/rust-sdk/pull/707)) + +### Other + +- add McpMux to Built with rmcp section ([#717](https://github.com/modelcontextprotocol/rust-sdk/pull/717)) + ## [1.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.0.0-alpha...rmcp-v1.0.0) - 2026-03-03 ### Fixed From a8ea0f49b17af16863298fc67d197d349d64058d Mon Sep 17 00:00:00 2001 From: Tanish Desai Date: Wed, 4 Mar 2026 19:12:05 +0530 Subject: [PATCH 084/333] docs: modify build command in README (#706) Updated the build command to specify the package for the MCP server examples. --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 3d0489723..2b358aa10 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,7 +3,7 @@ 1. **Build the Server (Counter Example)** ```sh - cargo build --release --example servers_counter_stdio + cargo build --release -p mcp-server-examples --example servers_counter_stdio ``` This builds a standard input/output MCP server binary. From 770937a9fe63b8299c54c09cf805325d395f1333 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 5 Mar 2026 09:07:21 -0500 Subject: [PATCH 085/333] fix: conformance syntax changes (#723) --- conformance/src/bin/client.rs | 111 +++------- conformance/src/bin/server.rs | 388 +++++++++++++--------------------- 2 files changed, 178 insertions(+), 321 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 53a44d9e7..422435840 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -5,8 +5,7 @@ use rmcp::{ model::*, service::RequestContext, transport::{ - AuthClient, AuthorizationManager, StreamableHttpClientTransport, - auth::{OAuthClientConfig, OAuthState}, + AuthClient, AuthorizationManager, StreamableHttpClientTransport, auth::OAuthState, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -17,9 +16,6 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[derive(Debug, Default, serde::Deserialize)] struct ConformanceContext { - #[serde(default)] - name: Option, - // pre-registration / client-credentials-basic #[serde(default)] client_id: Option, #[serde(default)] @@ -29,15 +25,6 @@ struct ConformanceContext { private_key_pem: Option, #[serde(default)] signing_algorithm: Option, - // cross-app-access - #[serde(default)] - idp_client_id: Option, - #[serde(default)] - idp_id_token: Option, - #[serde(default)] - idp_issuer: Option, - #[serde(default)] - idp_token_endpoint: Option, } fn load_context() -> ConformanceContext { @@ -175,17 +162,17 @@ impl ClientHandler for FullClientHandler { .and_then(|c| c.as_text()) .map(|t| t.text.clone()) .unwrap_or_default(); - Ok(CreateMessageResult { - message: SamplingMessage::new( + Ok(CreateMessageResult::new( + SamplingMessage::new( Role::Assistant, SamplingMessageContent::text(format!( "This is a mock LLM response to: {}", prompt_text )), ), - model: "mock-model".into(), - stop_reason: Some("endTurn".into()), - }) + "mock-model".into(), + ) + .with_stop_reason("endTurn")) } } @@ -216,7 +203,7 @@ const REDIRECT_URI: &str = "http://localhost:3000/callback"; /// 4. Return an `AuthClient` wrapping `reqwest::Client` async fn perform_oauth_flow( server_url: &str, - ctx: &ConformanceContext, + _ctx: &ConformanceContext, ) -> anyhow::Result> { let mut oauth = OAuthState::new(server_url, None).await?; @@ -335,12 +322,7 @@ async fn run_auth_client(server_url: &str, ctx: &ConformanceContext) -> anyhow:: for tool in &tools.tools { let args = build_tool_arguments(tool); let _ = client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: args, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), args)) .await; } @@ -352,7 +334,7 @@ async fn run_auth_client(server_url: &str, ctx: &ConformanceContext) -> anyhow:: /// then call tool which triggers 403 → re-auth with expanded scopes → retry. async fn run_auth_scope_step_up_client( server_url: &str, - ctx: &ConformanceContext, + _ctx: &ConformanceContext, ) -> anyhow::Result<()> { // First auth let mut oauth = OAuthState::new(server_url, None).await?; @@ -388,12 +370,7 @@ async fn run_auth_scope_step_up_client( for tool in &tools.tools { let args = build_tool_arguments(tool); match client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: args.clone(), - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), args.clone())) .await { Ok(_) => { @@ -428,12 +405,7 @@ async fn run_auth_scope_step_up_client( ); let client2 = BasicClientHandler.serve(transport2).await?; let _ = client2 - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: args, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), args)) .await; client2.cancel().await.ok(); return Ok(()); @@ -481,12 +453,7 @@ async fn run_auth_scope_retry_limit_client( for tool in &tools.tools { let args = build_tool_arguments(tool); match client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: args, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), args)) .await { Ok(_) => {} @@ -539,12 +506,7 @@ async fn run_auth_preregistered_client( for tool in &tools.tools { let args = build_tool_arguments(tool); let _ = client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: args, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), args)) .await; } client.cancel().await?; @@ -597,12 +559,7 @@ async fn run_client_credentials_basic( for tool in &tools.tools { let args = build_tool_arguments(tool); let _ = client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: args, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), args)) .await; } client.cancel().await?; @@ -667,12 +624,7 @@ async fn run_client_credentials_jwt( for tool in &tools.tools { let args = build_tool_arguments(tool); let _ = client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: args, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), args)) .await; } client.cancel().await?; @@ -783,6 +735,18 @@ async fn headless_authorize(auth_url: &str) -> anyhow::Result<(String, String)> Ok((code, state)) } +/// Build a `CallToolRequestParams` for a tool, optionally with arguments. +fn call_tool_params( + name: std::borrow::Cow<'static, str>, + arguments: Option>, +) -> CallToolRequestParams { + let mut p = CallToolRequestParams::new(name); + if let Some(a) = arguments { + p = p.with_arguments(a); + } + p +} + /// Build arguments for a tool based on its input schema. fn build_tool_arguments(tool: &Tool) -> Option> { let schema = &tool.input_schema; @@ -840,12 +804,7 @@ async fn run_tools_call_client(server_url: &str) -> anyhow::Result<()> { for tool in &tools.tools { let args = build_tool_arguments(tool); let _ = client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: args, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), args)) .await?; } client.cancel().await?; @@ -862,12 +821,7 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()> }); if let Some(tool) = test_tool { let _ = client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: None, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), None)) .await?; } client.cancel().await?; @@ -884,12 +838,7 @@ async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> { .find(|t| t.name.as_ref() == "test_reconnection") { let _ = client - .call_tool(CallToolRequestParams { - meta: None, - name: tool.name.clone(), - arguments: None, - task: None, - }) + .call_tool(call_tool_params(tool.name.clone(), None)) .await?; } client.cancel().await?; diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 97bfbcdcc..4cfde48c6 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -48,24 +48,16 @@ impl ServerHandler for ConformanceServer { _cx: RequestContext, ) -> impl Future> + Send + '_ { async { - Ok(InitializeResult { - server_info: Implementation { - name: "rust-conformance-server".into(), - title: None, - version: "0.1.0".into(), - description: None, - icons: None, - website_url: None, - }, - capabilities: ServerCapabilities::builder() + Ok(InitializeResult::new( + ServerCapabilities::builder() .enable_prompts() .enable_resources() .enable_tools() .enable_logging() .build(), - instructions: Some("Rust MCP conformance test server".into()), - ..Default::default() - }) + ) + .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) + .with_instructions("Rust MCP conformance test server")) } } @@ -232,19 +224,14 @@ impl ServerHandler for ConformanceServer { async move { let args = request.arguments.unwrap_or_default(); match request.name.as_ref() { - "test_simple_text" => Ok(CallToolResult { - content: vec![Content::text("This is a simple text response for testing.")], - structured_content: None, - is_error: None, - meta: None, - }), - - "test_image_content" => Ok(CallToolResult { - content: vec![Content::image(TEST_IMAGE_DATA, "image/png")], - structured_content: None, - is_error: None, - meta: None, - }), + "test_simple_text" => Ok(CallToolResult::success(vec![Content::text( + "This is a simple text response for testing.", + )])), + + "test_image_content" => Ok(CallToolResult::success(vec![Content::image( + TEST_IMAGE_DATA, + "image/png", + )])), "test_audio_content" => { // No Content::audio() helper, construct manually @@ -253,41 +240,28 @@ impl ServerHandler for ConformanceServer { mime_type: "audio/wav".into(), }) .no_annotation(); - Ok(CallToolResult { - content: vec![audio], - structured_content: None, - is_error: None, - meta: None, - }) + Ok(CallToolResult::success(vec![audio])) } - "test_embedded_resource" => Ok(CallToolResult { - content: vec![Content::resource(ResourceContents::TextResourceContents { + "test_embedded_resource" => Ok(CallToolResult::success(vec![Content::resource( + ResourceContents::TextResourceContents { uri: "test://embedded-resource".into(), mime_type: Some("text/plain".into()), text: "This is an embedded resource content.".into(), meta: None, - })], - structured_content: None, - is_error: None, - meta: None, - }), - - "test_multiple_content_types" => Ok(CallToolResult { - content: vec![ - Content::text("Multiple content types test:"), - Content::image(TEST_IMAGE_DATA, "image/png"), - Content::resource(ResourceContents::TextResourceContents { - uri: "test://mixed-content-resource".into(), - mime_type: Some("application/json".into()), - text: r#"{"test":"data","value":123}"#.into(), - meta: None, - }), - ], - structured_content: None, - is_error: None, - meta: None, - }), + }, + )])), + + "test_multiple_content_types" => Ok(CallToolResult::success(vec![ + Content::text("Multiple content types test:"), + Content::image(TEST_IMAGE_DATA, "image/png"), + Content::resource(ResourceContents::TextResourceContents { + uri: "test://mixed-content-resource".into(), + mime_type: Some("application/json".into()), + text: r#"{"test":"data","value":123}"#.into(), + meta: None, + }), + ])), "test_tool_with_logging" => { for msg in [ @@ -306,22 +280,14 @@ impl ServerHandler for ConformanceServer { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - Ok(CallToolResult { - content: vec![Content::text("Logging test completed")], - structured_content: None, - is_error: None, - meta: None, - }) + Ok(CallToolResult::success(vec![Content::text( + "Logging test completed", + )])) } - "test_error_handling" => Ok(CallToolResult { - content: vec![Content::text( - "This tool intentionally returns an error for testing", - )], - structured_content: None, - is_error: Some(true), - meta: None, - }), + "test_error_handling" => Ok(CallToolResult::error(vec![Content::text( + "This tool intentionally returns an error for testing", + )])), "test_tool_with_progress" => { let progress_token = cx.meta.get_progress_token(); @@ -343,12 +309,9 @@ impl ServerHandler for ConformanceServer { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - Ok(CallToolResult { - content: vec![Content::text("Progress test completed")], - structured_content: None, - is_error: None, - meta: None, - }) + Ok(CallToolResult::success(vec![Content::text( + "Progress test completed", + )])) } "test_sampling" => { @@ -359,20 +322,10 @@ impl ServerHandler for ConformanceServer { match cx .peer - .create_message(CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text(prompt)], - max_tokens: 100, - model_preferences: None, - system_prompt: None, - include_context: None, - temperature: None, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, - }) + .create_message(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text(prompt)], + 100, + )) .await { Ok(result) => { @@ -383,19 +336,15 @@ impl ServerHandler for ConformanceServer { .and_then(|c| c.as_text()) .map(|t| t.text.clone()) .unwrap_or_else(|| "No text response".into()); - Ok(CallToolResult { - content: vec![Content::text(format!("LLM response: {}", text))], - structured_content: None, - is_error: None, - meta: None, - }) + Ok(CallToolResult::success(vec![Content::text(format!( + "LLM response: {}", + text + ))])) } - Err(e) => Ok(CallToolResult { - content: vec![Content::text(format!("Sampling error: {}", e))], - structured_content: None, - is_error: Some(true), - meta: None, - }), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Sampling error: {}", + e + ))])), } } @@ -431,26 +380,19 @@ impl ServerHandler for ConformanceServer { }) .await { - Ok(result) => Ok(CallToolResult { - content: vec![Content::text(format!( - "User response: action={}, content={:?}", - match result.action { - ElicitationAction::Accept => "accept", - ElicitationAction::Decline => "decline", - ElicitationAction::Cancel => "cancel", - }, - result.content - ))], - structured_content: None, - is_error: None, - meta: None, - }), - Err(e) => Ok(CallToolResult { - content: vec![Content::text(format!("Elicitation error: {}", e))], - structured_content: None, - is_error: Some(true), - meta: None, - }), + Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + "User response: action={}, content={:?}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }, + result.content + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Elicitation error: {}", + e + ))])), } } @@ -498,26 +440,19 @@ impl ServerHandler for ConformanceServer { }) .await { - Ok(result) => Ok(CallToolResult { - content: vec![Content::text(format!( - "Elicitation completed: action={}, content={:?}", - match result.action { - ElicitationAction::Accept => "accept", - ElicitationAction::Decline => "decline", - ElicitationAction::Cancel => "cancel", - }, - result.content - ))], - structured_content: None, - is_error: None, - meta: None, - }), - Err(e) => Ok(CallToolResult { - content: vec![Content::text(format!("Elicitation error: {}", e))], - structured_content: None, - is_error: Some(true), - meta: None, - }), + Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + "Elicitation completed: action={}, content={:?}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }, + result.content + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Elicitation error: {}", + e + ))])), } } @@ -573,46 +508,34 @@ impl ServerHandler for ConformanceServer { }) .await { - Ok(result) => Ok(CallToolResult { - content: vec![Content::text(format!( - "Enum elicitation completed: action={}", - match result.action { - ElicitationAction::Accept => "accept", - ElicitationAction::Decline => "decline", - ElicitationAction::Cancel => "cancel", - } - ))], - structured_content: None, - is_error: None, - meta: None, - }), - Err(e) => Ok(CallToolResult { - content: vec![Content::text(format!("Elicitation error: {}", e))], - structured_content: None, - is_error: Some(true), - meta: None, - }), + Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + "Enum elicitation completed: action={}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + } + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Elicitation error: {}", + e + ))])), } } "json_schema_2020_12_tool" => { let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world"); - Ok(CallToolResult { - content: vec![Content::text(format!("Hello, {}!", name))], - structured_content: None, - is_error: None, - meta: None, - }) + Ok(CallToolResult::success(vec![Content::text(format!( + "Hello, {}!", + name + ))])) } "test_reconnection" => { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - Ok(CallToolResult { - content: vec![Content::text("Reconnection test completed")], - structured_content: None, - is_error: None, - meta: None, - }) + Ok(CallToolResult::success(vec![Content::text( + "Reconnection test completed", + )])) } _ => Err(ErrorData::invalid_params( @@ -668,22 +591,22 @@ impl ServerHandler for ConformanceServer { async move { let uri = request.uri.as_str(); match uri { - "test://static-text" => Ok(ReadResourceResult { - contents: vec![ResourceContents::TextResourceContents { + "test://static-text" => Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { uri: uri.into(), mime_type: Some("text/plain".into()), text: "This is the content of the static text resource.".into(), meta: None, - }], - }), - "test://static-binary" => Ok(ReadResourceResult { - contents: vec![ResourceContents::BlobResourceContents { + }, + ])), + "test://static-binary" => Ok(ReadResourceResult::new(vec![ + ResourceContents::BlobResourceContents { uri: uri.into(), mime_type: Some("image/png".into()), blob: TEST_IMAGE_DATA.into(), meta: None, - }], - }), + }, + ])), _ => { // Check if it matches template: test://template/{id}/data if uri.starts_with("test://template/") && uri.ends_with("/data") { @@ -691,8 +614,8 @@ impl ServerHandler for ConformanceServer { .strip_prefix("test://template/") .and_then(|s| s.strip_suffix("/data")) .unwrap_or("unknown"); - Ok(ReadResourceResult { - contents: vec![ResourceContents::TextResourceContents { + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { uri: uri.into(), mime_type: Some("application/json".into()), text: format!( @@ -700,8 +623,8 @@ impl ServerHandler for ConformanceServer { id, id ), meta: None, - }], - }) + }, + ])) } else { Err(ErrorData::resource_not_found( format!("Resource not found: {}", uri), @@ -779,18 +702,12 @@ impl ServerHandler for ConformanceServer { "test_prompt_with_arguments", Some("A test prompt that accepts arguments"), Some(vec![ - PromptArgument { - name: "name".into(), - title: None, - description: Some("The name to greet".into()), - required: Some(true), - }, - PromptArgument { - name: "style".into(), - title: None, - description: Some("The greeting style".into()), - required: Some(false), - }, + PromptArgument::new("name") + .with_description("The name to greet") + .with_required(true), + PromptArgument::new("style") + .with_description("The greeting style") + .with_required(false), ]), ), Prompt::new( @@ -816,13 +733,11 @@ impl ServerHandler for ConformanceServer { ) -> impl Future> + Send + '_ { async move { match request.name.as_str() { - "test_simple_prompt" => Ok(GetPromptResult { - description: Some("A simple test prompt".into()), - messages: vec![PromptMessage::new_text( - PromptMessageRole::User, - "This is a simple test prompt.", - )], - }), + "test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text( + PromptMessageRole::User, + "This is a simple test prompt.", + )]) + .with_description("A simple test prompt")), "test_prompt_with_arguments" => { let args = request.arguments.unwrap_or_default(); let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("World"); @@ -830,47 +745,41 @@ impl ServerHandler for ConformanceServer { .get("style") .and_then(|v| v.as_str()) .unwrap_or("friendly"); - Ok(GetPromptResult { - description: Some("A prompt with arguments".into()), - messages: vec![PromptMessage::new_text( - PromptMessageRole::User, - format!("Please greet {} in a {} style.", name, style), - )], - }) + Ok(GetPromptResult::new(vec![PromptMessage::new_text( + PromptMessageRole::User, + format!("Please greet {} in a {} style.", name, style), + )]) + .with_description("A prompt with arguments")) } - "test_prompt_with_embedded_resource" => Ok(GetPromptResult { - description: Some("A prompt with an embedded resource".into()), - messages: vec![ - PromptMessage::new_text(PromptMessageRole::User, "Here is a resource:"), - PromptMessage::new_resource( - PromptMessageRole::User, - "test://static-text".into(), - Some("text/plain".into()), - Some("Resource content for prompt".into()), - None, - None, - None, - ), - ], - }), + "test_prompt_with_embedded_resource" => Ok(GetPromptResult::new(vec![ + PromptMessage::new_text(PromptMessageRole::User, "Here is a resource:"), + PromptMessage::new_resource( + PromptMessageRole::User, + "test://static-text".into(), + Some("text/plain".into()), + Some("Resource content for prompt".into()), + None, + None, + None, + ), + ]) + .with_description("A prompt with an embedded resource")), "test_prompt_with_image" => { let image_content = RawImageContent { data: TEST_IMAGE_DATA.into(), mime_type: "image/png".into(), meta: None, }; - Ok(GetPromptResult { - description: Some("A prompt with an image".into()), - messages: vec![ - PromptMessage::new_text(PromptMessageRole::User, "Here is an image:"), - PromptMessage { - role: PromptMessageRole::User, - content: PromptMessageContent::Image { - image: image_content.no_annotation(), - }, + Ok(GetPromptResult::new(vec![ + PromptMessage::new_text(PromptMessageRole::User, "Here is an image:"), + PromptMessage::new( + PromptMessageRole::User, + PromptMessageContent::Image { + image: image_content.no_annotation(), }, - ], - }) + ), + ]) + .with_description("A prompt with an image")) } _ => Err(ErrorData::invalid_params( format!("Unknown prompt: {}", request.name), @@ -904,10 +813,9 @@ impl ServerHandler for ConformanceServer { } } }; - Ok(CompleteResult { - completion: CompletionInfo::new(values) - .map_err(|e| ErrorData::internal_error(e, None))?, - }) + Ok(CompleteResult::new( + CompletionInfo::new(values).map_err(|e| ErrorData::internal_error(e, None))?, + )) } } From 5c5a2e734d9c6b4872f4797ca8fdee6b2ac72f2e Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 5 Mar 2026 13:33:13 -0500 Subject: [PATCH 086/333] docs: roadmap.md update for correctness (#725) --- ROADMAP.md | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 62eee5dc9..4910bec0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ This roadmap tracks the path to SEP-1730 Tier 1 for the Rust MCP SDK. -Server conformance: 86.7% (26/30) · Client conformance: 85.0% (18/24) · Spec tracking gap: 6 days +Server conformance: 87.5% (28/32) · Client conformance: 80.0% (16/20) --- @@ -10,18 +10,19 @@ Server conformance: 86.7% (26/30) · Client conformance: 85.0% (18/24) · Spec t ### Conformance -#### Server (86.7% → 100%) +#### Server (87.5% → 100%) -- [ ] Fix `server-prompts-get-with-args` — prompt argument handling returns incorrect result -- [ ] Fix `server-prompts-get-embedded-resource` — embedded resource content in prompt responses -- [ ] Fix `server-elicitation-sep1330-enums` — enum inference handling per SEP-1330 -- [ ] Fix `server-dns-rebinding-protection` — validate `Host` / `Origin` headers on Streamable HTTP transport +- [ ] Fix `prompts-get-with-args` — prompt argument handling returns incorrect result (arg1/arg2 not substituted) +- [ ] Fix `prompts-get-embedded-resource` — embedded resource content in prompt responses (invalid content union) +- [ ] Fix `elicitation-sep1330-enums` — enum inference handling per SEP-1330 (missing enumNames for legacy titled enum) +- [ ] Fix `dns-rebinding-protection` — validate `Host` / `Origin` headers on Streamable HTTP transport (accepts invalid headers with 200) -#### Client (85.0% → 100%) +#### Client (80.0% → 100%) -- [ ] Fix `auth/scope-step-up` (2025-11-25) — handle 403 `insufficient_scope` and re-authorize with upgraded scopes -- [ ] Fix `auth/metadata-var3` (2025-11-25) — AS metadata discovery variant 3 -- [ ] Fix `auth/2025-03-26-oauth-endpoint-fallback` (2025-03-26) — legacy OAuth endpoint fallback for pre-2025-06-18 servers +- [ ] Fix `auth/metadata-var3` — AS metadata discovery variant 3 (no authorization support detected) +- [ ] Fix `auth/scope-from-www-authenticate` — use scope parameter from WWW-Authenticate header on 403 insufficient_scope +- [ ] Fix `auth/scope-step-up` — handle 403 `insufficient_scope` and re-authorize with upgraded scopes +- [ ] Fix `auth/2025-03-26-oauth-endpoint-fallback` — legacy OAuth endpoint fallback for pre-2025-06-18 servers (no authorization support detected) ### Governance & Policy @@ -58,10 +59,13 @@ Server conformance: 86.7% (26/30) · Client conformance: 85.0% (18/24) · Spec t --- -## Informational (not scored) +## Informational (not scored for tiering) -These draft/extension scenarios are tracked but do not block tier advancement: +These draft/extension scenarios are tracked but do not count toward tier advancement: -- [ ] `auth/resource-mismatch` (draft) -- [ ] `auth/cross-app-access-complete-flow` (extension) -- [ ] `auth/client-credentials-jwt` (extension) +| Scenario | Tag | Status | +|---|---|---| +| `auth/resource-mismatch` | draft | ❌ Failed | +| `auth/client-credentials-jwt` | extension | ❌ Failed — JWT `aud` claim verification error | +| `auth/client-credentials-basic` | extension | ✅ Passed | +| `auth/cross-app-access-complete-flow` | extension | ❌ Failed — sends `authorization_code` grant instead of `jwt-bearer` | From 9b507f5018e901e0b23279c7535c3969d8be5ed6 Mon Sep 17 00:00:00 2001 From: nazq Date: Sat, 7 Mar 2026 15:13:54 -0500 Subject: [PATCH 087/333] fix(rmcp-macros): replace deprecated *Param type aliases with *Params (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `#[task_handler]` macro generates code using deprecated type aliases (`PaginatedRequestParam`, `CallToolRequestParam`, `GetTaskInfoParam`, `GetTaskResultParam`, `CancelTaskParam`) that were renamed to `*Params` in rmcp 0.13.0. This causes 5 deprecation warnings for every crate using the macro. Update all references to use the canonical `*Params` names: - `PaginatedRequestParam` → `PaginatedRequestParams` - `CallToolRequestParam` → `CallToolRequestParams` - `GetTaskInfoParam` → `GetTaskInfoParams` - `GetTaskResultParam` → `GetTaskResultParams` - `CancelTaskParam` → `CancelTaskParams` Also fix the corresponding doc examples in `lib.rs`. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- crates/rmcp-macros/src/lib.rs | 4 ++-- crates/rmcp-macros/src/task_handler.rs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index ce9047e49..b04255af5 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -144,7 +144,7 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream { /// impl ServerHandler for MyToolHandler { /// async fn call_tool( /// &self, -/// request: CallToolRequestParam, +/// request: CallToolRequestParams, /// context: RequestContext, /// ) -> Result { /// let tcc = ToolCallContext::new(self, request, context); @@ -153,7 +153,7 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream { /// /// async fn list_tools( /// &self, -/// _request: Option, +/// _request: Option, /// _context: RequestContext, /// ) -> Result { /// let items = self.tool_router.list_all(); diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index 86664b18f..5c3169b2c 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -33,7 +33,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, + _request: Option, _: rmcp::service::RequestContext, ) -> Result { let running_ids = (#processor).lock().await.list_running(); @@ -61,7 +61,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { use rmcp::task_manager::{ @@ -116,7 +116,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { use rmcp::task_manager::current_timestamp; @@ -176,7 +176,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { use std::time::Duration; @@ -232,7 +232,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { use rmcp::task_manager::current_timestamp; From 8e5ebb4f5c427e3852e85a09878cc6466cddfe57 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:08:16 -0400 Subject: [PATCH 088/333] fix: accept logging/setLevel and ping before initialized notification (#730) * fix: accept logging/setLevel and ping before initialized notification * test: add server initialization tests for pre-init requests --- crates/rmcp/src/service/server.rs | 59 ++++--- .../rmcp/tests/test_server_initialization.rs | 160 ++++++++++++++++++ 2 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 crates/rmcp/tests/test_server_initialization.rs diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 666a79980..d011c225a 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -16,7 +16,7 @@ use crate::{ model::{ CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CreateMessageRequest, - CreateMessageRequestParams, CreateMessageResult, ErrorData, ListRootsRequest, + CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, ListRootsRequest, ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam, ProgressNotification, ProgressNotificationParam, PromptListChangedNotification, ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification, @@ -147,22 +147,6 @@ where ))) } -/// Helper function to expect a notification from the stream -async fn expect_notification( - transport: &mut T, - context: &str, -) -> Result -where - T: Transport, -{ - let msg = expect_next_message(transport, context).await?; - let msg_clone = msg.clone(); - msg.into_notification() - .ok_or(ServerInitializeError::ExpectedInitializedNotification( - Some(msg_clone), - )) -} - pub async fn serve_server_with_ct( service: S, transport: T, @@ -246,12 +230,41 @@ where ServerInitializeError::transport::(error, "sending initialize response") })?; - // Wait for initialize notification - let notification = expect_notification(&mut transport, "initialize notification").await?; - let ClientNotification::InitializedNotification(_) = notification else { - return Err(ServerInitializeError::ExpectedInitializedNotification( - Some(ClientJsonRpcMessage::notification(notification)), - )); + // Wait for initialized notification. The MCP spec permits logging/setLevel and ping + // before initialized; VS Code sends setLevel immediately after the initialize response. + let notification = loop { + let msg = expect_next_message(&mut transport, "initialize notification").await?; + match msg { + ClientJsonRpcMessage::Notification(n) + if matches!( + n.notification, + ClientNotification::InitializedNotification(_) + ) => + { + break n.notification; + } + ClientJsonRpcMessage::Request(req) + if matches!( + req.request, + ClientRequest::SetLevelRequest(_) | ClientRequest::PingRequest(_) + ) => + { + transport + .send(ServerJsonRpcMessage::response( + ServerResult::EmptyResult(EmptyResult {}), + req.id, + )) + .await + .map_err(|error| { + ServerInitializeError::transport::(error, "sending pre-init response") + })?; + } + other => { + return Err(ServerInitializeError::ExpectedInitializedNotification( + Some(other), + )); + } + } }; let context = NotificationContext { meta: notification.get_meta().clone(), diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs new file mode 100644 index 000000000..c07501f0b --- /dev/null +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -0,0 +1,160 @@ +// cargo test --features "client" --package rmcp -- server_init +#![cfg(feature = "client")] +mod common; + +use common::handlers::TestServer; +use rmcp::{ + ServiceExt, + model::{ClientJsonRpcMessage, ServerJsonRpcMessage, ServerResult}, + service::ServerInitializeError, + transport::{IntoTransport, Transport}, +}; + +fn msg(raw: &str) -> ClientJsonRpcMessage { + serde_json::from_str(raw).expect("invalid test message JSON") +} + +fn init_request() -> ClientJsonRpcMessage { + msg(r#"{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "0.0.1" } + } + }"#) +} + +fn initialized_notification() -> ClientJsonRpcMessage { + msg(r#"{ "jsonrpc": "2.0", "method": "notifications/initialized" }"#) +} + +fn set_level_request(id: u64) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "logging/setLevel", "params": {{ "level": "info" }} }}"# + )) +} + +fn ping_request(id: u64) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "ping" }}"# + )) +} + +fn list_tools_request(id: u64) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ "jsonrpc": "2.0", "id": {id}, "method": "tools/list" }}"# + )) +} + +async fn do_initialize(client: &mut impl Transport) { + client.send(init_request()).await.unwrap(); + let _response = client.receive().await.unwrap(); +} + +// Server responds with EmptyResult to setLevel received before initialized. +#[tokio::test] +async fn server_init_set_level_response_is_empty_result() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(set_level_request(2)).await.unwrap(); + + let response = client.receive().await.unwrap(); + assert!( + matches!( + response, + ServerJsonRpcMessage::Response(ref r) + if matches!(r.result, ServerResult::EmptyResult(_)) + ), + "expected EmptyResult for setLevel, got: {response:?}" + ); +} + +// Server initializes successfully when setLevel is sent before the initialized notification. +#[tokio::test] +async fn server_init_succeeds_after_set_level_before_initialized() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(set_level_request(2)).await.unwrap(); + let _response = client.receive().await.unwrap(); + client.send(initialized_notification()).await.unwrap(); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully after setLevel" + ); + result.unwrap().cancel().await.unwrap(); +} + +// Server responds with EmptyResult to ping received before initialized. +#[tokio::test] +async fn server_init_ping_response_is_empty_result() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(ping_request(2)).await.unwrap(); + + let response = client.receive().await.unwrap(); + assert!( + matches!( + response, + ServerJsonRpcMessage::Response(ref r) + if matches!(r.result, ServerResult::EmptyResult(_)) + ), + "expected EmptyResult for ping, got: {response:?}" + ); +} + +// Server initializes successfully when ping is sent before the initialized notification. +#[tokio::test] +async fn server_init_succeeds_after_ping_before_initialized() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(ping_request(2)).await.unwrap(); + let _response = client.receive().await.unwrap(); + client.send(initialized_notification()).await.unwrap(); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully after ping" + ); + result.unwrap().cancel().await.unwrap(); +} + +// Server returns ExpectedInitializedNotification for any other message before initialized. +#[tokio::test] +async fn server_init_rejects_unexpected_message_before_initialized() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(list_tools_request(2)).await.unwrap(); + + let result = server_handle.await.unwrap(); + assert!( + matches!( + result, + Err(ServerInitializeError::ExpectedInitializedNotification(_)) + ), + "expected ExpectedInitializedNotification error" + ); +} From 1158cfe1b80b97272fd2d1d137e94754d2635e5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:15:31 -0400 Subject: [PATCH 089/333] chore: release v1.1.1 (#732) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 38a4711f5..a45074598 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.1.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.1.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.1.1", path = "./crates/rmcp" } +rmcp-macros = { version = "1.1.1", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.1.0" +version = "1.1.1" authors = ["4t145 "] license = "Apache-2.0" license-file = "LICENSE" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 5ad7241ee..979cede8b 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.1.0...rmcp-macros-v1.1.1) - 2026-03-09 + +### Fixed + +- *(rmcp-macros)* replace deprecated *Param type aliases with *Params ([#727](https://github.com/modelcontextprotocol/rust-sdk/pull/727)) + ## [1.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.0.0...rmcp-macros-v1.1.0) - 2026-03-04 ### Other diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index c6e1ba0c4..4fba220da 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.1.0...rmcp-v1.1.1) - 2026-03-09 + +### Fixed + +- accept logging/setLevel and ping before initialized notification ([#730](https://github.com/modelcontextprotocol/rust-sdk/pull/730)) + ## [1.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.0.0...rmcp-v1.1.0) - 2026-03-04 ### Added From fc757d41ca311f16f8fa1e69a17aa9c1b901fcb0 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:56:27 -0400 Subject: [PATCH 090/333] fix: allow deserializing notifications without params field (#729) --- crates/rmcp/src/model.rs | 11 +++++++++++ crates/rmcp/src/model/serde_impl.rs | 17 ++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 538061516..2a79dc746 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3834,4 +3834,15 @@ mod tests { }); assert_eq!(json_url, expected_url_json); } + + #[test] + fn notification_without_params_should_deserialize_as_bare_jsonrpc_message() { + let payload = b"{\"method\":\"notifications/initialized\",\"jsonrpc\":\"2.0\"}"; + let result: Result = serde_json::from_slice(payload); + assert!( + matches!(result, Ok(JsonRpcMessage::Notification(_))), + "Expected Ok(Notification), got: {:?}", + result + ); + } } diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index c262d6acd..f8996f318 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -246,8 +246,19 @@ where where D: serde::Deserializer<'de>, { - let body = Proxy::deserialize(deserializer)?; - let _meta = body.params._meta.map(|m| m.into_owned()); + let body = ProxyOptionalParam::<'_, _, R>::deserialize(deserializer)?; + let (_meta, params) = match body.params { + Some(with_meta) => { + let meta = with_meta._meta.map(|m| m.into_owned()); + (meta, with_meta._rest) + } + None => { + // JSON-RPC 2.0: params is optional. Treat absent params as {}. + let empty = serde_json::Value::Object(serde_json::Map::new()); + let r = R::deserialize(empty).map_err(serde::de::Error::custom)?; + (None, r) + } + }; let mut extensions = Extensions::new(); if let Some(meta) = _meta { extensions.insert(meta); @@ -255,7 +266,7 @@ where Ok(Notification { extensions, method: body.method, - params: body.params._rest, + params, }) } } From be248980f25a74d1929b1c677d50bcc2b7e13f22 Mon Sep 17 00:00:00 2001 From: Axel Date: Tue, 10 Mar 2026 02:00:11 +0530 Subject: [PATCH 091/333] fix(rmcp-macros): use re-exported serde_json path in task_handler (#735) * fix(rmcp-macros): use re-exported serde_json path in task_handler Replace bare `::serde_json::` with `::rmcp::serde_json::` in task_handler.rs to prevent compilation errors in crates that don't directly depend on serde_json. Fixes #487 * Update crates/rmcp-macros/src/task_handler.rs --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp-macros/src/task_handler.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index 5c3169b2c..50416e032 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -193,7 +193,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result() { match &tool.result { Ok(call_tool) => { - let value = ::serde_json::to_value(call_tool).unwrap_or(::serde_json::Value::Null); + let value = ::rmcp::serde_json::to_value(call_tool).unwrap_or_default(); return Ok(rmcp::model::GetTaskPayloadResult::new(value)); } Err(err) => return Err(McpError::internal_error( From 54bb522e7f5aae5374b4a1aa955bf87d042b7d91 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:30:19 -0400 Subject: [PATCH 092/333] feat: include granted scopes in OAuth refresh token request (#731) * fix: include granted scopes in OAuth refresh token request * docs: document scope forwarding in token refresh flow --- crates/rmcp/src/transport/auth.rs | 181 +++++++++++++++++++++++++++++- docs/OAUTH_SUPPORT.md | 3 +- 2 files changed, 178 insertions(+), 6 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index afea70f24..a75b9ab54 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1176,17 +1176,22 @@ impl AuthorizationManager { .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; let stored = self.credential_store.load().await?; - let current_credentials = stored - .and_then(|s| s.token_response) - .ok_or_else(|| AuthError::AuthorizationRequired)?; + let stored_credentials = stored.ok_or(AuthError::AuthorizationRequired)?; + let current_credentials = stored_credentials + .token_response + .ok_or(AuthError::AuthorizationRequired)?; let refresh_token = current_credentials.refresh_token().ok_or_else(|| { AuthError::TokenRefreshFailed("No refresh token available".to_string()) })?; debug!("refresh token present, attempting refresh"); - let token_result = oauth_client - .exchange_refresh_token(&RefreshToken::new(refresh_token.secret().to_string())) + let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string()); + let mut refresh_request = oauth_client.exchange_refresh_token(&refresh_token_value); + for scope in &stored_credentials.granted_scopes { + refresh_request = refresh_request.add_scope(Scope::new(scope.clone())); + } + let token_result = refresh_request .request_async(&OAuthReqwestClient(self.http_client.clone())) .await .map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?; @@ -3580,4 +3585,170 @@ mod tests { "io.modelcontextprotocol/oauth-client-credentials" ); } + + // -- refresh_token -- + + fn make_token_response_with_refresh( + access_token: &str, + refresh_token: &str, + ) -> OAuthTokenResponse { + use oauth2::RefreshToken; + let mut resp = make_token_response(access_token, Some(3600)); + resp.set_refresh_token(Some(RefreshToken::new(refresh_token.to_string()))); + resp + } + + #[tokio::test] + async fn refresh_token_returns_error_when_no_stored_credentials() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let err = manager.refresh_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when no credentials stored, got: {err:?}" + ); + } + + #[tokio::test] + async fn refresh_token_returns_error_when_no_token_response() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: None, + granted_scopes: vec![], + token_received_at: None, + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.refresh_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when token_response is None, got: {err:?}" + ); + } + + #[tokio::test] + async fn refresh_token_returns_error_when_no_refresh_token() { + let mut manager = manager_with_metadata(None).await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response("old-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }; + manager.credential_store.save(stored).await.unwrap(); + + let err = manager.refresh_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::TokenRefreshFailed(_)), + "expected TokenRefreshFailed when no refresh token, got: {err:?}" + ); + } + + async fn start_token_server() -> (String, Arc>>) { + use axum::{Router, body::Body, http::Response, routing::post}; + let captured: Arc>> = Arc::new(std::sync::Mutex::new(None)); + let captured_clone = Arc::clone(&captured); + + let app = Router::new().route( + "/token", + post(move |body: axum::body::Bytes| { + let cap = Arc::clone(&captured_clone); + async move { + *cap.lock().unwrap() = + Some(String::from_utf8(body.to_vec()).unwrap()); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, + )) + .unwrap() + } + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + (format!("http://{}", addr), captured) + } + + #[tokio::test] + async fn refresh_token_sends_granted_scopes_in_request() { + let (base_url, captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec!["read".to_string(), "write".to_string()], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }; + manager.credential_store.save(stored).await.unwrap(); + + manager.refresh_token().await.unwrap(); + + let body = captured.lock().unwrap().take().unwrap(); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect(); + let scope = params + .get("scope") + .expect("scope should be present in refresh request"); + let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); + scope_parts.sort_unstable(); + assert_eq!(scope_parts, vec!["read", "write"]); + } + + #[tokio::test] + async fn refresh_token_omits_scope_when_granted_scopes_is_empty() { + let (base_url, captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }; + manager.credential_store.save(stored).await.unwrap(); + + manager.refresh_token().await.unwrap(); + + let body = captured.lock().unwrap().take().unwrap(); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect(); + assert!( + !params.contains_key("scope"), + "scope should be absent when granted_scopes is empty, body: {body}" + ); + } } diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index b0b59f9f7..dd32a17c1 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -127,7 +127,7 @@ cargo run -p mcp-client-examples --example clients_oauth_client 6. **Authorization Request**: Build authorization URL with PKCE (S256) and RFC 8707 resource parameter 7. **Authorization Code Exchange**: After user authorization, exchange code for access token (with resource parameter) 8. **Token Usage**: Use access token for API calls via `AuthClient` or `AuthorizedHttpClient` -9. **Token Refresh**: Automatically use refresh token to get new access token when current one expires +9. **Token Refresh**: Automatically use refresh token to get new access token when current one expires; previously granted scopes are forwarded in the refresh request so providers that require them (e.g. Azure AD v2) work correctly 10. **Scope Upgrade**: On 403 insufficient_scope, compute scope union and re-authorize with upgraded scopes ## Security Considerations @@ -158,3 +158,4 @@ If you encounter authorization issues, check the following: - [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) - [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) - [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636) +- [RFC 6749 §6: Refreshing an Access Token](https://www.rfc-editor.org/rfc/rfc6749#section-6) From 9fbf91e02157428efc67717e5973a5818695f877 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:41:16 -0400 Subject: [PATCH 093/333] chore(deps): update jsonwebtoken requirement from 9 to 10 (#737) Updates the requirements on [jsonwebtoken](https://github.com/Keats/jsonwebtoken) to permit the latest version. - [Changelog](https://github.com/Keats/jsonwebtoken/blob/master/CHANGELOG.md) - [Commits](https://github.com/Keats/jsonwebtoken/compare/v9.0.0...v10.3.0) --- updated-dependencies: - dependency-name: jsonwebtoken dependency-version: 10.3.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- crates/rmcp/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index c5d919ae7..31e05acab 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -28,7 +28,7 @@ pastey = { version = "0.2.0", optional = true } # oauth2 support oauth2 = { version = "5.0", optional = true, default-features = false } # JWT signing for client credentials (private_key_jwt) -jsonwebtoken = { version = "9", optional = true } +jsonwebtoken = { version = "10", optional = true } # for auto generate schema schemars = { version = "1.0", optional = true, features = ["chrono04"] } From 656a09a97a5885c0848d702ccfda83a980d9862a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:41:38 -0400 Subject: [PATCH 094/333] chore(deps): update rig-core requirement from 0.31.0 to 0.32.0 (#738) Updates the requirements on [rig-core](https://github.com/0xPlaygrounds/rig) to permit the latest version. - [Release notes](https://github.com/0xPlaygrounds/rig/releases) - [Changelog](https://github.com/0xPlaygrounds/rig/blob/main/release-plz.toml) - [Commits](https://github.com/0xPlaygrounds/rig/compare/rig-core-v0.31.0...rig-core-v0.32.0) --- updated-dependencies: - dependency-name: rig-core dependency-version: 0.32.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/rig-integration/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/rig-integration/Cargo.toml b/examples/rig-integration/Cargo.toml index 9429975eb..cfed3c4c1 100644 --- a/examples/rig-integration/Cargo.toml +++ b/examples/rig-integration/Cargo.toml @@ -13,7 +13,7 @@ readme = { workspace = true } publish = false [dependencies] -rig-core = "0.31.0" +rig-core = "0.32.0" tokio = { version = "1", features = ["full"] } rmcp = { workspace = true, features = [ "client", From 3d2c951ca3f3f492fbfbb56df1e830e71a6128ac Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:13:53 -0400 Subject: [PATCH 095/333] feat: add missing constructors for non-exhaustive model types (#739) * feat: add constructors for Root and ListRootsResult * feat: add constructors for UnsubscribeRequestParams and PromptReference --- crates/rmcp/src/model.rs | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 2a79dc746..f6cc5fb3e 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1262,6 +1262,16 @@ pub struct UnsubscribeRequestParams { pub uri: String, } +impl UnsubscribeRequestParams { + /// Creates a new `UnsubscribeRequestParams` for the given URI. + pub fn new(uri: impl Into) -> Self { + Self { + meta: None, + uri: uri.into(), + } + } +} + impl RequestParamsMeta for UnsubscribeRequestParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -2356,6 +2366,22 @@ pub struct PromptReference { pub title: Option, } +impl PromptReference { + /// Creates a new `PromptReference` with the given name. `title` defaults to `None`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + title: None, + } + } + + /// Sets the human-readable title for this prompt reference. + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } +} + const_string!(CompleteRequestMethod = "completion/complete"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] @@ -2378,6 +2404,22 @@ pub struct Root { pub name: Option, } +impl Root { + /// Creates a new `Root` with the given URI. `name` defaults to `None`. + pub fn new(uri: impl Into) -> Self { + Self { + uri: uri.into(), + name: None, + } + } + + /// Sets the human-readable name for this root. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + const_string!(ListRootsRequestMethod = "roots/list"); pub type ListRootsRequest = RequestNoParam; @@ -2389,6 +2431,13 @@ pub struct ListRootsResult { pub roots: Vec, } +impl ListRootsResult { + /// Creates a new `ListRootsResult` with the given roots. + pub fn new(roots: Vec) -> Self { + Self { roots } + } +} + const_string!(RootsListChangedNotificationMethod = "notifications/roots/list_changed"); pub type RootsListChangedNotification = NotificationNoParam; From 53224307728844aeafc36bce1164c97658562508 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:09:10 -0400 Subject: [PATCH 096/333] fix: handle ping requests sent before initialize handshake (#745) --- crates/rmcp/src/service/server.rs | 47 ++++++++++++------- .../rmcp/tests/test_server_initialization.rs | 41 ++++++++++++++++ 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index d011c225a..85f3f69a6 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -131,22 +131,6 @@ where .ok_or_else(|| ServerInitializeError::ConnectionClosed(context.to_string())) } -/// Helper function to expect a request from the stream -async fn expect_request( - transport: &mut T, - context: &str, -) -> Result<(ClientRequest, RequestId), ServerInitializeError> -where - T: Transport, -{ - let msg = expect_next_message(transport, context).await?; - let msg_clone = msg.clone(); - msg.into_request() - .ok_or(ServerInitializeError::ExpectedInitializeRequest(Some( - msg_clone, - ))) -} - pub async fn serve_server_with_ct( service: S, transport: T, @@ -177,8 +161,35 @@ where let mut transport = transport.into_transport(); let id_provider = >::default(); - // Get initialize request - let (request, id) = expect_request(&mut transport, "initialized request").await?; + // Get initialize request; the MCP spec permits ping before initialize. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization + let (request, id) = loop { + let msg = expect_next_message(&mut transport, "initialize request").await?; + match msg { + ClientJsonRpcMessage::Request(req) + if matches!(req.request, ClientRequest::PingRequest(_)) => + { + transport + .send(ServerJsonRpcMessage::response( + ServerResult::EmptyResult(EmptyResult {}), + req.id, + )) + .await + .map_err(|error| { + ServerInitializeError::transport::( + error, + "sending pre-init ping response", + ) + })?; + } + ClientJsonRpcMessage::Request(req) => break (req.request, req.id), + other => { + return Err(ServerInitializeError::ExpectedInitializeRequest(Some( + other, + ))); + } + } + }; let ClientRequest::InitializeRequest(peer_info) = &request else { return Err(ServerInitializeError::ExpectedInitializeRequest(Some( diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index c07501f0b..88a8e45b2 100644 --- a/crates/rmcp/tests/test_server_initialization.rs +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -96,6 +96,47 @@ async fn server_init_succeeds_after_set_level_before_initialized() { result.unwrap().cancel().await.unwrap(); } +// Server responds with EmptyResult to ping received before initialize request. +#[tokio::test] +async fn server_init_ping_response_is_empty_result_before_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + client.send(ping_request(1)).await.unwrap(); + + let response = client.receive().await.unwrap(); + assert!( + matches!( + response, + ServerJsonRpcMessage::Response(ref r) + if matches!(r.result, ServerResult::EmptyResult(_)) + ), + "expected EmptyResult for pre-initialize ping, got: {response:?}" + ); +} + +// Server initializes successfully when ping is sent before the initialize request. +#[tokio::test] +async fn server_init_succeeds_after_ping_before_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + client.send(ping_request(1)).await.unwrap(); + let _pong = client.receive().await.unwrap(); + do_initialize(&mut client).await; + client.send(initialized_notification()).await.unwrap(); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully after pre-initialize ping" + ); + result.unwrap().cancel().await.unwrap(); +} + // Server responds with EmptyResult to ping received before initialized. #[tokio::test] async fn server_init_ping_response_is_empty_result() { From 27b00967f17224233bc69ecd836b8a01e6f041c4 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:27:00 -0400 Subject: [PATCH 097/333] feat: transparent session re-init on HTTP 404 (#743) --- .../common/reqwest/streamable_http_client.rs | 4 + .../src/transport/streamable_http_client.rs | 237 +++++++++++++++++- .../test_streamable_http_stale_session.rs | 99 +++++++- 3 files changed, 321 insertions(+), 19 deletions(-) diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index a3b85da1b..8fca86fbc 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -144,6 +144,7 @@ impl StreamableHttpClient for reqwest::Client { } request = apply_custom_headers(request, custom_headers)?; + let session_was_attached = session_id.is_some(); if let Some(session_id) = session_id { request = request.header(HEADER_SESSION_ID, session_id.as_ref()); } @@ -186,6 +187,9 @@ impl StreamableHttpClient for reqwest::Client { ) { return Ok(StreamableHttpPostResponse::Accepted); } + if status == reqwest::StatusCode::NOT_FOUND && session_was_attached { + return Err(StreamableHttpError::SessionExpired); + } if !status.is_success() { let body = response .text() diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 85915c976..bbb98bf38 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -11,7 +11,10 @@ use tracing::debug; use super::common::client_side_sse::{ExponentialBackoff, SseRetryPolicy, SseStreamReconnect}; use crate::{ RoleClient, - model::{ClientJsonRpcMessage, ServerJsonRpcMessage, ServerResult}, + model::{ + ClientJsonRpcMessage, ClientNotification, InitializedNotification, ServerJsonRpcMessage, + ServerResult, + }, transport::{ common::client_side_sse::SseAutoReconnectStream, worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport}, @@ -79,6 +82,8 @@ pub enum StreamableHttpError { InsufficientScope(InsufficientScopeError), #[error("Header name '{0}' is reserved and conflicts with default headers")] ReservedHeaderConflict(String), + #[error("Session expired (HTTP 404)")] + SessionExpired, } #[derive(Debug, Clone, Error)] @@ -307,6 +312,69 @@ impl StreamableHttpClientWorker { } Ok(()) } + + /// Performs a transparent re-initialization handshake after a session-expired 404. + /// + /// Takes an owned clone of the client (avoiding `&self` across `.await` so the + /// future remains `Send` without requiring `C: Sync`). POSTs the saved + /// initialize request without a session ID, extracts the new session ID and + /// protocol version, sends `notifications/initialized`, and returns the new + /// `(session_id, protocol_headers)` pair. The init result message is **not** + /// forwarded to the handler because the handler already processed the original + /// initialization. + async fn perform_reinitialization( + client: C, + saved_init_request: ClientJsonRpcMessage, + uri: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> Result<(Option>, HashMap), StreamableHttpError> + { + let (init_msg, new_session_id_str) = client + .post_message( + uri.clone(), + saved_init_request, + None, + auth_header.clone(), + custom_headers.clone(), + ) + .await? + .expect_initialized::() + .await?; + + let new_session_id: Option> = new_session_id_str.map(|s| Arc::from(s.as_str())); + + // Start from custom_headers, then inject the negotiated MCP-Protocol-Version + // so all subsequent requests carry the right version (MCP 2025-06-18 spec). + let mut new_protocol_headers = custom_headers; + if let ServerJsonRpcMessage::Response(response) = &init_msg { + if let ServerResult::InitializeResult(init_result) = &response.result { + if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { + new_protocol_headers + .insert(HeaderName::from_static("mcp-protocol-version"), hv); + } + } + } + + let initialized_notification = ClientJsonRpcMessage::notification( + ClientNotification::InitializedNotification(InitializedNotification { + method: Default::default(), + extensions: Default::default(), + }), + ); + client + .post_message( + uri, + initialized_notification, + new_session_id.clone(), + auth_header, + new_protocol_headers.clone(), + ) + .await? + .expect_accepted_or_json::()?; + + Ok((new_session_id, new_protocol_headers)) + } } impl Worker for StreamableHttpClientWorker { @@ -338,14 +406,15 @@ impl Worker for StreamableHttpClientWorker { responder, message: initialize_request, } = context.recv_from_handler().await?; + let saved_init_request = initialize_request.clone(); let (message, session_id) = match self .client .post_message( config.uri.clone(), initialize_request, None, - self.config.auth_header, - self.config.custom_headers, + config.auth_header.clone(), + config.custom_headers.clone(), ) .await { @@ -364,7 +433,7 @@ impl Worker for StreamableHttpClientWorker { )); } }; - let session_id: Option> = if let Some(session_id) = session_id { + let mut session_id: Option> = if let Some(session_id) = session_id { Some(session_id.into()) } else { if !self.config.allow_stateless { @@ -378,7 +447,7 @@ impl Worker for StreamableHttpClientWorker { // Extract the negotiated protocol version from the init response // and build a custom headers map that includes MCP-Protocol-Version // for all subsequent HTTP requests (per MCP 2025-06-18 spec). - let protocol_headers = { + let mut protocol_headers = { let mut headers = config.custom_headers.clone(); if let ServerJsonRpcMessage::Response(response) = &message { if let ServerResult::InitializeResult(init_result) = &response.result { @@ -392,7 +461,7 @@ impl Worker for StreamableHttpClientWorker { }; // Store session info for cleanup when run() exits (not spawned, so cleanup completes before close() returns) - let session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { + let mut session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { client: self.client.clone(), uri: config.uri.clone(), session_id: sid.clone(), @@ -516,17 +585,171 @@ impl Worker for StreamableHttpClientWorker { match event { Event::ClientMessage(send_request) => { let WorkerSendRequest { message, responder } = send_request; + // Pass a clone to the first attempt so `message` is retained for a + // potential re-init retry. `post_message` takes ownership and the + // trait cannot be changed, so the clone is unavoidable. let response = self .client .post_message( config.uri.clone(), - message, + message.clone(), session_id.clone(), config.auth_header.clone(), protocol_headers.clone(), ) .await; let send_result = match response { + Err(StreamableHttpError::SessionExpired) => { + // The server discarded the session (HTTP 404). Perform a + // fresh handshake once and replay the original message. + tracing::info!( + "session expired (HTTP 404), attempting transparent re-initialization" + ); + match Self::perform_reinitialization( + self.client.clone(), + saved_init_request.clone(), + config.uri.clone(), + config.auth_header.clone(), + config.custom_headers.clone(), + ) + .await + { + Ok((new_session_id, new_protocol_headers)) => { + // Old streams hold the stale session ID; abort them + // so the new standalone SSE stream takes over. + streams.abort_all(); + + session_id = new_session_id; + protocol_headers = new_protocol_headers; + session_cleanup_info = + session_id.as_ref().map(|sid| SessionCleanupInfo { + client: self.client.clone(), + uri: config.uri.clone(), + session_id: sid.clone(), + auth_header: config.auth_header.clone(), + protocol_headers: protocol_headers.clone(), + }); + + if let Some(new_sid) = &session_id { + let client = self.client.clone(); + let uri = config.uri.clone(); + let new_sid = new_sid.clone(); + let auth_header = config.auth_header.clone(); + let retry_config = self.config.retry_config.clone(); + let sse_tx = sse_worker_tx.clone(); + let task_ct = transport_task_ct.clone(); + let config_uri = config.uri.clone(); + let config_auth = config.auth_header.clone(); + let spawn_headers = protocol_headers.clone(); + streams.spawn(async move { + match client + .get_stream( + uri, + new_sid.clone(), + None, + auth_header.clone(), + spawn_headers.clone(), + ) + .await + { + Ok(stream) => { + let sse_stream = SseAutoReconnectStream::new( + stream, + StreamableHttpClientReconnect { + client: client.clone(), + session_id: new_sid, + uri: config_uri, + auth_header: config_auth, + custom_headers: spawn_headers, + }, + retry_config, + ); + Self::execute_sse_stream( + sse_stream, + sse_tx, + false, + task_ct.child_token(), + ) + .await + } + Err(StreamableHttpError::ServerDoesNotSupportSse) => { + tracing::debug!( + "server doesn't support sse after re-init" + ); + Ok(()) + } + Err(e) => { + tracing::error!( + "fail to get common stream after re-init: {e}" + ); + Err(e) + } + } + }); + } + + let retry_response = self + .client + .post_message( + config.uri.clone(), + message, + session_id.clone(), + config.auth_header.clone(), + protocol_headers.clone(), + ) + .await; + match retry_response { + Err(e) => Err(e), + Ok(StreamableHttpPostResponse::Accepted) => { + tracing::trace!( + "client message accepted after re-init" + ); + Ok(()) + } + Ok(StreamableHttpPostResponse::Json(msg, ..)) => { + context.send_to_handler(msg).await?; + Ok(()) + } + Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { + if let Some(sid) = &session_id { + let sse_stream = SseAutoReconnectStream::new( + stream, + StreamableHttpClientReconnect { + client: self.client.clone(), + session_id: sid.clone(), + uri: config.uri.clone(), + auth_header: config.auth_header.clone(), + custom_headers: protocol_headers.clone(), + }, + self.config.retry_config.clone(), + ); + streams.spawn(Self::execute_sse_stream( + sse_stream, + sse_worker_tx.clone(), + true, + transport_task_ct.child_token(), + )); + } else { + let sse_stream = + SseAutoReconnectStream::never_reconnect( + stream, + StreamableHttpError::::UnexpectedEndOfStream, + ); + streams.spawn(Self::execute_sse_stream( + sse_stream, + sse_worker_tx.clone(), + true, + transport_task_ct.child_token(), + )); + } + tracing::trace!("got new sse stream after re-init"); + Ok(()) + } + } + } + Err(reinit_err) => Err(reinit_err), + } + } Err(e) => Err(e), Ok(StreamableHttpPostResponse::Accepted) => { tracing::trace!("client message accepted"); diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index a37a0895f..11f1a4da2 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -7,9 +7,13 @@ use std::{collections::HashMap, sync::Arc}; use rmcp::{ + ServiceExt, model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, transport::{ - streamable_http_client::{StreamableHttpClient, StreamableHttpError}, + StreamableHttpClientTransport, + streamable_http_client::{ + StreamableHttpClient, StreamableHttpClientTransportConfig, StreamableHttpError, + }, streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -76,18 +80,10 @@ async fn test_stale_session_id_returns_status_aware_error() -> anyhow::Result<() assert_eq!(raw_response.status(), reqwest::StatusCode::NOT_FOUND); match result { - Err(StreamableHttpError::UnexpectedServerResponse(message)) => { - let message = message.to_string(); - assert!( - message.contains("404"), - "error should include HTTP status code, got: {message}" - ); - assert!( - message.to_ascii_lowercase().contains("session not found"), - "error should include session-not-found hint, got: {message}" - ); + Err(StreamableHttpError::SessionExpired) => { + // Expected: post_message detects 404 with a session ID and returns SessionExpired } - other => panic!("expected UnexpectedServerResponse, got: {other:?}"), + other => panic!("expected SessionExpired, got: {other:?}"), } ct.cancel(); @@ -95,3 +91,82 @@ async fn test_stale_session_id_returns_status_aware_error() -> anyhow::Result<() Ok(()) } + +/// Verify that when the server loses a session (returns HTTP 404), the client +/// transparently re-initializes and the original request succeeds. +#[tokio::test] +async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let session_manager = Arc::new(LocalSessionManager::default()); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig { + stateful_mode: true, + sse_keep_alive: None, + cancellation_token: ct.child_token(), + ..Default::default() + }, + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let server_handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + // Connect a full client transport (this performs initialize + notifications/initialized) + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), + ); + let client = ().serve(transport).await?; + + // Verify the session is established: list_all_resources() succeeds + let _resources = client.list_all_resources().await?; + + // Capture the current session ID from the server + let original_session_id = { + let sessions = session_manager.sessions.read().await; + sessions + .keys() + .next() + .cloned() + .expect("session should exist") + }; + + // Force session expiry by removing all sessions from the server-side manager + { + let mut sessions = session_manager.sessions.write().await; + sessions.clear(); + } + + // This call should trigger transparent re-initialization and still succeed + let _resources_after = client.list_all_resources().await?; + + // Verify the server created a new session with a different ID + { + let sessions = session_manager.sessions.read().await; + let new_session_id = sessions + .keys() + .next() + .expect("new session should exist after re-initialization"); + assert_ne!( + new_session_id, &original_session_id, + "new session ID should differ from the original" + ); + } + + let _ = client.cancel().await; + ct.cancel(); + server_handle.await?; + + Ok(()) +} From 3bd75220708b2e9f8c74a3fe3277ac5d4f03f478 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:28:11 -0400 Subject: [PATCH 098/333] chore: release v1.2.0 (#736) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a45074598..5154456ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.1.1", path = "./crates/rmcp" } -rmcp-macros = { version = "1.1.1", path = "./crates/rmcp-macros" } +rmcp = { version = "1.2.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.2.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.1.1" +version = "1.2.0" authors = ["4t145 "] license = "Apache-2.0" license-file = "LICENSE" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 979cede8b..e61fcb3d8 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.1.1...rmcp-macros-v1.2.0) - 2026-03-11 + +### Fixed + +- *(rmcp-macros)* use re-exported serde_json path in task_handler ([#735](https://github.com/modelcontextprotocol/rust-sdk/pull/735)) + ## [1.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.1.0...rmcp-macros-v1.1.1) - 2026-03-09 ### Fixed diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 4fba220da..69bd72486 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.1.1...rmcp-v1.2.0) - 2026-03-11 + +### Added + +- add missing constructors for non-exhaustive model types ([#739](https://github.com/modelcontextprotocol/rust-sdk/pull/739)) +- include granted scopes in OAuth refresh token request ([#731](https://github.com/modelcontextprotocol/rust-sdk/pull/731)) + +### Fixed + +- handle ping requests sent before initialize handshake ([#745](https://github.com/modelcontextprotocol/rust-sdk/pull/745)) +- allow deserializing notifications without params field ([#729](https://github.com/modelcontextprotocol/rust-sdk/pull/729)) + +### Other + +- *(deps)* update jsonwebtoken requirement from 9 to 10 ([#737](https://github.com/modelcontextprotocol/rust-sdk/pull/737)) + ## [1.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.1.0...rmcp-v1.1.1) - 2026-03-09 ### Fixed From 8700e5c9207f3daed31df82c1c3a7426353679a9 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:12:00 -0400 Subject: [PATCH 099/333] chore: fix all clippy warnings across workspace (#746) --- Cargo.toml | 1 - conformance/src/bin/client.rs | 184 ++- conformance/src/bin/server.rs | 1331 ++++++++--------- crates/rmcp-macros/Cargo.toml | 1 - crates/rmcp/Cargo.toml | 1 - examples/servers/src/cimd_auth_streamhttp.rs | 10 +- examples/servers/src/common/counter.rs | 6 +- .../servers/src/elicitation_enum_inference.rs | 2 +- 8 files changed, 743 insertions(+), 793 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5154456ad..ee9b43a15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ edition = "2024" version = "1.2.0" authors = ["4t145 "] license = "Apache-2.0" -license-file = "LICENSE" repository = "https://github.com/modelcontextprotocol/rust-sdk/" description = "Rust SDK for Model Context Protocol" keywords = ["mcp", "sdk", "tokio", "modelcontextprotocol"] diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 422435840..b9c8cea9d 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -1,5 +1,3 @@ -use std::future::Future; - use rmcp::{ ClientHandler, ErrorData, RoleClient, ServiceExt, model::*, @@ -55,82 +53,76 @@ impl ClientHandler for ElicitationDefaultsClientHandler { info } - fn create_elicitation( + async fn create_elicitation( &self, request: CreateElicitationRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - let content = match &request { - CreateElicitationRequestParams::FormElicitationParams { - requested_schema, .. - } => { - let mut defaults = serde_json::Map::new(); - for (name, prop) in &requested_schema.properties { - match prop { - PrimitiveSchema::String(s) => { - if let Some(d) = &s.default { - defaults.insert(name.clone(), Value::String(d.clone())); - } + ) -> Result { + let content = match &request { + CreateElicitationRequestParams::FormElicitationParams { + requested_schema, .. + } => { + let mut defaults = serde_json::Map::new(); + for (name, prop) in &requested_schema.properties { + match prop { + PrimitiveSchema::String(s) => { + if let Some(d) = &s.default { + defaults.insert(name.clone(), Value::String(d.clone())); } - PrimitiveSchema::Number(n) => { - if let Some(d) = n.default { - defaults.insert(name.clone(), json!(d)); - } + } + PrimitiveSchema::Number(n) => { + if let Some(d) = n.default { + defaults.insert(name.clone(), json!(d)); } - PrimitiveSchema::Integer(i) => { - if let Some(d) = i.default { - defaults.insert(name.clone(), json!(d)); - } + } + PrimitiveSchema::Integer(i) => { + if let Some(d) = i.default { + defaults.insert(name.clone(), json!(d)); } - PrimitiveSchema::Boolean(b) => { - if let Some(d) = b.default { - defaults.insert(name.clone(), Value::Bool(d)); - } + } + PrimitiveSchema::Boolean(b) => { + if let Some(d) = b.default { + defaults.insert(name.clone(), Value::Bool(d)); } - PrimitiveSchema::Enum(e) => { - let val = match e { - EnumSchema::Single(SingleSelectEnumSchema::Untitled(u)) => { - u.default.as_ref().map(|d| Value::String(d.clone())) - } - EnumSchema::Single(SingleSelectEnumSchema::Titled(t)) => { - t.default.as_ref().map(|d| Value::String(d.clone())) - } - EnumSchema::Multi(MultiSelectEnumSchema::Untitled(u)) => { - u.default.as_ref().map(|d| { - Value::Array( - d.iter() - .map(|s| Value::String(s.clone())) - .collect(), - ) - }) - } - EnumSchema::Multi(MultiSelectEnumSchema::Titled(t)) => { - t.default.as_ref().map(|d| { - Value::Array( - d.iter() - .map(|s| Value::String(s.clone())) - .collect(), - ) - }) - } - EnumSchema::Legacy(_) => None, - }; - if let Some(v) = val { - defaults.insert(name.clone(), v); + } + PrimitiveSchema::Enum(e) => { + let val = match e { + EnumSchema::Single(SingleSelectEnumSchema::Untitled(u)) => { + u.default.as_ref().map(|d| Value::String(d.clone())) + } + EnumSchema::Single(SingleSelectEnumSchema::Titled(t)) => { + t.default.as_ref().map(|d| Value::String(d.clone())) + } + EnumSchema::Multi(MultiSelectEnumSchema::Untitled(u)) => { + u.default.as_ref().map(|d| { + Value::Array( + d.iter().map(|s| Value::String(s.clone())).collect(), + ) + }) + } + EnumSchema::Multi(MultiSelectEnumSchema::Titled(t)) => { + t.default.as_ref().map(|d| { + Value::Array( + d.iter().map(|s| Value::String(s.clone())).collect(), + ) + }) } + EnumSchema::Legacy(_) => None, + }; + if let Some(v) = val { + defaults.insert(name.clone(), v); } } } - Some(Value::Object(defaults)) } - _ => Some(json!({})), - }; - Ok(CreateElicitationResult { - action: ElicitationAction::Accept, - content, - }) - } + Some(Value::Object(defaults)) + } + _ => Some(json!({})), + }; + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content, + }) } } @@ -149,44 +141,40 @@ impl ClientHandler for FullClientHandler { info } - fn create_message( + async fn create_message( &self, params: CreateMessageRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - let prompt_text = params - .messages - .first() - .and_then(|m| m.content.first()) - .and_then(|c| c.as_text()) - .map(|t| t.text.clone()) - .unwrap_or_default(); - Ok(CreateMessageResult::new( - SamplingMessage::new( - Role::Assistant, - SamplingMessageContent::text(format!( - "This is a mock LLM response to: {}", - prompt_text - )), - ), - "mock-model".into(), - ) - .with_stop_reason("endTurn")) - } + ) -> Result { + let prompt_text = params + .messages + .first() + .and_then(|m| m.content.first()) + .and_then(|c| c.as_text()) + .map(|t| t.text.clone()) + .unwrap_or_default(); + Ok(CreateMessageResult::new( + SamplingMessage::new( + Role::Assistant, + SamplingMessageContent::text(format!( + "This is a mock LLM response to: {}", + prompt_text + )), + ), + "mock-model".into(), + ) + .with_stop_reason("endTurn")) } - fn create_elicitation( + async fn create_elicitation( &self, _request: CreateElicitationRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - Ok(CreateElicitationResult { - action: ElicitationAction::Accept, - content: Some(json!({"username": "testuser", "email": "test@example.com"})), - }) - } + ) -> Result { + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content: Some(json!({"username": "testuser", "email": "test@example.com"})), + }) } } @@ -761,9 +749,7 @@ fn build_tool_arguments(tool: &Tool) -> Option> { }) .unwrap_or_default(); - let Some(properties) = properties else { - return None; - }; + let properties = properties?; if properties.is_empty() && required.is_empty() { return None; } diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 4cfde48c6..bfa98f42c 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, future::Future, sync::Arc}; +use std::{collections::HashSet, sync::Arc}; use rmcp::{ ErrorData, RoleServer, ServerHandler, @@ -42,793 +42,764 @@ impl ConformanceServer { } impl ServerHandler for ConformanceServer { - fn initialize( + async fn initialize( &self, _request: InitializeRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async { - Ok(InitializeResult::new( - ServerCapabilities::builder() - .enable_prompts() - .enable_resources() - .enable_tools() - .enable_logging() - .build(), - ) - .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) - .with_instructions("Rust MCP conformance test server")) - } + ) -> Result { + Ok(InitializeResult::new( + ServerCapabilities::builder() + .enable_prompts() + .enable_resources() + .enable_tools() + .enable_logging() + .build(), + ) + .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) + .with_instructions("Rust MCP conformance test server")) } - fn ping( - &self, - _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async { Ok(()) } + async fn ping(&self, _cx: RequestContext) -> Result<(), ErrorData> { + Ok(()) } - fn list_tools( + async fn list_tools( &self, _request: Option, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async { - let tools = vec![ - Tool::new( - "test_simple_text", - "Returns simple text content", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_image_content", - "Returns image content", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_audio_content", - "Returns audio content", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_embedded_resource", - "Returns embedded resource content", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_multiple_content_types", - "Returns multiple content types", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_tool_with_logging", - "Sends logging notifications during execution", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_error_handling", - "Always returns an error", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_tool_with_progress", - "Reports progress notifications", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_sampling", - "Requests LLM sampling from client", - json_object(json!({ - "type": "object", - "properties": { - "prompt": { "type": "string", "description": "The prompt to send" } - }, - "required": ["prompt"] - })), - ), - Tool::new( - "test_elicitation", - "Requests user input from client", - json_object(json!({ - "type": "object", - "properties": { - "message": { "type": "string", "description": "The message to show" } - }, - "required": ["message"] - })), - ), - Tool::new( - "test_elicitation_sep1034_defaults", - "Tests elicitation with default values (SEP-1034)", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "test_elicitation_sep1330_enums", - "Tests enum schema improvements (SEP-1330)", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - Tool::new( - "json_schema_2020_12_tool", - "Tool with JSON Schema 2020-12 features", - json_object(json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "$defs": { - "address": { - "type": "object", - "properties": { - "street": { "type": "string" }, - "city": { "type": "string" } - } + ) -> Result { + let tools = vec![ + Tool::new( + "test_simple_text", + "Returns simple text content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_image_content", + "Returns image content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_audio_content", + "Returns audio content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_embedded_resource", + "Returns embedded resource content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_multiple_content_types", + "Returns multiple content types", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_tool_with_logging", + "Sends logging notifications during execution", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_error_handling", + "Always returns an error", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_tool_with_progress", + "Reports progress notifications", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_sampling", + "Requests LLM sampling from client", + json_object(json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "The prompt to send" } + }, + "required": ["prompt"] + })), + ), + Tool::new( + "test_elicitation", + "Requests user input from client", + json_object(json!({ + "type": "object", + "properties": { + "message": { "type": "string", "description": "The message to show" } + }, + "required": ["message"] + })), + ), + Tool::new( + "test_elicitation_sep1034_defaults", + "Tests elicitation with default values (SEP-1034)", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_elicitation_sep1330_enums", + "Tests enum schema improvements (SEP-1330)", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "json_schema_2020_12_tool", + "Tool with JSON Schema 2020-12 features", + json_object(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } } - }, - "properties": { - "name": { "type": "string" }, - "address": { "$ref": "#/$defs/address" } - }, - "additionalProperties": false - })), - ), - Tool::new( - "test_reconnection", - "Tests SSE reconnection behavior", - json_object(json!({ - "type": "object", - "properties": {} - })), - ), - ]; - Ok(ListToolsResult { - meta: None, - tools, - next_cursor: None, - }) - } + } + }, + "properties": { + "name": { "type": "string" }, + "address": { "$ref": "#/$defs/address" } + }, + "additionalProperties": false + })), + ), + Tool::new( + "test_reconnection", + "Tests SSE reconnection behavior", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + ]; + Ok(ListToolsResult { + meta: None, + tools, + next_cursor: None, + }) } - fn call_tool( + async fn call_tool( &self, request: CallToolRequestParams, cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - let args = request.arguments.unwrap_or_default(); - match request.name.as_ref() { - "test_simple_text" => Ok(CallToolResult::success(vec![Content::text( - "This is a simple text response for testing.", - )])), - - "test_image_content" => Ok(CallToolResult::success(vec![Content::image( - TEST_IMAGE_DATA, - "image/png", - )])), - - "test_audio_content" => { - // No Content::audio() helper, construct manually - let audio = RawContent::Audio(RawAudioContent { - data: TEST_AUDIO_DATA.into(), - mime_type: "audio/wav".into(), - }) - .no_annotation(); - Ok(CallToolResult::success(vec![audio])) + ) -> Result { + let args = request.arguments.unwrap_or_default(); + match request.name.as_ref() { + "test_simple_text" => Ok(CallToolResult::success(vec![Content::text( + "This is a simple text response for testing.", + )])), + + "test_image_content" => Ok(CallToolResult::success(vec![Content::image( + TEST_IMAGE_DATA, + "image/png", + )])), + + "test_audio_content" => { + let audio = RawContent::Audio(RawAudioContent { + data: TEST_AUDIO_DATA.into(), + mime_type: "audio/wav".into(), + }) + .no_annotation(); + Ok(CallToolResult::success(vec![audio])) + } + + "test_embedded_resource" => Ok(CallToolResult::success(vec![Content::resource( + ResourceContents::TextResourceContents { + uri: "test://embedded-resource".into(), + mime_type: Some("text/plain".into()), + text: "This is an embedded resource content.".into(), + meta: None, + }, + )])), + + "test_multiple_content_types" => Ok(CallToolResult::success(vec![ + Content::text("Multiple content types test:"), + Content::image(TEST_IMAGE_DATA, "image/png"), + Content::resource(ResourceContents::TextResourceContents { + uri: "test://mixed-content-resource".into(), + mime_type: Some("application/json".into()), + text: r#"{"test":"data","value":123}"#.into(), + meta: None, + }), + ])), + + "test_tool_with_logging" => { + for msg in [ + "Tool execution started", + "Tool processing data", + "Tool execution completed", + ] { + let _ = cx + .peer + .notify_logging_message(LoggingMessageNotificationParam { + level: LoggingLevel::Info, + logger: Some("conformance-server".into()), + data: json!(msg), + }) + .await; + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - "test_embedded_resource" => Ok(CallToolResult::success(vec![Content::resource( - ResourceContents::TextResourceContents { - uri: "test://embedded-resource".into(), - mime_type: Some("text/plain".into()), - text: "This is an embedded resource content.".into(), - meta: None, - }, - )])), - - "test_multiple_content_types" => Ok(CallToolResult::success(vec![ - Content::text("Multiple content types test:"), - Content::image(TEST_IMAGE_DATA, "image/png"), - Content::resource(ResourceContents::TextResourceContents { - uri: "test://mixed-content-resource".into(), - mime_type: Some("application/json".into()), - text: r#"{"test":"data","value":123}"#.into(), - meta: None, - }), - ])), - - "test_tool_with_logging" => { - for msg in [ - "Tool execution started", - "Tool processing data", - "Tool execution completed", - ] { + Ok(CallToolResult::success(vec![Content::text( + "Logging test completed", + )])) + } + + "test_error_handling" => Ok(CallToolResult::error(vec![Content::text( + "This tool intentionally returns an error for testing", + )])), + + "test_tool_with_progress" => { + let progress_token = cx.meta.get_progress_token(); + + for (progress, message) in + [(0.0, "Starting"), (50.0, "Halfway"), (100.0, "Complete")] + { + if let Some(token) = &progress_token { let _ = cx .peer - .notify_logging_message(LoggingMessageNotificationParam { - level: LoggingLevel::Info, - logger: Some("conformance-server".into()), - data: json!(msg), + .notify_progress(ProgressNotificationParam { + progress_token: token.clone(), + progress, + total: Some(100.0), + message: Some(message.into()), }) .await; - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - - Ok(CallToolResult::success(vec![Content::text( - "Logging test completed", - )])) + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - "test_error_handling" => Ok(CallToolResult::error(vec![Content::text( - "This tool intentionally returns an error for testing", - )])), - - "test_tool_with_progress" => { - let progress_token = cx.meta.get_progress_token(); - - for (progress, message) in - [(0.0, "Starting"), (50.0, "Halfway"), (100.0, "Complete")] - { - if let Some(token) = &progress_token { - let _ = cx - .peer - .notify_progress(ProgressNotificationParam { - progress_token: token.clone(), - progress, - total: Some(100.0), - message: Some(message.into()), - }) - .await; - } - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - } - - Ok(CallToolResult::success(vec![Content::text( - "Progress test completed", - )])) - } - - "test_sampling" => { - let prompt = args - .get("prompt") - .and_then(|v| v.as_str()) - .unwrap_or("Hello"); + Ok(CallToolResult::success(vec![Content::text( + "Progress test completed", + )])) + } - match cx - .peer - .create_message(CreateMessageRequestParams::new( - vec![SamplingMessage::user_text(prompt)], - 100, - )) - .await - { - Ok(result) => { - let text = result - .message - .content - .first() - .and_then(|c| c.as_text()) - .map(|t| t.text.clone()) - .unwrap_or_else(|| "No text response".into()); - Ok(CallToolResult::success(vec![Content::text(format!( - "LLM response: {}", - text - ))])) - } - Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( - "Sampling error: {}", - e - ))])), + "test_sampling" => { + let prompt = args + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or("Hello"); + + match cx + .peer + .create_message(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text(prompt)], + 100, + )) + .await + { + Ok(result) => { + let text = result + .message + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.clone()) + .unwrap_or_else(|| "No text response".into()); + Ok(CallToolResult::success(vec![Content::text(format!( + "LLM response: {}", + text + ))])) } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Sampling error: {}", + e + ))])), } + } - "test_elicitation" => { - let message = args - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("Please provide your information"); - - let schema_json = json!({ - "type": "object", - "properties": { - "username": { - "type": "string", - "description": "User's response" - }, - "email": { - "type": "string", - "description": "User's email address" - } + "test_elicitation" => { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Please provide your information"); + + let schema_json = json!({ + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "User's response" }, - "required": ["username", "email"] - }); + "email": { + "type": "string", + "description": "User's email address" + } + }, + "required": ["username", "email"] + }); - let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); - match cx - .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: message.into(), - requested_schema: schema, - }) - .await - { - Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( - "User response: action={}, content={:?}", - match result.action { - ElicitationAction::Accept => "accept", - ElicitationAction::Decline => "decline", - ElicitationAction::Cancel => "cancel", - }, - result.content - ))])), - Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( - "Elicitation error: {}", - e - ))])), - } + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: message.into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + "User response: action={}, content={:?}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }, + result.content + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Elicitation error: {}", + e + ))])), } + } - "test_elicitation_sep1034_defaults" => { - let schema_json = json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "User's name", - "default": "John Doe" - }, - "age": { - "type": "integer", - "description": "User's age", - "default": 30 - }, - "score": { - "type": "number", - "description": "User's score", - "default": 95.5 - }, - "status": { - "type": "string", - "description": "User's status", - "enum": ["active", "inactive", "pending"], - "default": "active" - }, - "verified": { - "type": "boolean", - "description": "Whether user is verified", - "default": true - } + "test_elicitation_sep1034_defaults" => { + let schema_json = json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "User's name", + "default": "John Doe" + }, + "age": { + "type": "integer", + "description": "User's age", + "default": 30 + }, + "score": { + "type": "number", + "description": "User's score", + "default": 95.5 + }, + "status": { + "type": "string", + "description": "User's status", + "enum": ["active", "inactive", "pending"], + "default": "active" + }, + "verified": { + "type": "boolean", + "description": "Whether user is verified", + "default": true } - }); + } + }); - let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); - match cx - .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "Please provide values (all have defaults)".into(), - requested_schema: schema, - }) - .await - { - Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( - "Elicitation completed: action={}, content={:?}", - match result.action { - ElicitationAction::Accept => "accept", - ElicitationAction::Decline => "decline", - ElicitationAction::Cancel => "cancel", - }, - result.content - ))])), - Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( - "Elicitation error: {}", - e - ))])), - } + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Please provide values (all have defaults)".into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + "Elicitation completed: action={}, content={:?}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }, + result.content + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Elicitation error: {}", + e + ))])), } + } - "test_elicitation_sep1330_enums" => { - let schema_json = json!({ - "type": "object", - "properties": { - "untitledSingle": { + "test_elicitation_sep1330_enums" => { + let schema_json = json!({ + "type": "object", + "properties": { + "untitledSingle": { + "type": "string", + "enum": ["option1", "option2", "option3"] + }, + "titledSingle": { + "type": "string", + "oneOf": [ + { "const": "value1", "title": "First Option" }, + { "const": "value2", "title": "Second Option" }, + { "const": "value3", "title": "Third Option" } + ] + }, + "legacyEnum": { + "type": "string", + "enum": ["opt1", "opt2", "opt3"], + "enumNames": ["Option One", "Option Two", "Option Three"] + }, + "untitledMulti": { + "type": "array", + "items": { "type": "string", "enum": ["option1", "option2", "option3"] - }, - "titledSingle": { - "type": "string", - "oneOf": [ - { "const": "value1", "title": "First Option" }, - { "const": "value2", "title": "Second Option" }, - { "const": "value3", "title": "Third Option" } + } + }, + "titledMulti": { + "type": "array", + "items": { + "anyOf": [ + { "const": "value1", "title": "First Choice" }, + { "const": "value2", "title": "Second Choice" }, + { "const": "value3", "title": "Third Choice" } ] - }, - "legacyEnum": { - "type": "string", - "enum": ["opt1", "opt2", "opt3"], - "enumNames": ["Option One", "Option Two", "Option Three"] - }, - "untitledMulti": { - "type": "array", - "items": { - "type": "string", - "enum": ["option1", "option2", "option3"] - } - }, - "titledMulti": { - "type": "array", - "items": { - "anyOf": [ - { "const": "value1", "title": "First Choice" }, - { "const": "value2", "title": "Second Choice" }, - { "const": "value3", "title": "Third Choice" } - ] - } } } - }); - - let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); - - match cx - .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "Test enum schema improvements".into(), - requested_schema: schema, - }) - .await - { - Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( - "Enum elicitation completed: action={}", - match result.action { - ElicitationAction::Accept => "accept", - ElicitationAction::Decline => "decline", - ElicitationAction::Cancel => "cancel", - } - ))])), - Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( - "Elicitation error: {}", - e - ))])), } - } + }); - "json_schema_2020_12_tool" => { - let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world"); - Ok(CallToolResult::success(vec![Content::text(format!( - "Hello, {}!", - name - ))])) - } + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); - "test_reconnection" => { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - Ok(CallToolResult::success(vec![Content::text( - "Reconnection test completed", - )])) + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Test enum schema improvements".into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + "Enum elicitation completed: action={}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + } + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Elicitation error: {}", + e + ))])), } + } - _ => Err(ErrorData::invalid_params( - format!("Unknown tool: {}", request.name), - None, - )), + "json_schema_2020_12_tool" => { + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world"); + Ok(CallToolResult::success(vec![Content::text(format!( + "Hello, {}!", + name + ))])) } + + "test_reconnection" => { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + Ok(CallToolResult::success(vec![Content::text( + "Reconnection test completed", + )])) + } + + _ => Err(ErrorData::invalid_params( + format!("Unknown tool: {}", request.name), + None, + )), } } - fn list_resources( + async fn list_resources( &self, _request: Option, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async { - Ok(ListResourcesResult { - meta: None, - resources: vec![ - RawResource { - uri: "test://static-text".into(), - name: "Static Text Resource".into(), - title: None, - description: Some("A static text resource for testing".into()), - mime_type: Some("text/plain".into()), - size: None, - icons: None, - meta: None, - } - .no_annotation(), - RawResource { - uri: "test://static-binary".into(), - name: "Static Binary Resource".into(), - title: None, - description: Some("A static binary/blob resource for testing".into()), - mime_type: Some("image/png".into()), - size: None, - icons: None, - meta: None, - } - .no_annotation(), - ], - next_cursor: None, - }) - } + ) -> Result { + Ok(ListResourcesResult { + meta: None, + resources: vec![ + RawResource { + uri: "test://static-text".into(), + name: "Static Text Resource".into(), + title: None, + description: Some("A static text resource for testing".into()), + mime_type: Some("text/plain".into()), + size: None, + icons: None, + meta: None, + } + .no_annotation(), + RawResource { + uri: "test://static-binary".into(), + name: "Static Binary Resource".into(), + title: None, + description: Some("A static binary/blob resource for testing".into()), + mime_type: Some("image/png".into()), + size: None, + icons: None, + meta: None, + } + .no_annotation(), + ], + next_cursor: None, + }) } - fn read_resource( + async fn read_resource( &self, request: ReadResourceRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - let uri = request.uri.as_str(); - match uri { - "test://static-text" => Ok(ReadResourceResult::new(vec![ - ResourceContents::TextResourceContents { - uri: uri.into(), - mime_type: Some("text/plain".into()), - text: "This is the content of the static text resource.".into(), - meta: None, - }, - ])), - "test://static-binary" => Ok(ReadResourceResult::new(vec![ - ResourceContents::BlobResourceContents { - uri: uri.into(), - mime_type: Some("image/png".into()), - blob: TEST_IMAGE_DATA.into(), - meta: None, - }, - ])), - _ => { - // Check if it matches template: test://template/{id}/data - if uri.starts_with("test://template/") && uri.ends_with("/data") { - let id = uri - .strip_prefix("test://template/") - .and_then(|s| s.strip_suffix("/data")) - .unwrap_or("unknown"); - Ok(ReadResourceResult::new(vec![ - ResourceContents::TextResourceContents { - uri: uri.into(), - mime_type: Some("application/json".into()), - text: format!( - r#"{{"id":"{}","templateTest":true,"data":"Data for ID: {}"}}"#, - id, id - ), - meta: None, - }, - ])) - } else { - Err(ErrorData::resource_not_found( - format!("Resource not found: {}", uri), - None, - )) - } + ) -> Result { + let uri = request.uri.as_str(); + match uri { + "test://static-text" => Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("text/plain".into()), + text: "This is the content of the static text resource.".into(), + meta: None, + }, + ])), + "test://static-binary" => Ok(ReadResourceResult::new(vec![ + ResourceContents::BlobResourceContents { + uri: uri.into(), + mime_type: Some("image/png".into()), + blob: TEST_IMAGE_DATA.into(), + meta: None, + }, + ])), + _ => { + if uri.starts_with("test://template/") && uri.ends_with("/data") { + let id = uri + .strip_prefix("test://template/") + .and_then(|s| s.strip_suffix("/data")) + .unwrap_or("unknown"); + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("application/json".into()), + text: format!( + r#"{{"id":"{}","templateTest":true,"data":"Data for ID: {}"}}"#, + id, id + ), + meta: None, + }, + ])) + } else { + Err(ErrorData::resource_not_found( + format!("Resource not found: {}", uri), + None, + )) } } } } - fn list_resource_templates( + async fn list_resource_templates( &self, _request: Option, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async { - Ok(ListResourceTemplatesResult { - meta: None, - resource_templates: vec![ - RawResourceTemplate { - uri_template: "test://template/{id}/data".into(), - name: "Dynamic Resource".into(), - title: None, - description: Some("A dynamic resource with parameter substitution".into()), - mime_type: Some("application/json".into()), - icons: None, - } - .no_annotation(), - ], - next_cursor: None, - }) - } + ) -> Result { + Ok(ListResourceTemplatesResult { + meta: None, + resource_templates: vec![ + RawResourceTemplate { + uri_template: "test://template/{id}/data".into(), + name: "Dynamic Resource".into(), + title: None, + description: Some("A dynamic resource with parameter substitution".into()), + mime_type: Some("application/json".into()), + icons: None, + } + .no_annotation(), + ], + next_cursor: None, + }) } - fn subscribe( + async fn subscribe( &self, request: SubscribeRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - let mut subs = self.subscriptions.lock().await; - subs.insert(request.uri.to_string()); - Ok(()) - } + ) -> Result<(), ErrorData> { + let mut subs = self.subscriptions.lock().await; + subs.insert(request.uri.to_string()); + Ok(()) } - fn unsubscribe( + async fn unsubscribe( &self, request: UnsubscribeRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - let mut subs = self.subscriptions.lock().await; - subs.remove(request.uri.as_str()); - Ok(()) - } + ) -> Result<(), ErrorData> { + let mut subs = self.subscriptions.lock().await; + subs.remove(request.uri.as_str()); + Ok(()) } - fn list_prompts( + async fn list_prompts( &self, _request: Option, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async { - Ok(ListPromptsResult { - meta: None, - prompts: vec![ - Prompt::new( - "test_simple_prompt", - Some("A simple test prompt with no arguments"), - None, - ), - Prompt::new( - "test_prompt_with_arguments", - Some("A test prompt that accepts arguments"), - Some(vec![ - PromptArgument::new("name") - .with_description("The name to greet") - .with_required(true), - PromptArgument::new("style") - .with_description("The greeting style") - .with_required(false), - ]), - ), - Prompt::new( - "test_prompt_with_embedded_resource", - Some("A test prompt that includes an embedded resource"), - None, - ), - Prompt::new( - "test_prompt_with_image", - Some("A test prompt that includes an image"), - None, - ), - ], - next_cursor: None, - }) - } + ) -> Result { + Ok(ListPromptsResult { + meta: None, + prompts: vec![ + Prompt::new( + "test_simple_prompt", + Some("A simple test prompt with no arguments"), + None, + ), + Prompt::new( + "test_prompt_with_arguments", + Some("A test prompt that accepts arguments"), + Some(vec![ + PromptArgument::new("name") + .with_description("The name to greet") + .with_required(true), + PromptArgument::new("style") + .with_description("The greeting style") + .with_required(false), + ]), + ), + Prompt::new( + "test_prompt_with_embedded_resource", + Some("A test prompt that includes an embedded resource"), + None, + ), + Prompt::new( + "test_prompt_with_image", + Some("A test prompt that includes an image"), + None, + ), + ], + next_cursor: None, + }) } - fn get_prompt( + async fn get_prompt( &self, request: GetPromptRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - match request.name.as_str() { - "test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text( + ) -> Result { + match request.name.as_str() { + "test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text( + PromptMessageRole::User, + "This is a simple test prompt.", + )]) + .with_description("A simple test prompt")), + "test_prompt_with_arguments" => { + let args = request.arguments.unwrap_or_default(); + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("World"); + let style = args + .get("style") + .and_then(|v| v.as_str()) + .unwrap_or("friendly"); + Ok(GetPromptResult::new(vec![PromptMessage::new_text( PromptMessageRole::User, - "This is a simple test prompt.", + format!("Please greet {} in a {} style.", name, style), )]) - .with_description("A simple test prompt")), - "test_prompt_with_arguments" => { - let args = request.arguments.unwrap_or_default(); - let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("World"); - let style = args - .get("style") - .and_then(|v| v.as_str()) - .unwrap_or("friendly"); - Ok(GetPromptResult::new(vec![PromptMessage::new_text( - PromptMessageRole::User, - format!("Please greet {} in a {} style.", name, style), - )]) - .with_description("A prompt with arguments")) - } - "test_prompt_with_embedded_resource" => Ok(GetPromptResult::new(vec![ - PromptMessage::new_text(PromptMessageRole::User, "Here is a resource:"), - PromptMessage::new_resource( + .with_description("A prompt with arguments")) + } + "test_prompt_with_embedded_resource" => Ok(GetPromptResult::new(vec![ + PromptMessage::new_text(PromptMessageRole::User, "Here is a resource:"), + PromptMessage::new_resource( + PromptMessageRole::User, + "test://static-text".into(), + Some("text/plain".into()), + Some("Resource content for prompt".into()), + None, + None, + None, + ), + ]) + .with_description("A prompt with an embedded resource")), + "test_prompt_with_image" => { + let image_content = RawImageContent { + data: TEST_IMAGE_DATA.into(), + mime_type: "image/png".into(), + meta: None, + }; + Ok(GetPromptResult::new(vec![ + PromptMessage::new_text(PromptMessageRole::User, "Here is an image:"), + PromptMessage::new( PromptMessageRole::User, - "test://static-text".into(), - Some("text/plain".into()), - Some("Resource content for prompt".into()), - None, - None, - None, + PromptMessageContent::Image { + image: image_content.no_annotation(), + }, ), ]) - .with_description("A prompt with an embedded resource")), - "test_prompt_with_image" => { - let image_content = RawImageContent { - data: TEST_IMAGE_DATA.into(), - mime_type: "image/png".into(), - meta: None, - }; - Ok(GetPromptResult::new(vec![ - PromptMessage::new_text(PromptMessageRole::User, "Here is an image:"), - PromptMessage::new( - PromptMessageRole::User, - PromptMessageContent::Image { - image: image_content.no_annotation(), - }, - ), - ]) - .with_description("A prompt with an image")) - } - _ => Err(ErrorData::invalid_params( - format!("Unknown prompt: {}", request.name), - None, - )), + .with_description("A prompt with an image")) } + _ => Err(ErrorData::invalid_params( + format!("Unknown prompt: {}", request.name), + None, + )), } } - fn complete( + async fn complete( &self, request: CompleteRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - let values = match &request.r#ref { - Reference::Resource(_) => { - if request.argument.name == "id" { - vec!["1".into(), "2".into(), "3".into()] - } else { - vec![] - } + ) -> Result { + let values = match &request.r#ref { + Reference::Resource(_) => { + if request.argument.name == "id" { + vec!["1".into(), "2".into(), "3".into()] + } else { + vec![] } - Reference::Prompt(prompt_ref) => { - if request.argument.name == "name" { - vec!["Alice".into(), "Bob".into(), "Charlie".into()] - } else if request.argument.name == "style" { - vec!["friendly".into(), "formal".into(), "casual".into()] - } else { - vec![prompt_ref.name.clone()] - } + } + Reference::Prompt(prompt_ref) => { + if request.argument.name == "name" { + vec!["Alice".into(), "Bob".into(), "Charlie".into()] + } else if request.argument.name == "style" { + vec!["friendly".into(), "formal".into(), "casual".into()] + } else { + vec![prompt_ref.name.clone()] } - }; - Ok(CompleteResult::new( - CompletionInfo::new(values).map_err(|e| ErrorData::internal_error(e, None))?, - )) - } + } + }; + Ok(CompleteResult::new( + CompletionInfo::new(values).map_err(|e| ErrorData::internal_error(e, None))?, + )) } - fn set_level( + async fn set_level( &self, request: SetLevelRequestParams, _cx: RequestContext, - ) -> impl Future> + Send + '_ { - async move { - let mut level = self.log_level.lock().await; - *level = request.level; - Ok(()) - } + ) -> Result<(), ErrorData> { + let mut level = self.log_level.lock().await; + *level = request.level; + Ok(()) } } diff --git a/crates/rmcp-macros/Cargo.toml b/crates/rmcp-macros/Cargo.toml index b59929265..8413e5d93 100644 --- a/crates/rmcp-macros/Cargo.toml +++ b/crates/rmcp-macros/Cargo.toml @@ -3,7 +3,6 @@ [package] name = "rmcp-macros" license = { workspace = true } -license-file = { workspace = true } version = { workspace = true } edition = { workspace = true } repository = { workspace = true } diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 31e05acab..6ac4b02c0 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "rmcp" license = { workspace = true } -license-file = { workspace = true } version = { workspace = true } edition = { workspace = true } repository = { workspace = true } diff --git a/examples/servers/src/cimd_auth_streamhttp.rs b/examples/servers/src/cimd_auth_streamhttp.rs index 7b402c9fb..6a634d883 100644 --- a/examples/servers/src/cimd_auth_streamhttp.rs +++ b/examples/servers/src/cimd_auth_streamhttp.rs @@ -18,7 +18,7 @@ use rmcp::transport::{ StreamableHttpServerConfig, streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::Value; use tokio::sync::RwLock; use tower_http::cors::{Any, CorsLayer}; @@ -35,8 +35,8 @@ const BIND_ADDRESS: &str = "127.0.0.1:3000"; /// In-memory authorization code record #[derive(Clone, Debug)] struct AuthCodeRecord { - client_id: String, - redirect_uri: String, + _client_id: String, + _redirect_uri: String, expires_at: SystemTime, } @@ -368,8 +368,8 @@ async fn handle_authorize( codes.insert( code.clone(), AuthCodeRecord { - client_id: client_id_url, - redirect_uri: redirect_uri.to_string(), + _client_id: client_id_url, + _redirect_uri: redirect_uri.to_string(), expires_at, }, ); diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index 1806a9fa3..91b4a7bc1 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -1,7 +1,6 @@ #![allow(dead_code)] use std::{any::Any, sync::Arc}; -use chrono::Utc; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, handler::server::{ @@ -12,14 +11,11 @@ use rmcp::{ prompt, prompt_handler, prompt_router, schemars, service::RequestContext, task_handler, - task_manager::{ - OperationDescriptor, OperationMessage, OperationProcessor, OperationResultTransport, - }, + task_manager::{OperationProcessor, OperationResultTransport}, tool, tool_handler, tool_router, }; use serde_json::json; use tokio::sync::Mutex; -use tracing::info; struct ToolCallOperationResult { id: String, diff --git a/examples/servers/src/elicitation_enum_inference.rs b/examples/servers/src/elicitation_enum_inference.rs index 27bde508e..328fb8c88 100644 --- a/examples/servers/src/elicitation_enum_inference.rs +++ b/examples/servers/src/elicitation_enum_inference.rs @@ -5,7 +5,7 @@ //! - Use `#[schemars(inline)]` to ensure the enum is inlined in the schema. //! - Use `#[schemars(extend("type" = "string"))]` to manually add the required type field, since `schemars` does not provide it for enums. //! - Optionally, use `#[schemars(title = "...")]` to provide titles for enum variants. -//! For more details, see: https://docs.rs/schemars/latest/schemars/ +//! For more details, see: https://docs.rs/schemars/latest/schemars/ use std::{ fmt::{Display, Formatter}, sync::Arc, From 1a4a52a1732d78d8e63a55652ecab92de442e197 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:22:56 -0400 Subject: [PATCH 100/333] feat: add local feature for !Send tool handler support (#740) * feat: add local feature for !Send tool handler support * fix: gate streamable HTTP transport on not(local) feature --- crates/rmcp-macros/Cargo.toml | 2 + crates/rmcp-macros/src/tool.rs | 17 ++- crates/rmcp/Cargo.toml | 1 + crates/rmcp/README.md | 2 +- crates/rmcp/src/handler/client.rs | 59 +++++----- crates/rmcp/src/handler/server.rs | 108 ++++++++++-------- crates/rmcp/src/handler/server/prompt.rs | 74 ++++++------ .../rmcp/src/handler/server/router/prompt.rs | 26 ++--- crates/rmcp/src/handler/server/router/tool.rs | 39 +++---- .../handler/server/router/tool/tool_traits.rs | 25 ++-- crates/rmcp/src/handler/server/tool.rs | 71 +++++++----- crates/rmcp/src/service.rs | 103 ++++++++++++++--- crates/rmcp/src/service/client.rs | 3 +- crates/rmcp/src/service/server.rs | 3 +- crates/rmcp/src/service/tower.rs | 4 +- crates/rmcp/src/transport.rs | 4 +- .../src/transport/streamable_http_server.rs | 4 +- .../streamable_http_server/session.rs | 2 +- .../transport/streamable_http_server/tower.rs | 2 +- crates/rmcp/tests/common/handlers.rs | 10 +- .../rmcp/tests/test_client_initialization.rs | 2 +- crates/rmcp/tests/test_close_connection.rs | 1 + crates/rmcp/tests/test_custom_headers.rs | 1 + crates/rmcp/tests/test_custom_request.rs | 1 + crates/rmcp/tests/test_logging.rs | 1 + crates/rmcp/tests/test_message_protocol.rs | 1 + crates/rmcp/tests/test_notification.rs | 1 + crates/rmcp/tests/test_progress_subscriber.rs | 1 + crates/rmcp/tests/test_prompt_macros.rs | 1 + crates/rmcp/tests/test_prompt_routers.rs | 1 + crates/rmcp/tests/test_sampling.rs | 1 + .../rmcp/tests/test_server_initialization.rs | 2 +- .../rmcp/tests/test_sse_concurrent_streams.rs | 1 + .../test_streamable_http_json_response.rs | 1 + .../tests/test_streamable_http_priming.rs | 1 + .../test_streamable_http_stale_session.rs | 3 +- .../tests/test_task_support_validation.rs | 1 + crates/rmcp/tests/test_tool_macros.rs | 1 + crates/rmcp/tests/test_tool_routers.rs | 1 + crates/rmcp/tests/test_with_js.rs | 1 + crates/rmcp/tests/test_with_python.rs | 1 + 41 files changed, 362 insertions(+), 222 deletions(-) diff --git a/crates/rmcp-macros/Cargo.toml b/crates/rmcp-macros/Cargo.toml index 8413e5d93..6f645bdc7 100644 --- a/crates/rmcp-macros/Cargo.toml +++ b/crates/rmcp-macros/Cargo.toml @@ -22,4 +22,6 @@ serde_json = "1.0" darling = { version = "0.23" } [features] +local = [] + [dev-dependencies] diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index 56bf65a14..6fe1765a8 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -95,6 +95,9 @@ pub struct ToolAttribute { pub icons: Option, /// Optional metadata for the tool pub meta: Option, + /// When true, the generated future will not require `Send`. Useful for `!Send` handlers + /// (e.g. single-threaded database connections). Also enabled globally by the `local` crate feature. + pub local: bool, } #[derive(FromMeta, Debug, Default)] @@ -333,7 +336,9 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { if fn_item.sig.asyncness.is_some() { // 1. remove asyncness from sig // 2. make return type: `std::pin::Pin + Send + '_>>` + // (omit `+ Send` when the `local` crate feature is active or `#[tool(local)]` is used) // 3. make body: { Box::pin(async move { #body }) } + let omit_send = cfg!(feature = "local") || attribute.local; let new_output = syn::parse2::({ let mut lt = quote! { 'static }; if let Some(receiver) = fn_item.sig.receiver() { @@ -347,10 +352,18 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { } match &fn_item.sig.output { syn::ReturnType::Default => { - quote! { -> ::std::pin::Pin + Send + #lt>> } + if omit_send { + quote! { -> ::std::pin::Pin + #lt>> } + } else { + quote! { -> ::std::pin::Pin + Send + #lt>> } + } } syn::ReturnType::Type(_, ty) => { - quote! { -> ::std::pin::Pin + Send + #lt>> } + if omit_send { + quote! { -> ::std::pin::Pin + #lt>> } + } else { + quote! { -> ::std::pin::Pin + Send + #lt>> } + } } } })?; diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 6ac4b02c0..b8677cb27 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -77,6 +77,7 @@ chrono = { version = "0.4.38", default-features = false, features = [ [features] default = ["base64", "macros", "server"] +local = ["rmcp-macros?/local"] client = ["dep:tokio-stream"] server = ["transport-async-rw", "dep:schemars", "dep:pastey"] macros = ["dep:rmcp-macros", "dep:pastey"] diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index 24deade15..c133e40e8 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -52,7 +52,7 @@ The transport layer is pluggable. Two built-in pairs cover the most common cases | | Client | Server | |:-:|:-:|:-:| | **stdio** | [`TokioChildProcess`](crate::transport::TokioChildProcess) | [`stdio`](crate::transport::stdio) | -| **Streamable HTTP** | [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) | [`StreamableHttpService`](crate::transport::StreamableHttpService) | +| **Streamable HTTP** | [`StreamableHttpClientTransport`](crate::transport::StreamableHttpClientTransport) | `StreamableHttpService` | Any type that implements the [`Transport`](crate::transport::Transport) trait can be used. The [`IntoTransport`](crate::transport::IntoTransport) helper trait provides automatic conversions from: diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index eeb79309e..1b9c1e38e 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -4,7 +4,9 @@ use std::sync::Arc; use crate::{ error::ErrorData as McpError, model::*, - service::{NotificationContext, RequestContext, RoleClient, Service, ServiceRole}, + service::{ + MaybeSendFuture, NotificationContext, RequestContext, RoleClient, Service, ServiceRole, + }, }; impl Service for H { @@ -83,7 +85,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { fn ping( &self, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Ok(())) } @@ -91,7 +93,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { &self, params: CreateMessageRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err( McpError::method_not_found::(), )) @@ -100,7 +102,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { fn list_roots( &self, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Ok(ListRootsResult::default())) } @@ -162,7 +164,8 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { &self, request: CreateElicitationRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ + { // Default implementation declines all requests - real clients should override this let _ = (request, context); std::future::ready(Ok(CreateElicitationResult { @@ -175,7 +178,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { &self, request: CustomRequest, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let CustomRequest { method, .. } = request; let _ = context; std::future::ready(Err(McpError::new( @@ -189,46 +192,46 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { &self, params: CancelledNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_progress( &self, params: ProgressNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_logging_message( &self, params: LoggingMessageNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_resource_updated( &self, params: ResourceUpdatedNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_resource_list_changed( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_tool_list_changed( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_prompt_list_changed( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } @@ -236,14 +239,14 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { &self, params: ElicitationResponseNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_custom_notification( &self, notification: CustomNotification, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { let _ = (notification, context); std::future::ready(()) } @@ -269,7 +272,7 @@ macro_rules! impl_client_handler_for_wrapper { fn ping( &self, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).ping(context) } @@ -277,14 +280,14 @@ macro_rules! impl_client_handler_for_wrapper { &self, params: CreateMessageRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).create_message(params, context) } fn list_roots( &self, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).list_roots(context) } @@ -292,7 +295,7 @@ macro_rules! impl_client_handler_for_wrapper { &self, request: CreateElicitationRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).create_elicitation(request, context) } @@ -300,7 +303,7 @@ macro_rules! impl_client_handler_for_wrapper { &self, request: CustomRequest, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).on_custom_request(request, context) } @@ -308,7 +311,7 @@ macro_rules! impl_client_handler_for_wrapper { &self, params: CancelledNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_cancelled(params, context) } @@ -316,7 +319,7 @@ macro_rules! impl_client_handler_for_wrapper { &self, params: ProgressNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_progress(params, context) } @@ -324,7 +327,7 @@ macro_rules! impl_client_handler_for_wrapper { &self, params: LoggingMessageNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_logging_message(params, context) } @@ -332,28 +335,28 @@ macro_rules! impl_client_handler_for_wrapper { &self, params: ResourceUpdatedNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_resource_updated(params, context) } fn on_resource_list_changed( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_resource_list_changed(context) } fn on_tool_list_changed( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_tool_list_changed(context) } fn on_prompt_list_changed( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_prompt_list_changed(context) } @@ -361,7 +364,7 @@ macro_rules! impl_client_handler_for_wrapper { &self, notification: CustomNotification, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_custom_notification(notification, context) } diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index a7ae335b0..7f21f8d63 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use crate::{ error::ErrorData as McpError, model::{TaskSupport, *}, - service::{NotificationContext, RequestContext, RoleServer, Service, ServiceRole}, + service::{ + MaybeSend, MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, + ServiceRole, + }, }; pub mod common; @@ -159,12 +162,16 @@ impl Service for H { } #[allow(unused_variables)] -pub trait ServerHandler: Sized + Send + Sync + 'static { +#[allow( + private_bounds, + reason = "MaybeSend is a sealed conditional Send + Sync alias" +)] +pub trait ServerHandler: Sized + MaybeSend + 'static { fn enqueue_task( &self, _request: CallToolRequestParams, _context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::internal_error( "Task processing not implemented".to_string(), None, @@ -173,7 +180,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { fn ping( &self, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Ok(())) } // handle requests @@ -181,7 +188,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: InitializeRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { if context.peer.peer_info().is_none() { context.peer.set_peer_info(request); } @@ -191,49 +198,50 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: CompleteRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Ok(CompleteResult::default())) } fn set_level( &self, request: SetLevelRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn get_prompt( &self, request: GetPromptRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn list_prompts( &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Ok(ListPromptsResult::default())) } fn list_resources( &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Ok(ListResourcesResult::default())) } fn list_resource_templates( &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ + { std::future::ready(Ok(ListResourceTemplatesResult::default())) } fn read_resource( &self, request: ReadResourceRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err( McpError::method_not_found::(), )) @@ -242,28 +250,28 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: SubscribeRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn unsubscribe( &self, request: UnsubscribeRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn call_tool( &self, request: CallToolRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn list_tools( &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Ok(ListToolsResult::default())) } /// Get a tool definition by name. @@ -277,7 +285,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: CustomRequest, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let CustomRequest { method, .. } = request; let _ = context; std::future::ready(Err(McpError::new( @@ -291,34 +299,34 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, notification: CancelledNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_progress( &self, notification: ProgressNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_initialized( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { tracing::info!("client initialized"); std::future::ready(()) } fn on_roots_list_changed( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } fn on_custom_notification( &self, notification: CustomNotification, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { let _ = (notification, context); std::future::ready(()) } @@ -331,7 +339,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } @@ -339,7 +347,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: GetTaskInfoParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -348,7 +356,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: GetTaskResultParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -357,7 +365,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -370,14 +378,14 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: CallToolRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).enqueue_task(request, context) } fn ping( &self, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).ping(context) } @@ -385,7 +393,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: InitializeRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).initialize(request, context) } @@ -393,7 +401,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: CompleteRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).complete(request, context) } @@ -401,7 +409,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: SetLevelRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).set_level(request, context) } @@ -409,7 +417,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: GetPromptRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).get_prompt(request, context) } @@ -417,7 +425,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).list_prompts(request, context) } @@ -425,7 +433,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).list_resources(request, context) } @@ -433,7 +441,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ + ) -> impl Future> + MaybeSendFuture + '_ { (**self).list_resource_templates(request, context) } @@ -442,7 +450,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: ReadResourceRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).read_resource(request, context) } @@ -450,7 +458,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: SubscribeRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).subscribe(request, context) } @@ -458,7 +466,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: UnsubscribeRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).unsubscribe(request, context) } @@ -466,7 +474,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: CallToolRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).call_tool(request, context) } @@ -474,7 +482,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).list_tools(request, context) } @@ -486,7 +494,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: CustomRequest, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).on_custom_request(request, context) } @@ -494,7 +502,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, notification: CancelledNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_cancelled(notification, context) } @@ -502,21 +510,21 @@ macro_rules! impl_server_handler_for_wrapper { &self, notification: ProgressNotificationParam, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_progress(notification, context) } fn on_initialized( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_initialized(context) } fn on_roots_list_changed( &self, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_roots_list_changed(context) } @@ -524,7 +532,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, notification: CustomNotification, context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { (**self).on_custom_notification(notification, context) } @@ -536,7 +544,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: Option, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).list_tasks(request, context) } @@ -544,7 +552,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: GetTaskInfoParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).get_task_info(request, context) } @@ -552,7 +560,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: GetTaskResultParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).get_task_result(request, context) } @@ -560,7 +568,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).cancel_task(request, context) } } diff --git a/crates/rmcp/src/handler/server/prompt.rs b/crates/rmcp/src/handler/server/prompt.rs index 826bee0df..27a03e835 100644 --- a/crates/rmcp/src/handler/server/prompt.rs +++ b/crates/rmcp/src/handler/server/prompt.rs @@ -6,7 +6,8 @@ use std::{future::Future, marker::PhantomData}; -use futures::future::{BoxFuture, FutureExt}; +#[cfg(not(feature = "local"))] +use futures::future::BoxFuture; use serde::de::DeserializeOwned; use super::common::{AsRequestContext, FromContextPart}; @@ -15,7 +16,7 @@ use crate::{ RoleServer, handler::server::wrapper::Parameters, model::{GetPromptResult, PromptMessage}, - service::RequestContext, + service::{MaybeBoxFuture, MaybeSend, MaybeSendFuture, RequestContext}, }; /// Context for prompt retrieval operations @@ -57,14 +58,23 @@ pub trait GetPromptHandler { fn handle( self, context: PromptContext<'_, S>, - ) -> BoxFuture<'_, Result>; + ) -> MaybeBoxFuture<'_, Result>; } /// Type alias for dynamic prompt handlers +#[cfg(not(feature = "local"))] pub type DynGetPromptHandler = dyn for<'a> Fn(PromptContext<'a, S>) -> BoxFuture<'a, Result> + Send + Sync; +#[cfg(feature = "local")] +pub type DynGetPromptHandler = dyn for<'a> Fn( + PromptContext<'a, S>, +) -> futures::future::LocalBoxFuture< + 'a, + Result, +>; + /// Adapter type for async methods that return `Vec` pub struct AsyncMethodAdapter(PhantomData); @@ -191,31 +201,31 @@ macro_rules! impl_prompt_handler_for { impl<$($Tn,)* S, F, R> GetPromptHandler for F where $( - $Tn: for<'a> FromContextPart> + Send, + $Tn: for<'a> FromContextPart> + MaybeSendFuture, )* - F: FnOnce(&S, $($Tn,)*) -> BoxFuture<'_, R> + Send, - R: IntoGetPromptResult + Send + 'static, - S: Send + Sync + 'static, + F: FnOnce(&S, $($Tn,)*) -> MaybeBoxFuture<'_, R> + MaybeSendFuture, + R: IntoGetPromptResult + MaybeSendFuture + 'static, + S: MaybeSend + 'static, { #[allow(unused_variables, non_snake_case, unused_mut)] fn handle( self, mut context: PromptContext<'_, S>, - ) -> BoxFuture<'_, Result> + ) -> MaybeBoxFuture<'_, Result> { $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { Ok(value) => value, - Err(e) => return std::future::ready(Err(e)).boxed(), + Err(e) => return Box::pin(std::future::ready(Err(e))), }; )* let service = context.server; let fut = self(service, $($Tn,)*); - async move { + Box::pin(async move { let result = fut.await; result.into_get_prompt_result() - }.boxed() + }) } } @@ -224,28 +234,28 @@ macro_rules! impl_prompt_handler_for { impl<$($Tn,)* S, F, R> GetPromptHandler> for F where $( - $Tn: for<'a> FromContextPart> + Send, + $Tn: for<'a> FromContextPart> + MaybeSendFuture, )* - F: FnOnce(&S, $($Tn,)*) -> R + Send, - R: IntoGetPromptResult + Send, - S: Send + Sync, + F: FnOnce(&S, $($Tn,)*) -> R + MaybeSendFuture, + R: IntoGetPromptResult + MaybeSendFuture, + S: MaybeSend, { #[allow(unused_variables, non_snake_case, unused_mut)] fn handle( self, mut context: PromptContext<'_, S>, - ) -> BoxFuture<'_, Result> + ) -> MaybeBoxFuture<'_, Result> { $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { Ok(value) => value, - Err(e) => return std::future::ready(Err(e)).boxed(), + Err(e) => return Box::pin(std::future::ready(Err(e))), }; )* let service = context.server; let result = self(service, $($Tn,)*); - std::future::ready(result.into_get_prompt_result()).boxed() + Box::pin(std::future::ready(result.into_get_prompt_result())) } } @@ -254,25 +264,25 @@ macro_rules! impl_prompt_handler_for { impl<$($Tn,)* S, F, Fut, R> GetPromptHandler> for F where $( - $Tn: for<'a> FromContextPart> + Send + 'static, + $Tn: for<'a> FromContextPart> + MaybeSendFuture + 'static, )* - F: FnOnce($($Tn,)*) -> Fut + Send + 'static, - Fut: Future> + Send + 'static, - R: IntoGetPromptResult + Send + 'static, - S: Send + Sync + 'static, + F: FnOnce($($Tn,)*) -> Fut + MaybeSendFuture + 'static, + Fut: Future> + MaybeSendFuture + 'static, + R: IntoGetPromptResult + MaybeSendFuture + 'static, + S: MaybeSend + 'static, { #[allow(unused_variables, non_snake_case, unused_mut)] fn handle( self, mut context: PromptContext<'_, S>, - ) -> BoxFuture<'_, Result> + ) -> MaybeBoxFuture<'_, Result> { // Extract all parameters before moving into the async block $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { Ok(value) => value, - Err(e) => return std::future::ready(Err(e)).boxed(), + Err(e) => return Box::pin(std::future::ready(Err(e))), }; )* @@ -290,27 +300,27 @@ macro_rules! impl_prompt_handler_for { impl<$($Tn,)* S, F, R> GetPromptHandler> for F where $( - $Tn: for<'a> FromContextPart> + Send + 'static, + $Tn: for<'a> FromContextPart> + MaybeSendFuture + 'static, )* - F: FnOnce($($Tn,)*) -> Result + Send + 'static, - R: IntoGetPromptResult + Send + 'static, - S: Send + Sync, + F: FnOnce($($Tn,)*) -> Result + MaybeSendFuture + 'static, + R: IntoGetPromptResult + MaybeSendFuture + 'static, + S: MaybeSend, { #[allow(unused_variables, non_snake_case, unused_mut)] fn handle( self, mut context: PromptContext<'_, S>, - ) -> BoxFuture<'_, Result> + ) -> MaybeBoxFuture<'_, Result> { $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { Ok(value) => value, - Err(e) => return std::future::ready(Err(e)).boxed(), + Err(e) => return Box::pin(std::future::ready(Err(e))), }; )* let result = self($($Tn,)*); - std::future::ready(result.and_then(|r| r.into_get_prompt_result())).boxed() + Box::pin(std::future::ready(result.and_then(|r| r.into_get_prompt_result()))) } } diff --git a/crates/rmcp/src/handler/server/router/prompt.rs b/crates/rmcp/src/handler/server/router/prompt.rs index 6ea925a0e..b5ea4a47f 100644 --- a/crates/rmcp/src/handler/server/router/prompt.rs +++ b/crates/rmcp/src/handler/server/router/prompt.rs @@ -1,10 +1,9 @@ use std::{borrow::Cow, sync::Arc}; -use futures::future::BoxFuture; - use crate::{ handler::server::prompt::{DynGetPromptHandler, GetPromptHandler, PromptContext}, model::{GetPromptResult, Prompt}, + service::{MaybeBoxFuture, MaybeSend}, }; pub struct PromptRoute { @@ -32,10 +31,10 @@ impl Clone for PromptRoute { } } -impl PromptRoute { +impl PromptRoute { pub fn new(attr: impl Into, handler: H) -> Self where - H: GetPromptHandler + Send + Sync + Clone + 'static, + H: GetPromptHandler + MaybeSend + Clone + 'static, { Self { get: Arc::new(move |context: PromptContext| { @@ -50,9 +49,8 @@ impl PromptRoute { where H: for<'a> Fn( PromptContext<'a, S>, - ) -> BoxFuture<'a, Result> - + Send - + Sync + ) -> MaybeBoxFuture<'a, Result> + + MaybeSend + 'static, { Self { @@ -72,9 +70,9 @@ pub trait IntoPromptRoute { impl IntoPromptRoute for (P, H) where - S: Send + Sync + 'static, + S: MaybeSend + 'static, A: 'static, - H: GetPromptHandler + Send + Sync + Clone + 'static, + H: GetPromptHandler + MaybeSend + Clone + 'static, P: Into, { fn into_prompt_route(self) -> PromptRoute { @@ -84,7 +82,7 @@ where impl IntoPromptRoute for PromptRoute where - S: Send + Sync + 'static, + S: MaybeSend + 'static, { fn into_prompt_route(self) -> PromptRoute { self @@ -96,7 +94,7 @@ pub struct PromptAttrGenerateFunctionAdapter; impl IntoPromptRoute for F where - S: Send + Sync + 'static, + S: MaybeSend + 'static, F: Fn() -> PromptRoute, { fn into_prompt_route(self) -> PromptRoute { @@ -137,7 +135,7 @@ impl IntoIterator for PromptRouter { impl PromptRouter where - S: Send + Sync + 'static, + S: MaybeSend + 'static, { pub fn new() -> Self { Self { @@ -195,7 +193,7 @@ where impl std::ops::Add> for PromptRouter where - S: Send + Sync + 'static, + S: MaybeSend + 'static, { type Output = Self; @@ -207,7 +205,7 @@ where impl std::ops::AddAssign> for PromptRouter where - S: Send + Sync + 'static, + S: MaybeSend + 'static, { fn add_assign(&mut self, other: PromptRouter) { self.merge(other); diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 5c1941bd0..42c582c40 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -124,7 +124,6 @@ mod tool_traits; use std::{borrow::Cow, sync::Arc}; -use futures::{FutureExt, future::BoxFuture}; use schemars::JsonSchema; pub use tool_traits::{AsyncTool, SyncTool, ToolBase}; @@ -134,6 +133,7 @@ use crate::{ tool_name_validation::validate_and_warn_tool_name, }, model::{CallToolResult, Tool, ToolAnnotations}, + service::{MaybeBoxFuture, MaybeSend}, }; pub struct ToolRoute { @@ -161,15 +161,15 @@ impl Clone for ToolRoute { } } -impl ToolRoute { +impl ToolRoute { pub fn new(attr: impl Into, call: C) -> Self where - C: CallToolHandler + Send + Sync + Clone + 'static, + C: CallToolHandler + MaybeSend + Clone + 'static, { Self { call: Arc::new(move |context: ToolCallContext| { let call = call.clone(); - context.invoke(call).boxed() + context.invoke(call) }), attr: attr.into(), } @@ -178,9 +178,8 @@ impl ToolRoute { where C: for<'a> Fn( ToolCallContext<'a, S>, - ) -> BoxFuture<'a, Result> - + Send - + Sync + ) -> MaybeBoxFuture<'a, Result> + + MaybeSend + 'static, { Self { @@ -199,8 +198,8 @@ pub trait IntoToolRoute { impl IntoToolRoute for (T, C) where - S: Send + Sync + 'static, - C: CallToolHandler + Send + Sync + Clone + 'static, + S: MaybeSend + 'static, + C: CallToolHandler + MaybeSend + Clone + 'static, T: Into, { fn into_tool_route(self) -> ToolRoute { @@ -210,7 +209,7 @@ where impl IntoToolRoute for ToolRoute where - S: Send + Sync + 'static, + S: MaybeSend + 'static, { fn into_tool_route(self) -> ToolRoute { self @@ -220,7 +219,7 @@ where pub struct ToolAttrGenerateFunctionAdapter; impl IntoToolRoute for F where - S: Send + Sync + 'static, + S: MaybeSend + 'static, F: Fn() -> ToolRoute, { fn into_tool_route(self) -> ToolRoute { @@ -230,14 +229,14 @@ where pub trait CallToolHandlerExt: Sized where - Self: CallToolHandler + Send + Sync + Clone + 'static, + Self: CallToolHandler + MaybeSend + Clone + 'static, { fn name(self, name: impl Into>) -> WithToolAttr; } impl CallToolHandlerExt for C where - C: CallToolHandler + Send + Sync + Clone + 'static, + C: CallToolHandler + MaybeSend + Clone + 'static, { fn name(self, name: impl Into>) -> WithToolAttr { WithToolAttr { @@ -254,7 +253,7 @@ where pub struct WithToolAttr where - C: CallToolHandler + Send + Sync + Clone + 'static, + C: CallToolHandler + MaybeSend + Clone + 'static, { pub attr: crate::model::Tool, pub call: C, @@ -263,8 +262,8 @@ where impl IntoToolRoute for WithToolAttr where - C: CallToolHandler + Send + Sync + Clone + 'static, - S: Send + Sync + 'static, + C: CallToolHandler + MaybeSend + Clone + 'static, + S: MaybeSend + 'static, { fn into_tool_route(self) -> ToolRoute { ToolRoute::new(self.attr, self.call) @@ -273,7 +272,7 @@ where impl WithToolAttr where - C: CallToolHandler + Send + Sync + Clone + 'static, + C: CallToolHandler + MaybeSend + Clone + 'static, { pub fn description(mut self, description: impl Into>) -> Self { self.attr.description = Some(description.into()); @@ -328,7 +327,7 @@ impl IntoIterator for ToolRouter { impl ToolRouter where - S: Send + Sync + 'static, + S: MaybeSend + 'static, { pub fn new() -> Self { Self { @@ -428,7 +427,7 @@ where impl std::ops::Add> for ToolRouter where - S: Send + Sync + 'static, + S: MaybeSend + 'static, { type Output = Self; @@ -440,7 +439,7 @@ where impl std::ops::AddAssign> for ToolRouter where - S: Send + Sync + 'static, + S: MaybeSend + 'static, { fn add_assign(&mut self, other: ToolRouter) { self.merge(other); diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs index 60ac9cff0..e4167a08b 100644 --- a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, pin::Pin, sync::Arc}; +use std::{borrow::Cow, future::Future, sync::Arc}; use serde::{Deserialize, Serialize}; @@ -11,6 +11,7 @@ use crate::{ }, model::{Icon, JsonObject, Meta, ToolAnnotations, ToolExecution}, schemars::JsonSchema, + service::{MaybeSend, MaybeSendFuture}, }; /// Base trait to define attributes of a tool. @@ -84,7 +85,8 @@ pub trait ToolBase { /// /// Consider using [`AsyncTool`] if your workflow involves asynchronous operations. /// Examples are shown in [the module-level documentation][crate::handler::server::router::tool]. -pub trait SyncTool: ToolBase { +#[allow(private_bounds)] +pub trait SyncTool: ToolBase { fn invoke(service: &S, param: Self::Parameter) -> Result; } @@ -92,11 +94,12 @@ pub trait SyncTool: ToolBase { /// /// Consider using [`SyncTool`] if your workflow does not involve asynchronous operations. /// Examples are shown in [the module-level documentation][crate::handler::server::router::tool]. -pub trait AsyncTool: ToolBase { +#[allow(private_bounds)] +pub trait AsyncTool: ToolBase { fn invoke( service: &S, param: Self::Parameter, - ) -> impl Future> + Send; + ) -> impl Future> + MaybeSendFuture; } pub(crate) fn tool_attribute() -> crate::model::Tool { @@ -113,14 +116,14 @@ pub(crate) fn tool_attribute() -> crate::model::Tool { } } -pub(crate) fn sync_tool_wrapper>( +pub(crate) fn sync_tool_wrapper>( service: &S, Parameters(params): Parameters, ) -> Result, ErrorData> { T::invoke(service, params).map(Json).map_err(Into::into) } -pub(crate) fn sync_tool_wrapper_with_empty_params>( +pub(crate) fn sync_tool_wrapper_with_empty_params>( service: &S, ) -> Result, ErrorData> { T::invoke(service, T::Parameter::default()) @@ -128,11 +131,10 @@ pub(crate) fn sync_tool_wrapper_with_empty_params>( +pub(crate) fn async_tool_wrapper>( service: &S, Parameters(params): Parameters, -) -> Pin, ErrorData>> + Send + '_>> { +) -> crate::service::MaybeBoxFuture<'_, Result, ErrorData>> { Box::pin(async move { T::invoke(service, params) .await @@ -141,10 +143,9 @@ pub(crate) fn async_tool_wrapper>( }) } -#[expect(clippy::type_complexity)] -pub(crate) fn async_tool_wrapper_with_empty_params>( +pub(crate) fn async_tool_wrapper_with_empty_params>( service: &S, -) -> Pin, ErrorData>> + Send + '_>> { +) -> crate::service::MaybeBoxFuture<'_, Result, ErrorData>> { Box::pin(async move { T::invoke(service, T::Parameter::default()) .await diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index c98aef0d5..0ad8ce61a 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -4,7 +4,8 @@ use std::{ marker::PhantomData, }; -use futures::future::{BoxFuture, FutureExt}; +#[cfg(not(feature = "local"))] +use futures::future::BoxFuture; use serde::de::DeserializeOwned; use super::common::{AsRequestContext, FromContextPart}; @@ -16,7 +17,7 @@ use crate::{ RoleServer, handler::server::wrapper::Parameters, model::{CallToolRequestParams, CallToolResult, IntoContents, JsonObject}, - service::RequestContext, + service::{MaybeBoxFuture, MaybeSend, MaybeSendFuture, RequestContext}, }; /// Deserialize a JSON object into a type @@ -146,13 +147,21 @@ pub trait CallToolHandler { fn call( self, context: ToolCallContext<'_, S>, - ) -> BoxFuture<'_, Result>; + ) -> MaybeBoxFuture<'_, Result>; } +#[cfg(not(feature = "local"))] pub type DynCallToolHandler = dyn for<'s> Fn(ToolCallContext<'s, S>) -> BoxFuture<'s, Result> + Send + Sync; +#[cfg(feature = "local")] +pub type DynCallToolHandler = + dyn for<'s> Fn( + ToolCallContext<'s, S>, + ) + -> futures::future::LocalBoxFuture<'s, Result>; + // Tool-specific extractor for tool name pub struct ToolName(pub Cow<'static, str>); @@ -189,7 +198,7 @@ impl FromContextPart> for JsonObject { } impl<'s, S> ToolCallContext<'s, S> { - pub fn invoke(self, h: H) -> BoxFuture<'s, Result> + pub fn invoke(self, h: H) -> MaybeBoxFuture<'s, Result> where H: CallToolHandler, { @@ -221,31 +230,31 @@ macro_rules! impl_for { $( $Tn: for<'a> FromContextPart> , )* - F: FnOnce(&S, $($Tn,)*) -> BoxFuture<'_, R>, + F: FnOnce(&S, $($Tn,)*) -> MaybeBoxFuture<'_, R>, // Need RTN support here(I guess), https://github.com/rust-lang/rust/pull/138424 // Fut: Future + Send + 'a, - R: IntoCallToolResult + Send + 'static, - S: Send + Sync + 'static, + R: IntoCallToolResult + MaybeSendFuture + 'static, + S: MaybeSend + 'static, { #[allow(unused_variables, non_snake_case, unused_mut)] fn call( self, mut context: ToolCallContext<'_, S>, - ) -> BoxFuture<'_, Result>{ + ) -> MaybeBoxFuture<'_, Result>{ $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { Ok(value) => value, - Err(e) => return std::future::ready(Err(e)).boxed(), + Err(e) => return Box::pin(std::future::ready(Err(e))), }; )* let service = context.service; let fut = self(service, $($Tn,)*); - async move { + Box::pin(async move { let result = fut.await; result.into_call_tool_result() - }.boxed() + }) } } @@ -254,28 +263,28 @@ macro_rules! impl_for { $( $Tn: for<'a> FromContextPart> , )* - F: FnOnce($($Tn,)*) -> Fut + Send + , - Fut: Future + Send + 'static, - R: IntoCallToolResult + Send + 'static, - S: Send + Sync, + F: FnOnce($($Tn,)*) -> Fut + MaybeSendFuture, + Fut: Future + MaybeSendFuture + 'static, + R: IntoCallToolResult + MaybeSendFuture + 'static, + S: MaybeSend, { #[allow(unused_variables, non_snake_case, unused_mut)] fn call( self, mut context: ToolCallContext, - ) -> BoxFuture<'static, Result>{ + ) -> MaybeBoxFuture<'static, Result>{ $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { Ok(value) => value, - Err(e) => return std::future::ready(Err(e)).boxed(), + Err(e) => return Box::pin(std::future::ready(Err(e))), }; )* let fut = self($($Tn,)*); - async move { + Box::pin(async move { let result = fut.await; result.into_call_tool_result() - }.boxed() + }) } } @@ -284,23 +293,23 @@ macro_rules! impl_for { $( $Tn: for<'a> FromContextPart> + , )* - F: FnOnce(&S, $($Tn,)*) -> R + Send + , - R: IntoCallToolResult + Send + , - S: Send + Sync, + F: FnOnce(&S, $($Tn,)*) -> R + MaybeSendFuture, + R: IntoCallToolResult + MaybeSendFuture, + S: MaybeSend, { #[allow(unused_variables, non_snake_case, unused_mut)] fn call( self, mut context: ToolCallContext, - ) -> BoxFuture<'static, Result> { + ) -> MaybeBoxFuture<'static, Result> { $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { Ok(value) => value, - Err(e) => return std::future::ready(Err(e)).boxed(), + Err(e) => return Box::pin(std::future::ready(Err(e))), }; )* - std::future::ready(self(context.service, $($Tn,)*).into_call_tool_result()).boxed() + Box::pin(std::future::ready(self(context.service, $($Tn,)*).into_call_tool_result())) } } @@ -309,23 +318,23 @@ macro_rules! impl_for { $( $Tn: for<'a> FromContextPart> + , )* - F: FnOnce($($Tn,)*) -> R + Send + , - R: IntoCallToolResult + Send + , - S: Send + Sync, + F: FnOnce($($Tn,)*) -> R + MaybeSendFuture, + R: IntoCallToolResult + MaybeSendFuture, + S: MaybeSend, { #[allow(unused_variables, non_snake_case, unused_mut)] fn call( self, mut context: ToolCallContext, - ) -> BoxFuture<'static, Result> { + ) -> MaybeBoxFuture<'static, Result> { $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { Ok(value) => value, - Err(e) => return std::future::ready(Err(e)).boxed(), + Err(e) => return Box::pin(std::future::ready(Err(e))), }; )* - std::future::ready(self($($Tn,)*).into_call_tool_result()).boxed() + Box::pin(std::future::ready(self($($Tn,)*).into_call_tool_result())) } } }; diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index d6613dd3c..be9b461ab 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -1,6 +1,47 @@ -use futures::{FutureExt, future::BoxFuture}; +use futures::FutureExt; +#[cfg(not(feature = "local"))] +use futures::future::BoxFuture; +#[cfg(feature = "local")] +use futures::future::LocalBoxFuture; use thiserror::Error; +// --------------------------------------------------------------------------- +// Conditional Send helpers +// +// `MaybeSend` – supertrait alias: `Send + Sync` without `local`, empty with `local` +// `MaybeSendFuture` – future bound alias: `Send` without `local`, empty with `local` +// `MaybeBoxFuture` – boxed future type: `BoxFuture` without `local`, `LocalBoxFuture` with `local` +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "local"))] +#[doc(hidden)] +pub trait MaybeSend: Send + Sync {} +#[cfg(not(feature = "local"))] +impl MaybeSend for T {} + +#[cfg(feature = "local")] +#[doc(hidden)] +pub trait MaybeSend {} +#[cfg(feature = "local")] +impl MaybeSend for T {} + +#[cfg(not(feature = "local"))] +#[doc(hidden)] +pub trait MaybeSendFuture: Send {} +#[cfg(not(feature = "local"))] +impl MaybeSendFuture for T {} + +#[cfg(feature = "local")] +#[doc(hidden)] +pub trait MaybeSendFuture {} +#[cfg(feature = "local")] +impl MaybeSendFuture for T {} + +#[cfg(not(feature = "local"))] +pub(crate) type MaybeBoxFuture<'a, T> = BoxFuture<'a, T>; +#[cfg(feature = "local")] +pub(crate) type MaybeBoxFuture<'a, T> = LocalBoxFuture<'a, T>; + #[cfg(feature = "server")] use crate::model::ServerJsonRpcMessage; use crate::{ @@ -87,17 +128,21 @@ pub type RxJsonRpcMessage = JsonRpcMessage< ::PeerNot, >; -pub trait Service: Send + Sync + 'static { +#[allow( + private_bounds, + reason = "MaybeSend is a sealed conditional Send + Sync alias" +)] +pub trait Service: MaybeSend + 'static { fn handle_request( &self, request: R::PeerReq, context: RequestContext, - ) -> impl Future> + Send + '_; + ) -> impl Future> + MaybeSendFuture + '_; fn handle_notification( &self, notification: R::PeerNot, context: NotificationContext, - ) -> impl Future> + Send + '_; + ) -> impl Future> + MaybeSendFuture + '_; fn get_info(&self) -> R::Info; } @@ -111,7 +156,7 @@ pub trait ServiceExt: Service + Sized { fn serve( self, transport: T, - ) -> impl Future, R::InitializeError>> + Send + ) -> impl Future, R::InitializeError>> + MaybeSendFuture where T: IntoTransport, E: std::error::Error + Send + Sync + 'static, @@ -123,7 +168,7 @@ pub trait ServiceExt: Service + Sized { self, transport: T, ct: CancellationToken, - ) -> impl Future, R::InitializeError>> + Send + ) -> impl Future, R::InitializeError>> + MaybeSendFuture where T: IntoTransport, E: std::error::Error + Send + Sync + 'static, @@ -135,7 +180,7 @@ impl Service for Box> { &self, request: R::PeerReq, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { DynService::handle_request(self.as_ref(), request, context) } @@ -143,7 +188,7 @@ impl Service for Box> { &self, notification: R::PeerNot, context: NotificationContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { DynService::handle_notification(self.as_ref(), notification, context) } @@ -152,17 +197,21 @@ impl Service for Box> { } } -pub trait DynService: Send + Sync { +#[allow( + private_bounds, + reason = "MaybeSend is a sealed conditional Send + Sync alias" +)] +pub trait DynService: MaybeSend { fn handle_request( &self, request: R::PeerReq, context: RequestContext, - ) -> BoxFuture<'_, Result>; + ) -> MaybeBoxFuture<'_, Result>; fn handle_notification( &self, notification: R::PeerNot, context: NotificationContext, - ) -> BoxFuture<'_, Result<(), McpError>>; + ) -> MaybeBoxFuture<'_, Result<(), McpError>>; fn get_info(&self) -> R::Info; } @@ -171,14 +220,14 @@ impl> DynService for S { &self, request: R::PeerReq, context: RequestContext, - ) -> BoxFuture<'_, Result> { + ) -> MaybeBoxFuture<'_, Result> { Box::pin(self.handle_request(request, context)) } fn handle_notification( &self, notification: R::PeerNot, context: NotificationContext, - ) -> BoxFuture<'_, Result<(), McpError>> { + ) -> MaybeBoxFuture<'_, Result<(), McpError>> { Box::pin(self.handle_notification(notification, context)) } fn get_info(&self) -> R::Info { @@ -639,6 +688,28 @@ where serve_inner(service, transport.into_transport(), peer, peer_rx, ct) } +/// Spawn a task that may hold `!Send` state when the `local` feature is active. +/// +/// Without the `local` feature this is `tokio::spawn` (requires `Future: Send + 'static`). +/// With `local` it uses `tokio::task::spawn_local` (requires only `Future: 'static`). +#[cfg(not(feature = "local"))] +fn spawn_service_task(future: F) -> tokio::task::JoinHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + tokio::spawn(future) +} + +#[cfg(feature = "local")] +fn spawn_service_task(future: F) -> tokio::task::JoinHandle +where + F: Future + 'static, + F::Output: 'static, +{ + tokio::task::spawn_local(future) +} + #[instrument(skip_all)] fn serve_inner( service: S, @@ -674,7 +745,7 @@ where let serve_loop_ct = ct.child_token(); let peer_return: Peer = peer.clone(); let current_span = tracing::Span::current(); - let handle = tokio::spawn(async move { + let handle = spawn_service_task(async move { let mut transport = transport.into_transport(); let mut batch_messages = VecDeque::>::new(); let mut send_task_set = tokio::task::JoinSet::::new(); @@ -860,7 +931,7 @@ where extensions, }; let current_span = tracing::Span::current(); - tokio::spawn(async move { + spawn_service_task(async move { let result = service .handle_request(request, context) .await; @@ -907,7 +978,7 @@ where extensions, }; let current_span = tracing::Span::current(); - tokio::spawn(async move { + spawn_service_task(async move { let result = service.handle_notification(notification, context).await; if let Err(error) = result { tracing::warn!(%error, "Error sending notification"); diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 6528e4144..8b49606e4 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -162,7 +162,8 @@ impl> ServiceExt for S { self, transport: T, ct: CancellationToken, - ) -> impl Future, ClientInitializeError>> + Send + ) -> impl Future, ClientInitializeError>> + + MaybeSendFuture where T: IntoTransport, E: std::error::Error + Send + Sync + 'static, diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 85f3f69a6..5946d23a4 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -95,7 +95,8 @@ impl> ServiceExt for S { self, transport: T, ct: CancellationToken, - ) -> impl Future, ServerInitializeError>> + Send + ) -> impl Future, ServerInitializeError>> + + MaybeSendFuture where T: IntoTransport, E: std::error::Error + Send + Sync + 'static, diff --git a/crates/rmcp/src/service/tower.rs b/crates/rmcp/src/service/tower.rs index ac4a66f00..867d3e7fa 100644 --- a/crates/rmcp/src/service/tower.rs +++ b/crates/rmcp/src/service/tower.rs @@ -3,7 +3,7 @@ use std::{future::poll_fn, marker::PhantomData}; use tower_service::Service as TowerService; use super::NotificationContext; -use crate::service::{RequestContext, Service, ServiceRole}; +use crate::service::{MaybeSendFuture, RequestContext, Service, ServiceRole}; pub struct TowerHandler { pub service: S, @@ -44,7 +44,7 @@ where &self, _notification: R::PeerNot, _context: NotificationContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Ok(())) } diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 683f6880f..8a90542d8 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -7,7 +7,7 @@ //! | transport | client | server | //! |:-: |:-: |:-: | //! | std IO | [`child_process::TokioChildProcess`] | [`io::stdio`] | -//! | streamable http | [`streamable_http_client::StreamableHttpClientTransport`] | [`streamable_http_server::StreamableHttpService`] | +//! | streamable http | [`streamable_http_client::StreamableHttpClientTransport`] | `streamable_http_server::StreamableHttpService` | //! //!## Helper Transport Types //! Thers are several helper transport types that can help you to create transport quickly. @@ -107,7 +107,7 @@ pub use auth::{ // pub mod ws; #[cfg(feature = "transport-streamable-http-server-session")] pub mod streamable_http_server; -#[cfg(feature = "transport-streamable-http-server")] +#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] pub use streamable_http_server::tower::{StreamableHttpServerConfig, StreamableHttpService}; #[cfg(feature = "transport-streamable-http-client")] diff --git a/crates/rmcp/src/transport/streamable_http_server.rs b/crates/rmcp/src/transport/streamable_http_server.rs index b991ff2e2..9cbb63cc0 100644 --- a/crates/rmcp/src/transport/streamable_http_server.rs +++ b/crates/rmcp/src/transport/streamable_http_server.rs @@ -1,6 +1,6 @@ pub mod session; -#[cfg(feature = "transport-streamable-http-server")] +#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] pub mod tower; pub use session::{SessionId, SessionManager}; -#[cfg(feature = "transport-streamable-http-server")] +#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] pub use tower::{StreamableHttpServerConfig, StreamableHttpService}; diff --git a/crates/rmcp/src/transport/streamable_http_server/session.rs b/crates/rmcp/src/transport/streamable_http_server/session.rs index 9cf4d0dbc..dcdb25c86 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session.rs @@ -33,7 +33,7 @@ pub mod never; /// Controls how MCP sessions are created, validated, and closed. /// -/// The [`StreamableHttpService`](super::StreamableHttpService) calls into this +/// The `StreamableHttpService` calls into this /// trait for every HTTP request that carries (or should carry) a session ID. /// /// See the [module-level docs](self) for background on sessions. diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 74b1fd79e..0e6f0789e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -204,7 +204,7 @@ impl Clone for StreamableHttpService { impl tower_service::Service> for StreamableHttpService where RequestBody: Body + Send + 'static, - S: crate::Service, + S: crate::Service + Send + 'static, M: SessionManager, RequestBody::Error: Display, RequestBody::Data: Send + 'static, diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index 811bd824b..866cbdeff 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -7,7 +7,11 @@ use std::{ use rmcp::service::NotificationContext; #[cfg(feature = "client")] use rmcp::{ClientHandler, RoleClient}; -use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, model::*, service::RequestContext}; +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, + model::*, + service::{MaybeSendFuture, RequestContext}, +}; #[cfg(feature = "client")] use serde_json::json; use tokio::sync::Notify; @@ -85,7 +89,7 @@ impl ClientHandler for TestClientHandler { &self, params: LoggingMessageNotificationParam, _context: NotificationContext, - ) -> impl Future + Send + '_ { + ) -> impl Future + MaybeSendFuture + '_ { let receive_signal = self.receive_signal.clone(); let received_messages = self.received_messages.clone(); @@ -116,7 +120,7 @@ impl ServerHandler for TestServer { &self, request: SetLevelRequestParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let peer = context.peer; async move { let (data, logger) = match request.level { diff --git a/crates/rmcp/tests/test_client_initialization.rs b/crates/rmcp/tests/test_client_initialization.rs index c9b8f94a2..4a91f3ac3 100644 --- a/crates/rmcp/tests/test_client_initialization.rs +++ b/crates/rmcp/tests/test_client_initialization.rs @@ -1,5 +1,5 @@ // cargo test --features "server client" --package rmcp test_client_initialization -#![cfg(feature = "client")] +#![cfg(all(feature = "client", not(feature = "local")))] mod common; diff --git a/crates/rmcp/tests/test_close_connection.rs b/crates/rmcp/tests/test_close_connection.rs index b3bb5b638..50479a237 100644 --- a/crates/rmcp/tests/test_close_connection.rs +++ b/crates/rmcp/tests/test_close_connection.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] //cargo test --test test_close_connection --features "client server" mod common; diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index b83c85772..7d4316d3e 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use std::collections::HashMap; use http::{HeaderName, HeaderValue}; diff --git a/crates/rmcp/tests/test_custom_request.rs b/crates/rmcp/tests/test_custom_request.rs index 83a8d347f..66ee1ff99 100644 --- a/crates/rmcp/tests/test_custom_request.rs +++ b/crates/rmcp/tests/test_custom_request.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use std::sync::Arc; use rmcp::{ diff --git a/crates/rmcp/tests/test_logging.rs b/crates/rmcp/tests/test_logging.rs index 11efd84c9..c27cafbc5 100644 --- a/crates/rmcp/tests/test_logging.rs +++ b/crates/rmcp/tests/test_logging.rs @@ -1,4 +1,5 @@ // cargo test --features "server client" --package rmcp test_logging +#![cfg(not(feature = "local"))] mod common; use std::sync::{Arc, Mutex}; diff --git a/crates/rmcp/tests/test_message_protocol.rs b/crates/rmcp/tests/test_message_protocol.rs index 073486ff9..898040dbd 100644 --- a/crates/rmcp/tests/test_message_protocol.rs +++ b/crates/rmcp/tests/test_message_protocol.rs @@ -1,4 +1,5 @@ //cargo test --test test_message_protocol --features "client server" +#![cfg(not(feature = "local"))] mod common; use common::handlers::{TestClientHandler, TestServer}; diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index 7d930678e..662c4bd58 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use std::sync::Arc; use rmcp::{ diff --git a/crates/rmcp/tests/test_progress_subscriber.rs b/crates/rmcp/tests/test_progress_subscriber.rs index 092f35747..7bed457f4 100644 --- a/crates/rmcp/tests/test_progress_subscriber.rs +++ b/crates/rmcp/tests/test_progress_subscriber.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use futures::StreamExt; use rmcp::{ ClientHandler, Peer, RoleServer, ServerHandler, ServiceExt, diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs index a41d2e7e5..b7c0c442f 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] //cargo test --test test_prompt_macros --features "client server" #![allow(dead_code)] use std::sync::Arc; diff --git a/crates/rmcp/tests/test_prompt_routers.rs b/crates/rmcp/tests/test_prompt_routers.rs index 53b13b131..23674bd96 100644 --- a/crates/rmcp/tests/test_prompt_routers.rs +++ b/crates/rmcp/tests/test_prompt_routers.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use std::collections::HashMap; use futures::future::BoxFuture; diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 02da06cf7..62bba1123 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] mod common; use anyhow::Result; diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index 88a8e45b2..c240e4256 100644 --- a/crates/rmcp/tests/test_server_initialization.rs +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -1,5 +1,5 @@ // cargo test --features "client" --package rmcp -- server_init -#![cfg(feature = "client")] +#![cfg(all(feature = "client", not(feature = "local")))] mod common; use common::handlers::TestServer; diff --git a/crates/rmcp/tests/test_sse_concurrent_streams.rs b/crates/rmcp/tests/test_sse_concurrent_streams.rs index 33625a741..a7821fe9d 100644 --- a/crates/rmcp/tests/test_sse_concurrent_streams.rs +++ b/crates/rmcp/tests/test_sse_concurrent_streams.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] /// Tests for concurrent SSE stream handling (shadow channels) /// /// These tests verify that multiple GET SSE streams on the same session diff --git a/crates/rmcp/tests/test_streamable_http_json_response.rs b/crates/rmcp/tests/test_streamable_http_json_response.rs index e5b3323a9..b023acd06 100644 --- a/crates/rmcp/tests/test_streamable_http_json_response.rs +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use rmcp::transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }; diff --git a/crates/rmcp/tests/test_streamable_http_priming.rs b/crates/rmcp/tests/test_streamable_http_priming.rs index 778dfedff..5e771024c 100644 --- a/crates/rmcp/tests/test_streamable_http_priming.rs +++ b/crates/rmcp/tests/test_streamable_http_priming.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use std::time::Duration; use rmcp::transport::streamable_http_server::{ diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index 11f1a4da2..e33bf91ca 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -1,7 +1,8 @@ #![cfg(all( feature = "transport-streamable-http-client", feature = "transport-streamable-http-client-reqwest", - feature = "transport-streamable-http-server" + feature = "transport-streamable-http-server", + not(feature = "local") ))] use std::{collections::HashMap, sync::Arc}; diff --git a/crates/rmcp/tests/test_task_support_validation.rs b/crates/rmcp/tests/test_task_support_validation.rs index 88d2ed519..c0a65a9e0 100644 --- a/crates/rmcp/tests/test_task_support_validation.rs +++ b/crates/rmcp/tests/test_task_support_validation.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] //! Tests for task support validation in tool calls. //! //! Verifies that the server correctly validates `execution.taskSupport` settings diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index bd06ca6ea..450a00033 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] //! Test tool macros, including documentation for generated fns. //cargo test --test test_tool_macros --features "client server" diff --git a/crates/rmcp/tests/test_tool_routers.rs b/crates/rmcp/tests/test_tool_routers.rs index 987d1a0b1..c10665064 100644 --- a/crates/rmcp/tests/test_tool_routers.rs +++ b/crates/rmcp/tests/test_tool_routers.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use std::collections::HashMap; use futures::future::BoxFuture; diff --git a/crates/rmcp/tests/test_with_js.rs b/crates/rmcp/tests/test_with_js.rs index c1e5d81a6..685ea1430 100644 --- a/crates/rmcp/tests/test_with_js.rs +++ b/crates/rmcp/tests/test_with_js.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use rmcp::{ ServiceExt, service::QuitReason, diff --git a/crates/rmcp/tests/test_with_python.rs b/crates/rmcp/tests/test_with_python.rs index 3f883c96f..c905e1b5b 100644 --- a/crates/rmcp/tests/test_with_python.rs +++ b/crates/rmcp/tests/test_with_python.rs @@ -1,3 +1,4 @@ +#![cfg(not(feature = "local"))] use std::process::Stdio; use rmcp::{ From 66712db8080c4dafc8ca2a4e4211e2653ab9d8ef Mon Sep 17 00:00:00 2001 From: Warwick Date: Fri, 13 Mar 2026 14:51:02 -0500 Subject: [PATCH 101/333] fix(auth): redact secrets in Debug output for StoredCredentials and StoredAuthorizationState (#744) * fix(auth): redact secrets in Debug output for StoredCredentials and StoredAuthorizationState Removes `Debug` from the derive macros on `StoredCredentials` and `StoredAuthorizationState` and replaces them with manual `Debug` impls that print `[REDACTED]` for sensitive fields (access/refresh tokens, PKCE verifiers, and CSRF tokens), preventing accidental credential leakage via `{:?}` formatters, log calls, and error chains. Fixes #741 Co-Authored-By: Claude Sonnet 4.6 * test(auth): assert Debug output redacts secrets for credential types Adds regression tests for the fix in the previous commit, verifying that `{:?}` formatting of `StoredAuthorizationState` and `StoredCredentials` does not emit plaintext secrets. Co-Authored-By: Claude Sonnet 4.6 * test(auth): address review feedback on debug redaction tests - Remove redundant VendorExtraTokenFields from use super:: in test_stored_credentials_debug_redacts_token_response (already imported at module scope) - Add assert!(debug_output.contains("created_at")) to test_stored_authorization_state_debug_redacts_secrets to verify non-secret fields remain visible in Debug output - Run cargo fmt Co-Authored-By: Claude Sonnet 4.6 * Update crates/rmcp/src/transport/auth.rs Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> * fix: remaining formatting issue * fix: formatting * fix: formatting * fix: please --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp/src/transport/auth.rs | 66 ++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index a75b9ab54..9e048b1a2 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -59,7 +59,7 @@ impl<'c> AsyncHttpClient<'c> for OAuthReqwestClient { const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; /// Stored credentials for OAuth2 authorization -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct StoredCredentials { pub client_id: String, pub token_response: Option, @@ -69,6 +69,20 @@ pub struct StoredCredentials { pub token_received_at: Option, } +impl std::fmt::Debug for StoredCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredCredentials") + .field("client_id", &self.client_id) + .field( + "token_response", + &self.token_response.as_ref().map(|_| "[REDACTED]"), + ) + .field("granted_scopes", &self.granted_scopes) + .field("token_received_at", &self.token_received_at) + .finish() + } +} + /// Trait for storing and retrieving OAuth2 credentials /// /// Implementations of this trait can provide custom storage backends @@ -119,13 +133,23 @@ impl CredentialStore for InMemoryCredentialStore { } /// Stored authorization state for OAuth2 PKCE flow -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct StoredAuthorizationState { pub pkce_verifier: String, pub csrf_token: String, pub created_at: u64, } +impl std::fmt::Debug for StoredAuthorizationState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredAuthorizationState") + .field("pkce_verifier", &"[REDACTED]") + .field("csrf_token", &"[REDACTED]") + .field("created_at", &self.created_at) + .finish() + } +} + /// A transparent wrapper around a JSON object that captures any extra fields returned by the /// authorization server during token exchange that are not part of the standard OAuth 2.0 token /// response. @@ -2776,6 +2800,44 @@ mod tests { assert_eq!(deserialized.csrf_token, "my-csrf"); } + #[test] + fn test_stored_authorization_state_debug_redacts_secrets() { + let pkce = PkceCodeVerifier::new("super-secret-verifier".to_string()); + let csrf = CsrfToken::new("super-secret-csrf".to_string()); + let state = StoredAuthorizationState::new(&pkce, &csrf); + let debug_output = format!("{:?}", state); + + assert!(!debug_output.contains("super-secret-verifier")); + assert!(!debug_output.contains("super-secret-csrf")); + assert!(debug_output.contains("[REDACTED]")); + assert!(debug_output.contains("created_at")); + assert!(debug_output.contains("created_at")); + } + + #[test] + fn test_stored_credentials_debug_redacts_token_response() { + use oauth2::{AccessToken, basic::BasicTokenType}; + + use super::{OAuthTokenResponse, StoredCredentials}; + + let token_response = OAuthTokenResponse::new( + AccessToken::new("super-secret-access-token".to_string()), + BasicTokenType::Bearer, + VendorExtraTokenFields::default(), + ); + let creds = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(token_response), + granted_scopes: vec![], + token_received_at: None, + }; + let debug_output = format!("{:?}", creds); + + assert!(!debug_output.contains("super-secret-access-token")); + assert!(debug_output.contains("[REDACTED]")); + assert!(debug_output.contains("my-client")); + } + #[test] fn test_stored_authorization_state_into_pkce_verifier() { let pkce = PkceCodeVerifier::new("original-verifier".to_string()); From 44dfcf5550937fbf33c193077000fe9118e946e5 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:02:20 -0400 Subject: [PATCH 102/333] fix: default CallToolResult content to empty vec on missing field (#752) --- crates/rmcp/src/model.rs | 7 +++-- .../server_json_rpc_message_schema.json | 20 ++++++------- ...erver_json_rpc_message_schema_current.json | 20 ++++++------- crates/rmcp/tests/test_structured_output.rs | 28 +++++++++++++------ 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index f6cc5fb3e..fa47ea788 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2697,6 +2697,7 @@ pub type ElicitationCompletionNotification = #[non_exhaustive] pub struct CallToolResult { /// The content returned by the tool (text, images, etc.) + #[serde(default)] pub content: Vec, /// An optional JSON object that represents the structured result of the tool call #[serde(skip_serializing_if = "Option::is_none")] @@ -3247,16 +3248,16 @@ ts_union!( | ListResourcesResult | ListResourceTemplatesResult | ReadResourceResult - | CallToolResult | ListToolsResult | CreateElicitationResult - | EmptyResult | CreateTaskResult | ListTasksResult | GetTaskResult | CancelTaskResult - | CustomResult + | CallToolResult | GetTaskPayloadResult + | EmptyResult + | CustomResult ; ); diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 4fb0febf0..bd8f744b0 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -388,6 +388,7 @@ "content": { "description": "The content returned by the tool (text, images, etc.)", "type": "array", + "default": [], "items": { "$ref": "#/definitions/Annotated" } @@ -402,10 +403,7 @@ "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } - }, - "required": [ - "content" - ] + } }, "CancelTaskResult": { "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", @@ -2813,18 +2811,12 @@ { "$ref": "#/definitions/ReadResourceResult" }, - { - "$ref": "#/definitions/CallToolResult" - }, { "$ref": "#/definitions/ListToolsResult" }, { "$ref": "#/definitions/CreateElicitationResult" }, - { - "$ref": "#/definitions/EmptyObject" - }, { "$ref": "#/definitions/CreateTaskResult" }, @@ -2838,10 +2830,16 @@ "$ref": "#/definitions/CancelTaskResult" }, { - "$ref": "#/definitions/CustomResult" + "$ref": "#/definitions/CallToolResult" }, { "$ref": "#/definitions/GetTaskPayloadResult" + }, + { + "$ref": "#/definitions/EmptyObject" + }, + { + "$ref": "#/definitions/CustomResult" } ] }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 4fb0febf0..bd8f744b0 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -388,6 +388,7 @@ "content": { "description": "The content returned by the tool (text, images, etc.)", "type": "array", + "default": [], "items": { "$ref": "#/definitions/Annotated" } @@ -402,10 +403,7 @@ "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } - }, - "required": [ - "content" - ] + } }, "CancelTaskResult": { "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", @@ -2813,18 +2811,12 @@ { "$ref": "#/definitions/ReadResourceResult" }, - { - "$ref": "#/definitions/CallToolResult" - }, { "$ref": "#/definitions/ListToolsResult" }, { "$ref": "#/definitions/CreateElicitationResult" }, - { - "$ref": "#/definitions/EmptyObject" - }, { "$ref": "#/definitions/CreateTaskResult" }, @@ -2838,10 +2830,16 @@ "$ref": "#/definitions/CancelTaskResult" }, { - "$ref": "#/definitions/CustomResult" + "$ref": "#/definitions/CallToolResult" }, { "$ref": "#/definitions/GetTaskPayloadResult" + }, + { + "$ref": "#/definitions/EmptyObject" + }, + { + "$ref": "#/definitions/CustomResult" } ] }, diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index 082d3e439..b498d3120 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -298,18 +298,20 @@ async fn test_empty_content_array_with_is_error() { assert_eq!(result.is_error, Some(false)); } -#[tokio::test] -async fn test_missing_content_is_rejected() { +#[test] +fn test_missing_content_defaults_to_empty() { let raw = json!({ "isError": false }); - let result: Result = serde_json::from_value(raw); - assert!(result.is_err()); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert!(result.content.is_empty()); + assert_eq!(result.is_error, Some(false)); } -#[tokio::test] -async fn test_missing_content_with_structured_content_is_rejected() { +#[test] +fn test_missing_content_with_structured_content_deserializes() { let raw = json!({ "structuredContent": {"key": "value"}, "isError": false }); - let result: Result = serde_json::from_value(raw); - assert!(result.is_err()); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert!(result.content.is_empty()); + assert_eq!(result.structured_content.unwrap()["key"], "value"); } #[tokio::test] @@ -333,3 +335,13 @@ async fn test_empty_content_roundtrip() { let deserialized: CallToolResult = serde_json::from_value(v).unwrap(); assert_eq!(deserialized, result); } + +#[test] +fn test_call_tool_result_deserialize_without_content() { + let json = json!({ + "structuredContent": {"message": "Hello"} + }); + let result: CallToolResult = serde_json::from_value(json).unwrap(); + assert!(result.content.is_empty()); + assert!(result.structured_content.is_some()); +} From 55b478b0f4b49eb9a3d8f932b3f8dad9135c5c64 Mon Sep 17 00:00:00 2001 From: Axel Date: Tue, 17 Mar 2026 18:57:45 +0530 Subject: [PATCH 103/333] fix(rmcp): surface JSON-RPC error bodies on HTTP 4xx responses (#748) * fix(rmcp): surface JSON-RPC error bodies on HTTP 4xx responses When a server returns a 4xx status with Content-Type: application/json, attempt to deserialize the body as a ServerJsonRpcMessage before falling back to UnexpectedServerResponse. This allows JSON-RPC error payloads carried on HTTP error responses to be surfaced as McpError instead of being lost in a transport-level error string. Fixes #724 * fix(rmcp): surface JSON-RPC error bodies on HTTP 4xx responses When a server returns a 4xx status with Content-Type: application/json, attempt to deserialize the body as a ServerJsonRpcMessage before falling back to UnexpectedServerResponse. This allows JSON-RPC error payloads carried on HTTP error responses to be surfaced as McpError instead of being lost in a transport-level error string. Fixes #724 * fix(rmcp): only accept JsonRpcMessage::Error on non-success responses --- crates/rmcp/Cargo.toml | 5 + .../common/reqwest/streamable_http_client.rs | 82 ++++++++++-- .../test_streamable_http_4xx_error_body.rs | 121 ++++++++++++++++++ 3 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_4xx_error_body.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index b8677cb27..9c6133989 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -219,6 +219,11 @@ name = "test_streamable_http_json_response" required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] path = "tests/test_streamable_http_json_response.rs" +[[test]] +name = "test_streamable_http_4xx_error_body" +required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"] +path = "tests/test_streamable_http_4xx_error_body.rs" + [[test]] name = "test_custom_request" diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 8fca86fbc..fc37414e7 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -6,7 +6,7 @@ use reqwest::header::ACCEPT; use sse_stream::{Sse, SseStream}; use crate::{ - model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage}, transport::{ common::http_header::{ EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION, @@ -59,6 +59,15 @@ fn apply_custom_headers( Ok(builder) } +/// Attempts to parse `body` as a JSON-RPC error message. +/// Returns `None` if the body is not parseable or is not a `JsonRpcMessage::Error`. +fn parse_json_rpc_error(body: &str) -> Option { + match serde_json::from_str::(body) { + Ok(message @ JsonRpcMessage::Error(_)) => Some(message), + _ => None, + } +} + impl StreamableHttpClient for reqwest::Client { type Error = reqwest::Error; @@ -190,21 +199,40 @@ impl StreamableHttpClient for reqwest::Client { if status == reqwest::StatusCode::NOT_FOUND && session_was_attached { return Err(StreamableHttpError::SessionExpired); } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string()); + let session_id = response + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + // Non-success responses may carry valid JSON-RPC error payloads that + // should be surfaced as McpError rather than lost in TransportSend. if !status.is_success() { let body = response .text() .await .unwrap_or_else(|_| "".to_owned()); + if content_type + .as_deref() + .is_some_and(|ct| ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes())) + { + match parse_json_rpc_error(&body) { + Some(message) => { + return Ok(StreamableHttpPostResponse::Json(message, session_id)); + } + None => tracing::warn!( + "HTTP {status}: could not parse JSON body as a JSON-RPC error" + ), + } + } return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( format!("HTTP {status}: {body}"), ))); } - let content_type = response.headers().get(reqwest::header::CONTENT_TYPE); - let session_id = response.headers().get(HEADER_SESSION_ID); - let session_id = session_id - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - match content_type { + match content_type.as_deref() { Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) @@ -226,9 +254,7 @@ impl StreamableHttpClient for reqwest::Client { _ => { // unexpected content type tracing::error!("unexpected content type: {:?}", content_type); - Err(StreamableHttpError::UnexpectedContentType( - content_type.map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string()), - )) + Err(StreamableHttpError::UnexpectedContentType(content_type)) } } } @@ -308,8 +334,8 @@ fn extract_scope_from_header(header: &str) -> Option { #[cfg(test)] mod tests { - use super::extract_scope_from_header; - use crate::transport::streamable_http_client::InsufficientScopeError; + use super::{extract_scope_from_header, parse_json_rpc_error}; + use crate::{model::JsonRpcMessage, transport::streamable_http_client::InsufficientScopeError}; #[test] fn extract_scope_quoted() { @@ -356,4 +382,36 @@ mod tests { assert!(!without_scope.can_upgrade()); assert_eq!(without_scope.get_required_scope(), None); } + + #[test] + fn parse_json_rpc_error_returns_error_variant() { + let body = + r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#; + assert!(matches!( + parse_json_rpc_error(body), + Some(JsonRpcMessage::Error(_)) + )); + } + + #[test] + fn parse_json_rpc_error_rejects_non_error_request() { + // A valid JSON-RPC request (method + id) must not be accepted as an error. + let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; + assert!(parse_json_rpc_error(body).is_none()); + } + + #[test] + fn parse_json_rpc_error_rejects_notification() { + // A notification (method, no id) must not be accepted as an error. + let body = + r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"#; + assert!(parse_json_rpc_error(body).is_none()); + } + + #[test] + fn parse_json_rpc_error_rejects_malformed_json() { + assert!(parse_json_rpc_error("not json at all").is_none()); + assert!(parse_json_rpc_error("").is_none()); + assert!(parse_json_rpc_error(r#"{"broken":"#).is_none()); + } } diff --git a/crates/rmcp/tests/test_streamable_http_4xx_error_body.rs b/crates/rmcp/tests/test_streamable_http_4xx_error_body.rs new file mode 100644 index 000000000..ea49a4172 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_4xx_error_body.rs @@ -0,0 +1,121 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; + +/// Spin up a minimal axum server that always responds with the given status, +/// content-type, and body — no MCP logic involved. +async fn spawn_mock_server(status: u16, content_type: &'static str, body: &'static str) -> String { + use axum::{Router, body::Body, http::Response, routing::post}; + + let router = Router::new().route( + "/mcp", + post(move || async move { + Response::builder() + .status(status) + .header("content-type", content_type) + .body(Body::from(body)) + .unwrap() + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + format!("http://{addr}/mcp") +} + +fn ping_message() -> ClientJsonRpcMessage { + ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ) +} + +/// HTTP 4xx with Content-Type: application/json and a valid JSON-RPC error body +/// must be surfaced as `StreamableHttpPostResponse::Json`, not swallowed as a +/// transport error. +#[tokio::test] +async fn http_4xx_json_rpc_error_body_is_surfaced_as_json_response() { + let body = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#; + let url = spawn_mock_server(400, "application/json", body).await; + + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ping_message(), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Ok(StreamableHttpPostResponse::Json(msg, _)) => { + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["error"]["code"], -32600); + assert_eq!(json["error"]["message"], "Invalid Request"); + } + other => panic!("expected Json response, got: {other:?}"), + } +} + +/// HTTP 4xx with non-JSON content-type must still return `UnexpectedServerResponse` +/// (no regression on the original error path). +#[tokio::test] +async fn http_4xx_non_json_body_returns_unexpected_server_response() { + let url = spawn_mock_server(400, "text/plain", "Bad Request").await; + + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ping_message(), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Err(StreamableHttpError::UnexpectedServerResponse(_)) => {} + other => panic!("expected UnexpectedServerResponse, got: {other:?}"), + } +} + +/// HTTP 4xx with Content-Type: application/json but a body that is NOT a valid +/// JSON-RPC message must fall back to `UnexpectedServerResponse`. +#[tokio::test] +async fn http_4xx_malformed_json_body_falls_back_to_unexpected_server_response() { + let url = spawn_mock_server(400, "application/json", r#"{"error":"not jsonrpc"}"#).await; + + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ping_message(), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Err(StreamableHttpError::UnexpectedServerResponse(_)) => {} + other => panic!("expected UnexpectedServerResponse, got: {other:?}"), + } +} From d485249048b4e0c164b5a2f11e2d411d9a8efb85 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:37:32 -0400 Subject: [PATCH 104/333] fix: use cfg-gated Send+Sync supertraits to avoid semver break (#757) --- crates/rmcp/src/handler/server.rs | 417 +++++++++++++++--------------- crates/rmcp/src/service.rs | 44 +++- 2 files changed, 248 insertions(+), 213 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 7f21f8d63..8673a8bfd 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -4,8 +4,7 @@ use crate::{ error::ErrorData as McpError, model::{TaskSupport, *}, service::{ - MaybeSend, MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, - ServiceRole, + MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole, }, }; @@ -161,214 +160,226 @@ impl Service for H { } } -#[allow(unused_variables)] -#[allow( - private_bounds, - reason = "MaybeSend is a sealed conditional Send + Sync alias" -)] -pub trait ServerHandler: Sized + MaybeSend + 'static { - fn enqueue_task( - &self, - _request: CallToolRequestParams, - _context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::internal_error( - "Task processing not implemented".to_string(), - None, - ))) - } - fn ping( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(())) - } - // handle requests - fn initialize( - &self, - request: InitializeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - if context.peer.peer_info().is_none() { - context.peer.set_peer_info(request); +macro_rules! server_handler_methods { + () => { + fn enqueue_task( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::internal_error( + "Task processing not implemented".to_string(), + None, + ))) + } + fn ping( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(())) + } + // handle requests + fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + if context.peer.peer_info().is_none() { + context.peer.set_peer_info(request); + } + std::future::ready(Ok(self.get_info())) + } + fn complete( + &self, + request: CompleteRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(CompleteResult::default())) + } + fn set_level( + &self, + request: SetLevelRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + fn get_prompt( + &self, + request: GetPromptRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + fn list_prompts( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(ListPromptsResult::default())) + } + fn list_resources( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(ListResourcesResult::default())) + } + fn list_resource_templates( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + + MaybeSendFuture + + '_ { + std::future::ready(Ok(ListResourceTemplatesResult::default())) + } + fn read_resource( + &self, + request: ReadResourceRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err( + McpError::method_not_found::(), + )) + } + fn subscribe( + &self, + request: SubscribeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + fn unsubscribe( + &self, + request: UnsubscribeRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err( + McpError::method_not_found::(), + )) + } + fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } + fn list_tools( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(ListToolsResult::default())) + } + /// Get a tool definition by name. + /// + /// The default implementation returns `None`, which bypasses validation. + /// When using `#[tool_handler]`, this method is automatically implemented. + fn get_tool(&self, _name: &str) -> Option { + None + } + fn on_custom_request( + &self, + request: CustomRequest, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let CustomRequest { method, .. } = request; + let _ = context; + std::future::ready(Err(McpError::new( + ErrorCode::METHOD_NOT_FOUND, + method, + None, + ))) } - std::future::ready(Ok(self.get_info())) - } - fn complete( - &self, - request: CompleteRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(CompleteResult::default())) - } - fn set_level( - &self, - request: SetLevelRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - fn get_prompt( - &self, - request: GetPromptRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - fn list_prompts( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListPromptsResult::default())) - } - fn list_resources( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListResourcesResult::default())) - } - fn list_resource_templates( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ - { - std::future::ready(Ok(ListResourceTemplatesResult::default())) - } - fn read_resource( - &self, - request: ReadResourceRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err( - McpError::method_not_found::(), - )) - } - fn subscribe( - &self, - request: SubscribeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - fn unsubscribe( - &self, - request: UnsubscribeRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - fn list_tools( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListToolsResult::default())) - } - /// Get a tool definition by name. - /// - /// The default implementation returns `None`, which bypasses validation. - /// When using `#[tool_handler]`, this method is automatically implemented. - fn get_tool(&self, _name: &str) -> Option { - None - } - fn on_custom_request( - &self, - request: CustomRequest, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let CustomRequest { method, .. } = request; - let _ = context; - std::future::ready(Err(McpError::new( - ErrorCode::METHOD_NOT_FOUND, - method, - None, - ))) - } - fn on_cancelled( - &self, - notification: CancelledNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_progress( - &self, - notification: ProgressNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_initialized( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - tracing::info!("client initialized"); - std::future::ready(()) - } - fn on_roots_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_custom_notification( - &self, - notification: CustomNotification, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - let _ = (notification, context); - std::future::ready(()) - } + fn on_cancelled( + &self, + notification: CancelledNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_progress( + &self, + notification: ProgressNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_initialized( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + tracing::info!("client initialized"); + std::future::ready(()) + } + fn on_roots_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_custom_notification( + &self, + notification: CustomNotification, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + let _ = (notification, context); + std::future::ready(()) + } - fn get_info(&self) -> ServerInfo { - ServerInfo::default() - } + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } - fn list_tasks( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } + fn list_tasks( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(McpError::method_not_found::())) + } - fn get_task_info( - &self, - request: GetTaskInfoParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) - } + fn get_task_info( + &self, + request: GetTaskInfoParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } - fn get_task_result( - &self, - request: GetTaskResultParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) - } + fn get_task_result( + &self, + request: GetTaskResultParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } - fn cancel_task( - &self, - request: CancelTaskParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) - } + fn cancel_task( + &self, + request: CancelTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + }; +} + +#[allow(unused_variables)] +#[cfg(not(feature = "local"))] +pub trait ServerHandler: Sized + Send + Sync + 'static { + server_handler_methods!(); +} + +#[allow(unused_variables)] +#[cfg(feature = "local")] +pub trait ServerHandler: Sized + 'static { + server_handler_methods!(); } macro_rules! impl_server_handler_for_wrapper { diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index be9b461ab..95188cb96 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -128,11 +128,23 @@ pub type RxJsonRpcMessage = JsonRpcMessage< ::PeerNot, >; -#[allow( - private_bounds, - reason = "MaybeSend is a sealed conditional Send + Sync alias" -)] -pub trait Service: MaybeSend + 'static { +#[cfg(not(feature = "local"))] +pub trait Service: Send + Sync + 'static { + fn handle_request( + &self, + request: R::PeerReq, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_; + fn handle_notification( + &self, + notification: R::PeerNot, + context: NotificationContext, + ) -> impl Future> + MaybeSendFuture + '_; + fn get_info(&self) -> R::Info; +} + +#[cfg(feature = "local")] +pub trait Service: 'static { fn handle_request( &self, request: R::PeerReq, @@ -197,11 +209,23 @@ impl Service for Box> { } } -#[allow( - private_bounds, - reason = "MaybeSend is a sealed conditional Send + Sync alias" -)] -pub trait DynService: MaybeSend { +#[cfg(not(feature = "local"))] +pub trait DynService: Send + Sync { + fn handle_request( + &self, + request: R::PeerReq, + context: RequestContext, + ) -> MaybeBoxFuture<'_, Result>; + fn handle_notification( + &self, + notification: R::PeerNot, + context: NotificationContext, + ) -> MaybeBoxFuture<'_, Result<(), McpError>>; + fn get_info(&self) -> R::Info; +} + +#[cfg(feature = "local")] +pub trait DynService { fn handle_request( &self, request: R::PeerReq, From e709d0d084c74f6564222b4509f8fe204ba30a6f Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:37:51 -0400 Subject: [PATCH 105/333] fix: remove default type param from StreamableHttpService (#758) --- crates/rmcp/src/transport/streamable_http_server/tower.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 0e6f0789e..0130467df 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -185,7 +185,7 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box /// # todo!() /// } /// ``` -pub struct StreamableHttpService { +pub struct StreamableHttpService { pub config: StreamableHttpServerConfig, session_manager: Arc, service_factory: Arc Result + Send + Sync>, From 251ebec098fe3ffb1ae5d744a4776bbd27be5dd2 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:38:15 -0400 Subject: [PATCH 106/333] fix: drain in-flight responses on stdin EOF (#759) --- crates/rmcp/src/service.rs | 41 ++++- .../tests/test_inflight_response_drain.rs | 158 ++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 crates/rmcp/tests/test_inflight_response_drain.rs diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 95188cb96..3bad42519 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -773,6 +773,7 @@ where let mut transport = transport.into_transport(); let mut batch_messages = VecDeque::>::new(); let mut send_task_set = tokio::task::JoinSet::::new(); + let mut response_send_tasks = tokio::task::JoinSet::<()>::new(); #[derive(Debug)] enum SendTaskResult { Request { @@ -884,7 +885,7 @@ where } let send = transport.send(m); let current_span = tracing::Span::current(); - tokio::spawn(async move { + response_send_tasks.spawn(async move { let send_result = send.await; if let Err(error) = send_result { tracing::error!(%error, "fail to response message"); @@ -1032,6 +1033,44 @@ where } } }; + + // Drain in-flight handler responses before closing the transport. + // When stdin EOF or cancellation arrives, spawned handler tasks may still + // be finishing. We need to: + // 1. Wait for response sends that were already spawned in the main loop + // 2. Drain any remaining handler responses from the channel + let drain_timeout = match &quit_reason { + QuitReason::Closed => Some(Duration::from_secs(5)), + QuitReason::Cancelled => Some(Duration::from_secs(2)), + _ => None, + }; + if let Some(timeout_duration) = drain_timeout { + // Drop our sender so the channel closes once all handler task + // clones finish sending their responses (or are dropped). + drop(sink_proxy_tx); + let drain_result = tokio::time::timeout(timeout_duration, async { + // First, wait for any response sends already dispatched by the + // main loop (these hold transport write futures). + while let Some(result) = response_send_tasks.join_next().await { + if let Err(error) = result { + tracing::error!(%error, "response send task failed during drain"); + } + } + // Then drain any handler responses still in the channel + // (handlers that finished after the loop broke). + while let Some(m) = sink_proxy_rx.recv().await { + if let Err(error) = transport.send(m).await { + tracing::error!(%error, "failed to send pending response during drain"); + break; + } + } + }) + .await; + if drain_result.is_err() { + tracing::warn!("timed out draining in-flight responses"); + } + } + let sink_close_result = transport.close().await; if let Err(e) = sink_close_result { tracing::error!(%e, "fail to close sink"); diff --git a/crates/rmcp/tests/test_inflight_response_drain.rs b/crates/rmcp/tests/test_inflight_response_drain.rs new file mode 100644 index 000000000..b5fc160e2 --- /dev/null +++ b/crates/rmcp/tests/test_inflight_response_drain.rs @@ -0,0 +1,158 @@ +#![cfg(not(feature = "local"))] +// cargo test --test test_inflight_response_drain --features "client server" + +use std::{ + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll}, + time::Duration, +}; + +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, + service::QuitReason, + tool, tool_handler, tool_router, +}; +use tokio::io::{AsyncRead, ReadBuf}; + +// A slow tool server that sleeps before returning a response. +#[derive(Debug, Clone)] +struct SlowToolServer { + tool_router: ToolRouter, +} + +impl SlowToolServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct SlowToolRequest { + #[schemars(description = "how long to sleep in milliseconds")] + sleep_ms: u64, +} + +#[tool_router] +impl SlowToolServer { + #[tool(description = "A tool that sleeps then returns")] + async fn slow_tool( + &self, + Parameters(SlowToolRequest { sleep_ms }): Parameters, + ) -> String { + tokio::time::sleep(Duration::from_millis(sleep_ms)).await; + format!("done after {}ms", sleep_ms) + } +} + +#[tool_handler] +impl ServerHandler for SlowToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } +} + +#[derive(Debug, Clone, Default)] +struct DummyClientHandler; + +impl rmcp::ClientHandler for DummyClientHandler { + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +/// An `AsyncRead` wrapper that delegates to the inner reader until signalled, +/// then returns EOF (read 0 bytes). +struct ClosableReader { + inner: R, + eof_flag: Arc, +} + +impl AsyncRead for ClosableReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.eof_flag.load(Ordering::Acquire) { + return Poll::Ready(Ok(())); + } + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +/// When the server's input stream returns EOF while a tool handler is still +/// in-flight, the drain phase should flush pending responses before closing. +#[tokio::test] +async fn test_inflight_response_drain_on_eof() -> anyhow::Result<()> { + // Two unidirectional channels: + // client_write → server_read (client sends requests to server) + // server_write → client_read (server sends responses to client) + let (client_write, server_read) = tokio::io::duplex(4096); + let (server_write, client_read) = tokio::io::duplex(4096); + + // Wrap the server's read side so we can signal EOF from the test. + let eof_flag = Arc::new(AtomicBool::new(false)); + let closable_read = ClosableReader { + inner: server_read, + eof_flag: eof_flag.clone(), + }; + + let server_transport = (closable_read, server_write); + let client_transport = (client_read, client_write); + + // Start server with slow tool handler + let server_handle = tokio::spawn(async move { + let server = SlowToolServer::new(); + let running = server.serve(server_transport).await?; + let reason = running.waiting().await?; + assert!( + matches!(reason, QuitReason::Closed), + "expected Closed quit reason, got {:?}", + reason, + ); + anyhow::Ok(()) + }); + + // Start client + let client = DummyClientHandler.serve(client_transport).await?; + + // Call the slow tool (200ms sleep). Concurrently, signal the server's + // read side to return EOF after the request has been sent but before + // the handler finishes. + let tool_future = client.call_tool( + CallToolRequestParams::new("slow_tool").with_arguments( + serde_json::json!({ "sleep_ms": 200 }) + .as_object() + .unwrap() + .clone(), + ), + ); + + let (tool_result, _) = tokio::join!(tool_future, async { + // Wait for the request to be sent and received by the server, + // then signal EOF on the server's read side. + tokio::time::sleep(Duration::from_millis(50)).await; + eof_flag.store(true, Ordering::Release); + }); + + // The tool result should still arrive thanks to the drain phase. + let result = tool_result?; + let text = result + .content + .first() + .and_then(|c| c.raw.as_text()) + .map(|t| t.text.as_str()) + .expect("expected text content in tool result"); + assert_eq!(text, "done after 200ms"); + + server_handle.await??; + Ok(()) +} From 3ea8c3c55578f8214c22030995dfe8deccab60e4 Mon Sep 17 00:00:00 2001 From: Guy Lichtman <1395797+glicht@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:05:40 +0200 Subject: [PATCH 107/333] feat: add configuration for transparent session re-init (#760) * feat: add configuration for transparent session re-init * fix: in ci revert running tests without local until all tests pass * fix: pr comments * fix: documentation --- crates/rmcp/Cargo.toml | 12 + .../src/transport/streamable_http_client.rs | 215 ++++++++++-------- .../test_streamable_http_stale_session.rs | 71 +++++- 3 files changed, 203 insertions(+), 95 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 9c6133989..0f7446e4b 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -265,3 +265,15 @@ path = "tests/test_sse_concurrent_streams.rs" name = "test_client_credentials" required-features = ["auth"] path = "tests/test_client_credentials.rs" + +[[test]] +name = "test_streamable_http_stale_session" +required-features = [ + "server", + "client", + "transport-streamable-http-server", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest" +] +path = "tests/test_streamable_http_stale_session.rs" + diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index bbb98bf38..9a27b4935 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -600,48 +600,51 @@ impl Worker for StreamableHttpClientWorker { .await; let send_result = match response { Err(StreamableHttpError::SessionExpired) => { - // The server discarded the session (HTTP 404). Perform a - // fresh handshake once and replay the original message. - tracing::info!( - "session expired (HTTP 404), attempting transparent re-initialization" - ); - match Self::perform_reinitialization( - self.client.clone(), - saved_init_request.clone(), - config.uri.clone(), - config.auth_header.clone(), - config.custom_headers.clone(), - ) - .await - { - Ok((new_session_id, new_protocol_headers)) => { - // Old streams hold the stale session ID; abort them - // so the new standalone SSE stream takes over. - streams.abort_all(); + if !config.reinit_on_expired_session { + Err(StreamableHttpError::SessionExpired) + } else { + // The server discarded the session (HTTP 404). Perform a + // fresh handshake once and replay the original message. + tracing::info!( + "session expired (HTTP 404), attempting transparent re-initialization" + ); + match Self::perform_reinitialization( + self.client.clone(), + saved_init_request.clone(), + config.uri.clone(), + config.auth_header.clone(), + config.custom_headers.clone(), + ) + .await + { + Ok((new_session_id, new_protocol_headers)) => { + // Old streams hold the stale session ID; abort them + // so the new standalone SSE stream takes over. + streams.abort_all(); - session_id = new_session_id; - protocol_headers = new_protocol_headers; - session_cleanup_info = - session_id.as_ref().map(|sid| SessionCleanupInfo { - client: self.client.clone(), - uri: config.uri.clone(), - session_id: sid.clone(), - auth_header: config.auth_header.clone(), - protocol_headers: protocol_headers.clone(), - }); + session_id = new_session_id; + protocol_headers = new_protocol_headers; + session_cleanup_info = + session_id.as_ref().map(|sid| SessionCleanupInfo { + client: self.client.clone(), + uri: config.uri.clone(), + session_id: sid.clone(), + auth_header: config.auth_header.clone(), + protocol_headers: protocol_headers.clone(), + }); - if let Some(new_sid) = &session_id { - let client = self.client.clone(); - let uri = config.uri.clone(); - let new_sid = new_sid.clone(); - let auth_header = config.auth_header.clone(); - let retry_config = self.config.retry_config.clone(); - let sse_tx = sse_worker_tx.clone(); - let task_ct = transport_task_ct.clone(); - let config_uri = config.uri.clone(); - let config_auth = config.auth_header.clone(); - let spawn_headers = protocol_headers.clone(); - streams.spawn(async move { + if let Some(new_sid) = &session_id { + let client = self.client.clone(); + let uri = config.uri.clone(); + let new_sid = new_sid.clone(); + let auth_header = config.auth_header.clone(); + let retry_config = self.config.retry_config.clone(); + let sse_tx = sse_worker_tx.clone(); + let task_ct = transport_task_ct.clone(); + let config_uri = config.uri.clone(); + let config_auth = config.auth_header.clone(); + let spawn_headers = protocol_headers.clone(); + streams.spawn(async move { match client .get_stream( uri, @@ -686,69 +689,71 @@ impl Worker for StreamableHttpClientWorker { } } }); - } - - let retry_response = self - .client - .post_message( - config.uri.clone(), - message, - session_id.clone(), - config.auth_header.clone(), - protocol_headers.clone(), - ) - .await; - match retry_response { - Err(e) => Err(e), - Ok(StreamableHttpPostResponse::Accepted) => { - tracing::trace!( - "client message accepted after re-init" - ); - Ok(()) - } - Ok(StreamableHttpPostResponse::Json(msg, ..)) => { - context.send_to_handler(msg).await?; - Ok(()) } - Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { - if let Some(sid) = &session_id { - let sse_stream = SseAutoReconnectStream::new( - stream, - StreamableHttpClientReconnect { - client: self.client.clone(), - session_id: sid.clone(), - uri: config.uri.clone(), - auth_header: config.auth_header.clone(), - custom_headers: protocol_headers.clone(), - }, - self.config.retry_config.clone(), + + let retry_response = self + .client + .post_message( + config.uri.clone(), + message, + session_id.clone(), + config.auth_header.clone(), + protocol_headers.clone(), + ) + .await; + match retry_response { + Err(e) => Err(e), + Ok(StreamableHttpPostResponse::Accepted) => { + tracing::trace!( + "client message accepted after re-init" ); - streams.spawn(Self::execute_sse_stream( - sse_stream, - sse_worker_tx.clone(), - true, - transport_task_ct.child_token(), - )); - } else { - let sse_stream = + Ok(()) + } + Ok(StreamableHttpPostResponse::Json(msg, ..)) => { + context.send_to_handler(msg).await?; + Ok(()) + } + Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { + if let Some(sid) = &session_id { + let sse_stream = SseAutoReconnectStream::new( + stream, + StreamableHttpClientReconnect { + client: self.client.clone(), + session_id: sid.clone(), + uri: config.uri.clone(), + auth_header: config.auth_header.clone(), + custom_headers: protocol_headers + .clone(), + }, + self.config.retry_config.clone(), + ); + streams.spawn(Self::execute_sse_stream( + sse_stream, + sse_worker_tx.clone(), + true, + transport_task_ct.child_token(), + )); + } else { + let sse_stream = SseAutoReconnectStream::never_reconnect( stream, StreamableHttpError::::UnexpectedEndOfStream, ); - streams.spawn(Self::execute_sse_stream( - sse_stream, - sse_worker_tx.clone(), - true, - transport_task_ct.child_token(), - )); + streams.spawn(Self::execute_sse_stream( + sse_stream, + sse_worker_tx.clone(), + true, + transport_task_ct.child_token(), + )); + } + tracing::trace!("got new sse stream after re-init"); + Ok(()) } - tracing::trace!("got new sse stream after re-init"); - Ok(()) } } + Err(reinit_err) => Err(reinit_err), } - Err(reinit_err) => Err(reinit_err), - } + } // else enable_reinit_on_expired_session } Err(e) => Err(e), Ok(StreamableHttpPostResponse::Accepted) => { @@ -1051,6 +1056,16 @@ pub struct StreamableHttpClientTransportConfig { pub auth_header: Option, /// Custom HTTP headers to include with every request pub custom_headers: HashMap, + /// Enables transparent recovery when the server reports an expired session (`HTTP 404`). + /// + /// When enabled, the transport performs one automatic recovery attempt: + /// 1. Replays the original `initialize` handshake to create a new session. + /// 2. Re-establishes streaming state for that session. + /// 3. Retries the in-flight request that failed with `SessionExpired`. + /// + /// This recovery is best-effort and bounded to a single attempt. If recovery fails, + /// the original failure path is preserved and the error is returned to the caller. + pub reinit_on_expired_session: bool, } impl StreamableHttpClientTransportConfig { @@ -1098,6 +1113,19 @@ impl StreamableHttpClientTransportConfig { self.custom_headers = custom_headers; self } + + /// Set whether the transport should attempt transparent re-initialization on session expiration + /// See [`Self::reinit_on_expired_session`] for details. + /// # Example + /// ```rust,no_run + /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; + /// let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8000") + /// .reinit_on_expired_session(true); + /// ``` + pub fn reinit_on_expired_session(mut self, enable: bool) -> Self { + self.reinit_on_expired_session = enable; + self + } } impl Default for StreamableHttpClientTransportConfig { @@ -1109,6 +1137,7 @@ impl Default for StreamableHttpClientTransportConfig { allow_stateless: true, auth_header: None, custom_headers: HashMap::new(), + reinit_on_expired_session: true, } } } diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index e33bf91ca..b385cc52b 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -8,7 +8,7 @@ use std::{collections::HashMap, sync::Arc}; use rmcp::{ - ServiceExt, + ServiceError, ServiceExt, model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, transport::{ StreamableHttpClientTransport, @@ -126,7 +126,8 @@ async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result // Connect a full client transport (this performs initialize + notifications/initialized) let transport = StreamableHttpClientTransport::from_config( - StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) + .reinit_on_expired_session(true), ); let client = ().serve(transport).await?; @@ -171,3 +172,69 @@ async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result Ok(()) } + +/// Verify that when `reinit_on_expired_session` is false and the server loses the session, +/// the client receives a `SessionExpired` transport error instead of retrying. +#[tokio::test] +async fn test_session_expired_error_when_reinit_disabled() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let session_manager = Arc::new(LocalSessionManager::default()); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig { + stateful_mode: true, + sse_keep_alive: None, + cancellation_token: ct.child_token(), + ..Default::default() + }, + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let server_handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")) + .reinit_on_expired_session(false), + ); + let client = ().serve(transport).await?; + + // Verify the session is established + let _resources = client.list_all_resources().await?; + + // Force session expiry by removing all sessions from the server-side manager + { + let mut sessions = session_manager.sessions.write().await; + sessions.clear(); + } + + // This call should fail with a SessionExpired transport error + let result = client.list_all_resources().await; + match result { + Err(ServiceError::TransportSend(ref dyn_err)) => { + let err_msg = format!("{dyn_err}"); + assert!( + err_msg.contains("Session expired"), + "expected 'Session expired' in error message, got: {err_msg}" + ); + } + other => panic!("expected TransportSend(SessionExpired), got: {other:?}"), + } + + let _ = client.cancel().await; + ct.cancel(); + server_handle.await?; + + Ok(()) +} From 30cdc38c9a7d5604e39b9fd10d3d772dac2d14cb Mon Sep 17 00:00:00 2001 From: jokemanfire Date: Mon, 23 Mar 2026 09:01:49 +0800 Subject: [PATCH 108/333] chore: remove the rig example (#763) The rig official has the mcp example, we need not to keep it just give the link. Signed-off-by: jokemanfire --- examples/README.md | 2 +- examples/rig-integration/Cargo.toml | 34 ----- examples/rig-integration/config.toml | 10 -- examples/rig-integration/src/chat.rs | 134 -------------------- examples/rig-integration/src/config.rs | 20 --- examples/rig-integration/src/config/mcp.rs | 83 ------------ examples/rig-integration/src/main.rs | 69 ---------- examples/rig-integration/src/mcp_adaptor.rs | 119 ----------------- 8 files changed, 1 insertion(+), 470 deletions(-) delete mode 100644 examples/rig-integration/Cargo.toml delete mode 100644 examples/rig-integration/config.toml delete mode 100644 examples/rig-integration/src/chat.rs delete mode 100644 examples/rig-integration/src/config.rs delete mode 100644 examples/rig-integration/src/config/mcp.rs delete mode 100644 examples/rig-integration/src/main.rs delete mode 100644 examples/rig-integration/src/mcp_adaptor.rs diff --git a/examples/README.md b/examples/README.md index 2b358aa10..b1985f147 100644 --- a/examples/README.md +++ b/examples/README.md @@ -70,7 +70,7 @@ see [servers/README.md](servers/README.md) # Integration -- [Rig](rig-integration) A stream chatbot with rig +- [Rig](https://github.com/0xPlaygrounds/rig/blob/main/rig/rig-core/examples/rmcp.rs) A stream chatbot with rig - [Simple Chat Client](simple-chat-client) A simple chat client implementation using the Model Context Protocol (MCP) SDK. # WASI diff --git a/examples/rig-integration/Cargo.toml b/examples/rig-integration/Cargo.toml deleted file mode 100644 index cfed3c4c1..000000000 --- a/examples/rig-integration/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "rig-integration" -edition = { workspace = true } -version = { workspace = true } -authors = { workspace = true } -license = { workspace = true } -repository = { workspace = true } -description = { workspace = true } -keywords = { workspace = true } -homepage = { workspace = true } -categories = { workspace = true } -readme = { workspace = true } -publish = false - -[dependencies] -rig-core = "0.32.0" -tokio = { version = "1", features = ["full"] } -rmcp = { workspace = true, features = [ - "client", - "transport-child-process", - "transport-streamable-http-client-reqwest" -] } -anyhow = "1.0" -serde_json = "1" -serde = { version = "1", features = ["derive"] } -toml = "1.0" -futures = "0.3" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = [ - "env-filter", - "std", - "fmt", -] } -tracing-appender = "0.2" diff --git a/examples/rig-integration/config.toml b/examples/rig-integration/config.toml deleted file mode 100644 index 1affe7aaa..000000000 --- a/examples/rig-integration/config.toml +++ /dev/null @@ -1,10 +0,0 @@ -deepseek_key = "" -cohere_key = "" - -[mcp] - -[[mcp.server]] -name = "git" -protocol = "stdio" -command = "uvx" -args = ["mcp-server-git"] diff --git a/examples/rig-integration/src/chat.rs b/examples/rig-integration/src/chat.rs deleted file mode 100644 index 13d28ab56..000000000 --- a/examples/rig-integration/src/chat.rs +++ /dev/null @@ -1,134 +0,0 @@ -use futures::StreamExt; -use rig::{ - agent::{Agent, MultiTurnStreamItem}, - completion::CompletionModel, - message::{Message, Text}, - streaming::{StreamedAssistantContent, StreamingChat}, -}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; - -pub async fn cli_chatbot(chatbot: Agent) -> anyhow::Result<()> -where - M: CompletionModel + 'static, - M::StreamingResponse: Send, -{ - let mut chat_log = vec![]; - - let mut output = BufWriter::new(tokio::io::stdout()); - let mut input = BufReader::new(tokio::io::stdin()); - output.write_all(b"Enter :q to quit\n").await?; - loop { - output.write_all(b"\x1b[32muser>\x1b[0m ").await?; - // Flush stdout to ensure the prompt appears before input - output.flush().await?; - let mut input_buf = String::new(); - input.read_line(&mut input_buf).await?; - // Remove the newline character from the input - let input = input_buf.trim(); - // Check for a command to exit - if input == ":q" { - break; - } - - tracing::info!(%input); - chat_log.push(Message::user(input)); - - let mut response = chatbot.stream_chat(input, chat_log.clone()).await; - stream_output_agent_start(&mut output).await?; - let mut message_buf = String::new(); - - while let Some(message) = response.next().await { - match message { - Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text( - Text { text }, - ))) => { - message_buf.push_str(&text); - output_agent(&text, &mut output).await?; - } - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::ToolCall { tool_call, .. }, - )) => { - let name = &tool_call.function.name; - let arguments = &tool_call.function.arguments; - stream_output_toolcall( - format!("Calling tool: {name} with args: {arguments}"), - &mut output, - ) - .await?; - } - Ok(MultiTurnStreamItem::StreamUserItem(user_content)) => { - // Tool results are streamed back as user items - stream_output_toolcall(format!("Tool result: {:?}", user_content), &mut output) - .await?; - } - Ok(MultiTurnStreamItem::FinalResponse(final_response)) => { - tracing::info!("Final response received: {:?}", final_response); - } - Ok(_) => { - // Handle other stream items (reasoning, deltas, etc.) - } - Err(error) => { - output_error(error, &mut output).await?; - } - } - } - - chat_log.push(Message::assistant(message_buf)); - stream_output_agent_finished(&mut output).await?; - } - - Ok(()) -} - -pub async fn output_error( - e: impl std::fmt::Display, - output: &mut BufWriter, -) -> std::io::Result<()> { - output - .write_all(b"\x1b[1;31m\xE2\x9D\x8C ERROR: \x1b[0m") - .await?; - output.write_all(e.to_string().as_bytes()).await?; - output.write_all(b"\n").await?; - output.flush().await?; - Ok(()) -} - -pub async fn output_agent( - content: impl std::fmt::Display, - output: &mut BufWriter, -) -> std::io::Result<()> { - output.write_all(content.to_string().as_bytes()).await?; - output.flush().await?; - Ok(()) -} - -pub async fn stream_output_toolcall( - content: impl std::fmt::Display, - output: &mut BufWriter, -) -> std::io::Result<()> { - output - .write_all(b"\x1b[1;33m\xF0\x9F\x9B\xA0 Tool Call: \x1b[0m") - .await?; - output.write_all(content.to_string().as_bytes()).await?; - output.write_all(b"\n").await?; - output.flush().await?; - Ok(()) -} - -pub async fn stream_output_agent_start( - output: &mut BufWriter, -) -> std::io::Result<()> { - output - .write_all(b"\x1b[1;34m\xF0\x9F\xA4\x96 Agent: \x1b[0m") - .await?; - output.flush().await?; - Ok(()) -} - -pub async fn stream_output_agent_finished( - output: &mut BufWriter, -) -> std::io::Result<()> { - output.write_all(b"\n").await?; - output.flush().await?; - Ok(()) -} diff --git a/examples/rig-integration/src/config.rs b/examples/rig-integration/src/config.rs deleted file mode 100644 index 387a4f686..000000000 --- a/examples/rig-integration/src/config.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::path::Path; - -use serde::{Deserialize, Serialize}; - -pub mod mcp; - -#[derive(Debug, Deserialize, Serialize)] -pub struct Config { - pub mcp: mcp::McpConfig, - pub deepseek_key: Option, - pub cohere_key: Option, -} - -impl Config { - pub async fn retrieve(path: impl AsRef) -> anyhow::Result { - let content = tokio::fs::read_to_string(path).await?; - let config: Self = toml::from_str(&content)?; - Ok(config) - } -} diff --git a/examples/rig-integration/src/config/mcp.rs b/examples/rig-integration/src/config/mcp.rs deleted file mode 100644 index 45e4c23ca..000000000 --- a/examples/rig-integration/src/config/mcp.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::{collections::HashMap, process::Stdio}; - -use rmcp::{RoleClient, ServiceExt, service::RunningService, transport::ConfigureCommandExt}; -use serde::{Deserialize, Serialize}; - -use crate::mcp_adaptor::McpManager; -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct McpServerConfig { - name: String, - #[serde(flatten)] - transport: McpServerTransportConfig, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(tag = "protocol", rename_all = "lowercase")] -pub enum McpServerTransportConfig { - Streamable { - url: String, - }, - Stdio { - command: String, - #[serde(default)] - args: Vec, - #[serde(default)] - envs: HashMap, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct McpConfig { - server: Vec, -} - -impl McpConfig { - pub async fn create_manager(&self) -> anyhow::Result { - let mut clients = HashMap::new(); - let mut task_set = tokio::task::JoinSet::>::new(); - for server in &self.server { - let server = server.clone(); - task_set.spawn(async move { - let client = server.transport.start().await?; - anyhow::Result::Ok((server.name.clone(), client)) - }); - } - let start_up_result = task_set.join_all().await; - for result in start_up_result { - match result { - Ok((name, client)) => { - clients.insert(name, client); - } - Err(e) => { - eprintln!("Failed to start server: {:?}", e); - } - } - } - Ok(McpManager { clients }) - } -} - -impl McpServerTransportConfig { - pub async fn start(&self) -> anyhow::Result> { - let client = match self { - McpServerTransportConfig::Streamable { url } => { - let transport = - rmcp::transport::StreamableHttpClientTransport::from_uri(url.to_string()); - ().serve(transport).await? - } - McpServerTransportConfig::Stdio { - command, - args, - envs, - } => { - let transport = rmcp::transport::TokioChildProcess::new( - tokio::process::Command::new(command).configure(|cmd| { - cmd.args(args).envs(envs).stderr(Stdio::null()); - }), - )?; - ().serve(transport).await? - } - }; - Ok(client) - } -} diff --git a/examples/rig-integration/src/main.rs b/examples/rig-integration/src/main.rs deleted file mode 100644 index c9fe81190..000000000 --- a/examples/rig-integration/src/main.rs +++ /dev/null @@ -1,69 +0,0 @@ -use rig::{ - client::{CompletionClient, ProviderClient}, - embeddings::EmbeddingsBuilder, - providers::{cohere, deepseek}, - vector_store::in_memory_store::InMemoryVectorStore, -}; -use tracing_appender::rolling::{RollingFileAppender, Rotation}; -pub mod chat; -pub mod config; -pub mod mcp_adaptor; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let file_appender = RollingFileAppender::new( - Rotation::DAILY, - "logs", - format!("{}.log", env!("CARGO_CRATE_NAME")), - ); - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::from_default_env() - .add_directive(tracing::Level::INFO.into()), - ) - .with_writer(file_appender) - .with_file(false) - .with_ansi(false) - .init(); - - let config = config::Config::retrieve("config.toml").await?; - let deepseek_client = { - if let Some(key) = config.deepseek_key { - deepseek::Client::new(&key)? - } else { - deepseek::Client::from_env() - } - }; - let cohere_client = { - if let Some(key) = config.cohere_key { - cohere::Client::new(&key)? - } else { - cohere::Client::from_env() - } - }; - let mcp_manager = config.mcp.create_manager().await?; - tracing::info!( - "MCP Manager created, {} servers started", - mcp_manager.clients.len() - ); - let tool_set = mcp_manager.get_tool_set().await?; - let embedding_model = - cohere_client.embedding_model(cohere::EMBED_MULTILINGUAL_V3, "search_document"); - let embeddings = EmbeddingsBuilder::new(embedding_model.clone()) - .documents(tool_set.schemas()?)? - .build() - .await?; - let store = InMemoryVectorStore::from_documents_with_id_f(embeddings, |f| { - tracing::info!("store tool {}", f.name); - f.name.clone() - }); - let index = store.index(embedding_model); - let dpsk = deepseek_client - .agent(deepseek::DEEPSEEK_CHAT) - .dynamic_tools(4, index, tool_set) - .build(); - - chat::cli_chatbot(dpsk).await?; - - Ok(()) -} diff --git a/examples/rig-integration/src/mcp_adaptor.rs b/examples/rig-integration/src/mcp_adaptor.rs deleted file mode 100644 index 41de15768..000000000 --- a/examples/rig-integration/src/mcp_adaptor.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::collections::HashMap; - -use rig::tool::{ToolDyn as RigTool, ToolEmbeddingDyn, ToolSet}; -use rmcp::{ - RoleClient, - model::{CallToolRequestParams, CallToolResult, Tool as McpTool}, - service::{RunningService, ServerSink}, -}; - -pub struct McpToolAdaptor { - tool: McpTool, - server: ServerSink, -} - -impl RigTool for McpToolAdaptor { - fn name(&self) -> String { - self.tool.name.to_string() - } - - fn definition( - &self, - _prompt: String, - ) -> std::pin::Pin + Send + '_>> { - Box::pin(std::future::ready(rig::completion::ToolDefinition { - name: self.name(), - description: self - .tool - .description - .as_deref() - .unwrap_or_default() - .to_string(), - parameters: self.tool.schema_as_json_value(), - })) - } - - fn call( - &self, - args: String, - ) -> std::pin::Pin> + Send + '_>> - { - let server = self.server.clone(); - Box::pin(async move { - let call_mcp_tool_result = server - .call_tool( - CallToolRequestParams::new(self.tool.name.clone()).with_arguments( - serde_json::from_str(&args).map_err(rig::tool::ToolError::JsonError)?, - ), - ) - .await - .inspect(|result| tracing::info!(?result)) - .inspect_err(|error| tracing::error!(%error)) - .map_err(|e| rig::tool::ToolError::ToolCallError(Box::new(e)))?; - - Ok(convert_mcp_call_tool_result_to_string(call_mcp_tool_result)) - }) - } -} - -impl ToolEmbeddingDyn for McpToolAdaptor { - fn context(&self) -> serde_json::Result { - serde_json::to_value(self.tool.clone()) - } - - fn embedding_docs(&self) -> Vec { - vec![ - self.tool - .description - .as_deref() - .unwrap_or_default() - .to_string(), - ] - } -} - -pub struct McpManager { - pub clients: HashMap>, -} - -impl McpManager { - pub async fn get_tool_set(&self) -> anyhow::Result { - let mut tool_set = ToolSet::default(); - let mut task = tokio::task::JoinSet::>::new(); - for client in self.clients.values() { - let server = client.peer().clone(); - task.spawn(get_tool_set(server)); - } - let results = task.join_all().await; - for result in results { - match result { - Err(e) => { - tracing::error!(error = %e, "Failed to get tool set"); - } - Ok(tools) => { - tool_set.add_tools(tools); - } - } - } - Ok(tool_set) - } -} - -pub fn convert_mcp_call_tool_result_to_string(result: CallToolResult) -> String { - serde_json::to_string(&result).unwrap() -} - -pub async fn get_tool_set(server: ServerSink) -> anyhow::Result { - let tools = server.list_all_tools().await?; - let mut tool_builder = ToolSet::builder(); - for tool in tools { - tracing::info!("get tool: {}", tool.name); - let adaptor = McpToolAdaptor { - tool: tool.clone(), - server: server.clone(), - }; - tool_builder = tool_builder.dynamic_tool(adaptor); - } - let tool_set = tool_builder.build(); - Ok(tool_set) -} From c8c0c0cffc595e22d70b42daa8e2b05f142acd1a Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:20:55 -0400 Subject: [PATCH 109/333] fix: prevent CallToolResult and GetTaskPayloadResult from shadowing CustomResult in untagged enums (#771) The `#[serde(default)]` on `CallToolResult.content` (added in #752) made all fields optional, causing `CallToolResult` to greedily match any JSON object during `#[serde(untagged)]` deserialization of `ServerResult`. Similarly, `GetTaskPayloadResult(Value)` matched everything before `CustomResult(Value)` could be reached. Fix by replacing derived `Deserialize` impls with custom ones: - `CallToolResult`: require at least one known field to be present - `GetTaskPayloadResult`: always fail (indistinguishable from `CustomResult` in JSON; construct programmatically via `::new()`) --- crates/rmcp/src/model.rs | 44 ++++++++++++++++++++++++++++++++++- crates/rmcp/src/model/task.rs | 21 ++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index fa47ea788..482384354 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2691,7 +2691,7 @@ pub type ElicitationCompletionNotification = /// /// Contains the content returned by the tool execution and an optional /// flag indicating whether the operation resulted in an error. -#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Default, Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] @@ -2710,6 +2710,48 @@ pub struct CallToolResult { pub meta: Option, } +// Custom Deserialize implementation that: +// 1. Defaults `content` to `[]` when the field is missing (lenient per Postel's law) +// 2. Requires at least one known field to be present, so that `CallToolResult` doesn't +// greedily match arbitrary JSON objects when used inside `#[serde(untagged)]` enums +// (e.g. `ServerResult`), which would shadow `CustomResult`. +impl<'de> Deserialize<'de> for CallToolResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + content: Option>, + structured_content: Option, + is_error: Option, + #[serde(rename = "_meta")] + meta: Option, + } + + let helper = Helper::deserialize(deserializer)?; + + if helper.content.is_none() + && helper.structured_content.is_none() + && helper.is_error.is_none() + && helper.meta.is_none() + { + return Err(serde::de::Error::custom( + "expected at least one known CallToolResult field \ + (content, structuredContent, isError, or _meta)", + )); + } + + Ok(CallToolResult { + content: helper.content.unwrap_or_default(), + structured_content: helper.structured_content, + is_error: helper.is_error, + meta: helper.meta, + }) + } +} + impl CallToolResult { /// Create a successful tool result with unstructured content pub fn success(content: Vec) -> Self { diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index 8373aa243..343c925ef 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -123,7 +123,7 @@ pub struct GetTaskResult { /// (e.g., `CallToolResult` for `tools/call`). This is represented as /// an open object. The payload is the original request's result /// serialized as a JSON value. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetTaskPayloadResult(pub Value); @@ -135,6 +135,25 @@ impl GetTaskPayloadResult { } } +// Custom Deserialize that always fails, so that `GetTaskPayloadResult` is skipped +// during `#[serde(untagged)]` enum deserialization (e.g. `ServerResult`). +// The payload has the same JSON shape as `CustomResult(Value)`, so they are +// indistinguishable. `CustomResult` acts as the catch-all instead. +// `GetTaskPayloadResult` should be constructed programmatically via `::new()`. +impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Consume the value so the deserializer state stays consistent. + serde::de::IgnoredAny::deserialize(deserializer)?; + Err(serde::de::Error::custom( + "GetTaskPayloadResult cannot be deserialized directly; \ + use CustomResult as the catch-all", + )) + } +} + /// Response to a `tasks/cancel` request. /// /// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`. From f89e412200b7927e630beee2fa8e3794ac2e0010 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:23:28 -0400 Subject: [PATCH 110/333] chore(deps): update tokio-tungstenite requirement from 0.28.0 to 0.29.0 (#773) Updates the requirements on [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) to permit the latest version. - [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md) - [Commits](https://github.com/snapview/tokio-tungstenite/compare/v0.28.0...v0.29.0) --- updated-dependencies: - dependency-name: tokio-tungstenite dependency-version: 0.29.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/transport/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/transport/Cargo.toml b/examples/transport/Cargo.toml index bbb692521..716b32261 100644 --- a/examples/transport/Cargo.toml +++ b/examples/transport/Cargo.toml @@ -40,7 +40,7 @@ rand = { version = "0.10" } schemars = { version = "1.0", optional = true } hyper = { version = "1", features = ["client", "server", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } -tokio-tungstenite = "0.28.0" +tokio-tungstenite = "0.29.0" reqwest = { version = "0.13.2" } pin-project-lite = "0.2" From baf22d37bbdfd2ad7bdf572f6f48d447f34301f8 Mon Sep 17 00:00:00 2001 From: Guy Lichtman <1395797+glicht@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:03:16 +0200 Subject: [PATCH 111/333] chore: run all tests in ci without "local" feature (#761) --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++++++++++- justfile | 9 +++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79e1d4d02..1e329e10d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,41 @@ jobs: - name: Run tests run: cargo test --all-features - + + test-no-local: + name: Run Tests (no local feature) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # install nodejs + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Python + run: uv python install + + - name: Create venv for python + run: uv venv + + - uses: Swatinem/rust-cache@v2 + + - name: Run tests without local feature + run: | + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] + | select(startswith("__") | not) + | select(. != "local")] | join(",")') + cargo test -p rmcp --features "$FEATURES" + coverage: name: Code Coverage runs-on: ubuntu-latest diff --git a/justfile b/justfile index c7579358b..b0498faa8 100644 --- a/justfile +++ b/justfile @@ -10,6 +10,15 @@ fix: fmt test: cargo test --all-features + if command -v jq > /dev/null 2>&1; then \ + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] \ + | select(startswith("__") | not) \ + | select(. != "local")] | join(",")') && \ + cargo test -p rmcp --features "$FEATURES"; \ + else \ + echo "warning: jq not found, skipping non-local feature tests"; \ + fi cov: cargo llvm-cov --lcov --output-path {{justfile_directory()}}/target/llvm-cov-target/coverage.lcov \ No newline at end of file From a32a9c83a1d97eeefae59fe7b128d5405dd6d1c2 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 23 Mar 2026 17:15:23 -0700 Subject: [PATCH 112/333] feat(auth): implement SEP-2207 OIDC-flavored refresh token guidance (#676) * feat: implement sep-2207 refresh token guidance * fix: update client-metadata.json to allow refresh tokens --- client-metadata.json | 2 +- crates/rmcp/src/transport/auth.rs | 173 +++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/client-metadata.json b/client-metadata.json index 86d037534..0c289e1e4 100644 --- a/client-metadata.json +++ b/client-metadata.json @@ -1,7 +1,7 @@ { "client_id": "https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json", "redirect_uris": ["http://127.0.0.1:8080/callback"], - "grant_types": ["authorization_code"], + "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" } diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 9e048b1a2..051349d84 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -971,12 +971,23 @@ impl AuthorizationManager { attempts < self.scope_upgrade_config.max_upgrade_attempts } + /// select scopes to request from authorization server + pub fn select_scopes( + &self, + www_authenticate_scope: Option<&str>, + default_scopes: &[&str], + ) -> Vec { + let mut scopes = self.select_base_scopes(www_authenticate_scope, default_scopes); + self.add_offline_access_if_supported(&mut scopes); + scopes + } + /// select scopes based on SEP-835 priority: /// 1. scope from WWW-Authenticate header (argument or stored from initial 401 probe) /// 2. scopes_supported from protected resource metadata (RFC 9728) /// 3. scopes_supported from authorization server metadata /// 4. provided default scopes - pub fn select_scopes( + fn select_base_scopes( &self, www_authenticate_scope: Option<&str>, default_scopes: &[&str], @@ -1011,6 +1022,21 @@ impl AuthorizationManager { default_scopes.iter().map(|s| s.to_string()).collect() } + /// SEP-2207: when the AS advertises `offline_access` in `scopes_supported`, append + /// it so OIDC-flavored Authorization Servers will issue refresh tokens. + fn add_offline_access_if_supported(&self, scopes: &mut Vec) { + if scopes.is_empty() || scopes.iter().any(|s| s == "offline_access") { + return; + } + if let Some(metadata) = &self.metadata { + if let Some(supported) = &metadata.scopes_supported { + if supported.iter().any(|s| s == "offline_access") { + scopes.push("offline_access".to_string()); + } + } + } + } + /// attempt to upgrade scopes after receiving a 403 insufficient_scope error. /// returns the authorization URL for re-authorization with upgraded scopes. pub async fn request_scope_upgrade(&self, required_scope: &str) -> Result { @@ -1143,7 +1169,11 @@ impl AuthorizationManager { /// to avoid races between token retrieval and the actual HTTP request. const REFRESH_BUFFER_SECS: u64 = 30; - /// get access token, if expired, refresh it automatically + /// Get access token from local credential store. + /// If expired, refresh it automatically when a refresh token is available. + /// When the access token has expired and no refresh token is available (or + /// the refresh itself fails), returns [`AuthError::AuthorizationRequired`] + /// so the caller can re-authenticate. pub async fn get_access_token(&self) -> Result { let stored = self.credential_store.load().await?; let Some(stored_creds) = stored else { @@ -2275,7 +2305,9 @@ impl OAuthState { let selected_scopes: Vec = if scopes.is_empty() { manager.select_scopes(None, &[]) } else { - scopes.iter().map(|s| s.to_string()).collect() + let mut s: Vec = scopes.iter().map(|s| s.to_string()).collect(); + manager.add_offline_access_if_supported(&mut s); + s }; let scope_refs: Vec<&str> = selected_scopes.iter().map(|s| s.as_str()).collect(); debug!("start session"); @@ -3279,6 +3311,141 @@ mod tests { assert_eq!(result.len(), 2); } + // -- SEP-2207: offline_access -- + + #[tokio::test] + async fn select_scopes_adds_offline_access_when_as_supports_it() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + *mgr.resource_scopes.write().await = vec!["profile".to_string()]; + + let scopes = mgr.select_scopes(None, &[]); + assert!( + scopes.contains(&"offline_access".to_string()), + "offline_access should be added when AS supports it" + ); + assert!(scopes.contains(&"profile".to_string())); + } + + #[tokio::test] + async fn select_scopes_does_not_add_offline_access_when_as_does_not_support_it() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]), + ..Default::default() + })) + .await; + *mgr.resource_scopes.write().await = vec!["profile".to_string()]; + + let scopes = mgr.select_scopes(None, &[]); + assert!( + !scopes.contains(&"offline_access".to_string()), + "offline_access should not be added when AS does not support it" + ); + } + + #[tokio::test] + async fn select_scopes_falls_back_to_defaults() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: None, + ..Default::default() + })) + .await; + + let scopes = mgr.select_scopes(None, &["default_scope"]); + assert_eq!(scopes, vec!["default_scope".to_string()]); + } + + #[tokio::test] + async fn select_scopes_does_not_duplicate_offline_access() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + + // When AS metadata is the scope source and already contains offline_access, + // it should appear exactly once. + let scopes = mgr.select_scopes(None, &[]); + let count = scopes.iter().filter(|s| *s == "offline_access").count(); + assert_eq!(count, 1, "offline_access should not be duplicated"); + } + + #[tokio::test] + async fn select_scopes_adds_offline_access_to_www_authenticate_scopes() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + *mgr.www_auth_scopes.write().await = vec!["profile".to_string()]; + + let scopes = mgr.select_scopes(None, &[]); + assert!(scopes.contains(&"offline_access".to_string())); + assert!(scopes.contains(&"profile".to_string())); + } + + #[tokio::test] + async fn select_scopes_adds_offline_access_to_www_authenticate_argument() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + + let scopes = mgr.select_scopes(Some("profile email"), &[]); + assert!(scopes.contains(&"offline_access".to_string())); + assert!(scopes.contains(&"profile".to_string())); + assert!(scopes.contains(&"email".to_string())); + } + + #[tokio::test] + async fn add_offline_access_if_supported_works_with_explicit_scopes() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + + let mut explicit = vec!["read".to_string(), "write".to_string()]; + mgr.add_offline_access_if_supported(&mut explicit); + assert!(explicit.contains(&"offline_access".to_string())); + } + + #[tokio::test] + async fn add_offline_access_if_supported_skips_empty_scopes() { + let mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + + let mut empty: Vec = vec![]; + mgr.add_offline_access_if_supported(&mut empty); + assert!( + empty.is_empty(), + "offline_access should not be the only scope" + ); + } + #[test] fn scope_upgrade_config_default_values() { let config = ScopeUpgradeConfig::default(); From ee1c63c53f097388983d684c9fb80eb3106bf14c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 24 Mar 2026 09:50:32 -0400 Subject: [PATCH 113/333] feat(transport): add Unix domain socket client for streamable HTTP (#749) * feat(transport): add Unix domain socket client for streamable HTTP MCP hosts in Kubernetes environments with Envoy sidecars need to route HTTP through Unix domain sockets because DNS-based URIs only resolve via the proxy. Adds UnixSocketHttpClient implementing StreamableHttpClient using hyper over tokio::net::UnixStream, gated behind the transport-streamable-http-client-unix-socket feature. Also extracts RESERVED_HEADERS, extract_scope_from_header, and validate_custom_header into common/http_header.rs to share header validation logic between the reqwest and unix socket implementations. * fix(transport): address review feedback for unix socket transport - Document one-connection-per-request behavior on UnixSocketHttpClient - Reject empty socket paths and bare '@' in constructor with assert - Add explicit dep:http to unix-socket feature for self-documenting deps - Document MCP-Protocol-Version exception on RESERVED_HEADERS constant - Fix test catch-all to echo request id instead of hardcoding 1 - Remove leftover sleep(100ms) in test_unix_socket_custom_headers - Add blank line before macro comment in Cargo.toml * fix(transport): fix CI failures for unix socket transport - Use std::io::Error::other() instead of Error::new(ErrorKind::Other) to satisfy clippy::io_other_error on newer nightly - Use #[tokio::test(flavor = "current_thread")] for unix socket tests since axum's serve(UnixListener) requires spawn_local - Gate validate_custom_header behind client-side-sse feature since it references http::HeaderName which isn't available with default features * fix(transport): fix CI failures for unix socket transport axum::serve(UnixListener) uses spawn_local on Linux, which panics outside a LocalSet. Replace with manual hyper HTTP/1.1 server that accepts connections directly from the UnixListener, avoiding the spawn_local requirement entirely. * fix(transport): skip unix socket tests when local feature is enabled The local feature causes ().serve(transport) to use spawn_local, which requires a LocalSet. Gate the integration tests with not(feature = "local") to match every other integration test in the repo. --- crates/rmcp/Cargo.toml | 26 + crates/rmcp/src/transport.rs | 2 + crates/rmcp/src/transport/common.rs | 3 + .../rmcp/src/transport/common/http_header.rs | 119 ++++ .../common/reqwest/streamable_http_client.rs | 90 +-- .../rmcp/src/transport/common/unix_socket.rs | 545 ++++++++++++++++++ .../rmcp/tests/test_unix_socket_transport.rs | 298 ++++++++++ 7 files changed, 998 insertions(+), 85 deletions(-) create mode 100644 crates/rmcp/src/transport/common/unix_socket.rs create mode 100644 crates/rmcp/tests/test_unix_socket_transport.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 0f7446e4b..cbf02ea48 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -62,6 +62,11 @@ uuid = { version = "1", features = ["v4"], optional = true } http-body = { version = "1", optional = true } http-body-util = { version = "0.1", optional = true } bytes = { version = "1", optional = true } + +# for unix socket transport +hyper = { version = "1", features = ["client", "http1"], optional = true } +hyper-util = { version = "0.1", features = ["tokio"], optional = true } + # macro rmcp-macros = { workspace = true, optional = true } [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] @@ -112,6 +117,15 @@ client-side-sse = ["dep:sse-stream", "dep:http"] # Streamable HTTP client transport-streamable-http-client = ["client-side-sse", "transport-worker"] transport-streamable-http-client-reqwest = ["transport-streamable-http-client", "__reqwest"] +transport-streamable-http-client-unix-socket = [ + "transport-streamable-http-client", + "dep:hyper", + "dep:hyper-util", + "dep:http-body-util", + "dep:http", + "dep:bytes", + "tokio/net", +] transport-async-rw = ["tokio/io-util", "tokio-util/codec"] transport-io = ["transport-async-rw", "tokio/io-std"] @@ -139,6 +153,9 @@ schemars = ["dep:schemars"] tokio = { version = "1", features = ["full"] } schemars = { version = "1.1.0", features = ["chrono04"] } axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +tower-service = "0.3" url = "2.4" anyhow = "1.0" tracing-subscriber = { version = "0.3", features = [ @@ -266,6 +283,15 @@ name = "test_client_credentials" required-features = ["auth"] path = "tests/test_client_credentials.rs" +[[test]] +name = "test_unix_socket_transport" +required-features = [ + "client", + "server", + "transport-streamable-http-client-unix-socket", +] +path = "tests/test_unix_socket_transport.rs" + [[test]] name = "test_streamable_http_stale_session" required-features = [ diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 8a90542d8..2a11f4fed 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -112,6 +112,8 @@ pub use streamable_http_server::tower::{StreamableHttpServerConfig, StreamableHt #[cfg(feature = "transport-streamable-http-client")] pub mod streamable_http_client; +#[cfg(all(unix, feature = "transport-streamable-http-client-unix-socket"))] +pub use common::unix_socket::UnixSocketHttpClient; #[cfg(feature = "transport-streamable-http-client")] pub use streamable_http_client::StreamableHttpClientTransport; diff --git a/crates/rmcp/src/transport/common.rs b/crates/rmcp/src/transport/common.rs index 615b0e273..3691602b1 100644 --- a/crates/rmcp/src/transport/common.rs +++ b/crates/rmcp/src/transport/common.rs @@ -14,3 +14,6 @@ pub mod client_side_sse; #[cfg(feature = "auth")] pub mod auth; + +#[cfg(all(unix, feature = "transport-streamable-http-client-unix-socket"))] +pub mod unix_socket; diff --git a/crates/rmcp/src/transport/common/http_header.rs b/crates/rmcp/src/transport/common/http_header.rs index 441753260..196d96fff 100644 --- a/crates/rmcp/src/transport/common/http_header.rs +++ b/crates/rmcp/src/transport/common/http_header.rs @@ -3,3 +3,122 @@ pub const HEADER_LAST_EVENT_ID: &str = "Last-Event-Id"; pub const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version"; pub const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; pub const JSON_MIME_TYPE: &str = "application/json"; + +/// Reserved headers that must not be overridden by user-supplied custom headers. +/// `MCP-Protocol-Version` is in this list but is allowed through because the worker +/// injects it after initialization. +pub(crate) const RESERVED_HEADERS: &[&str] = &[ + "accept", + HEADER_SESSION_ID, + HEADER_MCP_PROTOCOL_VERSION, // allowed through by validate_custom_header; worker injects it post-init + HEADER_LAST_EVENT_ID, +]; + +/// Checks whether a custom header name is allowed. +/// Returns `Ok(())` if allowed, `Err(name)` if rejected as reserved. +/// `MCP-Protocol-Version` is reserved but allowed through (the worker injects it post-init). +#[cfg(feature = "client-side-sse")] +pub(crate) fn validate_custom_header(name: &http::HeaderName) -> Result<(), String> { + if RESERVED_HEADERS + .iter() + .any(|&r| name.as_str().eq_ignore_ascii_case(r)) + { + if name + .as_str() + .eq_ignore_ascii_case(HEADER_MCP_PROTOCOL_VERSION) + { + return Ok(()); + } + return Err(name.to_string()); + } + Ok(()) +} + +/// Extracts the `scope=` parameter from a `WWW-Authenticate` header value. +/// Handles both quoted (`scope="files:read files:write"`) and unquoted (`scope=read:data`) forms. +pub(crate) fn extract_scope_from_header(header: &str) -> Option { + let header_lowercase = header.to_ascii_lowercase(); + let scope_key = "scope="; + + if let Some(pos) = header_lowercase.find(scope_key) { + let start = pos + scope_key.len(); + let value_slice = &header[start..]; + + if let Some(stripped) = value_slice.strip_prefix('"') { + if let Some(end_quote) = stripped.find('"') { + return Some(stripped[..end_quote].to_string()); + } + } else { + let end = value_slice + .find(|c: char| c == ',' || c == ';' || c.is_whitespace()) + .unwrap_or(value_slice.len()); + if end > 0 { + return Some(value_slice[..end].to_string()); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_scope_quoted() { + let header = r#"Bearer error="insufficient_scope", scope="files:read files:write""#; + assert_eq!( + extract_scope_from_header(header), + Some("files:read files:write".to_string()) + ); + } + + #[test] + fn extract_scope_unquoted() { + let header = r#"Bearer scope=read:data, error="insufficient_scope""#; + assert_eq!( + extract_scope_from_header(header), + Some("read:data".to_string()) + ); + } + + #[test] + fn extract_scope_missing() { + let header = r#"Bearer error="invalid_token""#; + assert_eq!(extract_scope_from_header(header), None); + } + + #[test] + fn extract_scope_empty_header() { + assert_eq!(extract_scope_from_header("Bearer"), None); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn validate_rejects_reserved_accept() { + let name = http::HeaderName::from_static("accept"); + assert!(validate_custom_header(&name).is_err()); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn validate_rejects_reserved_session_id() { + let name = http::HeaderName::from_static("mcp-session-id"); + assert!(validate_custom_header(&name).is_err()); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn validate_allows_mcp_protocol_version() { + let name = http::HeaderName::from_static("mcp-protocol-version"); + assert!(validate_custom_header(&name).is_ok()); + } + + #[cfg(feature = "client-side-sse")] + #[test] + fn validate_allows_custom_header() { + let name = http::HeaderName::from_static("x-custom"); + assert!(validate_custom_header(&name).is_ok()); + } +} diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index fc37414e7..dea98c7b9 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -9,8 +9,8 @@ use crate::{ model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage}, transport::{ common::http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION, - HEADER_SESSION_ID, JSON_MIME_TYPE, + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + extract_scope_from_header, validate_custom_header, }, streamable_http_client::*, }, @@ -22,38 +22,13 @@ impl From for StreamableHttpError { } } -/// Reserved headers that must not be overridden by user-supplied custom headers. -/// `MCP-Protocol-Version` is in this list but is allowed through because the worker -/// injects it after initialization. -const RESERVED_HEADERS: &[&str] = &[ - "accept", - HEADER_SESSION_ID, - HEADER_MCP_PROTOCOL_VERSION, - HEADER_LAST_EVENT_ID, -]; - -/// Applies custom headers to a request builder, rejecting reserved headers -/// except `MCP-Protocol-Version` (which the worker injects after init). +/// Applies custom headers to a request builder, rejecting reserved headers. fn apply_custom_headers( mut builder: reqwest::RequestBuilder, custom_headers: HashMap, ) -> Result> { for (name, value) in custom_headers { - if RESERVED_HEADERS - .iter() - .any(|&r| name.as_str().eq_ignore_ascii_case(r)) - { - if name - .as_str() - .eq_ignore_ascii_case(HEADER_MCP_PROTOCOL_VERSION) - { - builder = builder.header(name, value); - continue; - } - return Err(StreamableHttpError::ReservedHeaderConflict( - name.to_string(), - )); - } + validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?; builder = builder.header(name, value); } Ok(builder) @@ -306,66 +281,11 @@ impl StreamableHttpClientTransport { } } -/// extract scope parameter from WWW-Authenticate header -fn extract_scope_from_header(header: &str) -> Option { - let header_lowercase = header.to_ascii_lowercase(); - let scope_key = "scope="; - - if let Some(pos) = header_lowercase.find(scope_key) { - let start = pos + scope_key.len(); - let value_slice = &header[start..]; - - if let Some(stripped) = value_slice.strip_prefix('"') { - if let Some(end_quote) = stripped.find('"') { - return Some(stripped[..end_quote].to_string()); - } - } else { - let end = value_slice - .find(|c: char| c == ',' || c == ';' || c.is_whitespace()) - .unwrap_or(value_slice.len()); - if end > 0 { - return Some(value_slice[..end].to_string()); - } - } - } - - None -} - #[cfg(test)] mod tests { - use super::{extract_scope_from_header, parse_json_rpc_error}; + use super::parse_json_rpc_error; use crate::{model::JsonRpcMessage, transport::streamable_http_client::InsufficientScopeError}; - #[test] - fn extract_scope_quoted() { - let header = r#"Bearer error="insufficient_scope", scope="files:read files:write""#; - assert_eq!( - extract_scope_from_header(header), - Some("files:read files:write".to_string()) - ); - } - - #[test] - fn extract_scope_unquoted() { - let header = r#"Bearer scope=read:data, error="insufficient_scope""#; - assert_eq!( - extract_scope_from_header(header), - Some("read:data".to_string()) - ); - } - - #[test] - fn extract_scope_missing() { - let header = r#"Bearer error="invalid_token""#; - assert_eq!(extract_scope_from_header(header), None); - } - - #[test] - fn extract_scope_empty_header() { - assert_eq!(extract_scope_from_header("Bearer"), None); - } - #[test] fn insufficient_scope_error_can_upgrade() { let with_scope = InsufficientScopeError { diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs new file mode 100644 index 000000000..3af987973 --- /dev/null +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -0,0 +1,545 @@ +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + +use bytes::Bytes; +use futures::{StreamExt, stream::BoxStream}; +use http::{HeaderName, HeaderValue, Method, Request, StatusCode, header::WWW_AUTHENTICATE}; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use sse_stream::{Sse, SseStream}; +use tokio::net::UnixStream; + +use crate::{ + model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + transport::{ + common::http_header::{ + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + extract_scope_from_header, validate_custom_header, + }, + streamable_http_client::*, + }, +}; + +#[derive(Debug, thiserror::Error)] +pub enum UnixSocketError { + #[error("hyper error: {0}")] + Hyper(#[from] hyper::Error), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("HTTP error: {0}")] + Http(#[from] http::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +impl From for StreamableHttpError { + fn from(e: UnixSocketError) -> Self { + StreamableHttpError::Client(e) + } +} + +/// HTTP client that routes requests through a Unix domain socket. +/// +/// Implements [`StreamableHttpClient`] using `hyper` over `tokio::net::UnixStream`, +/// enabling MCP hosts in Kubernetes environments to connect through Envoy sidecars +/// or other Unix socket-based proxies. +/// +/// Each request opens a new Unix socket connection (no connection pooling). +/// This is appropriate when connecting through a sidecar proxy that manages +/// its own upstream connection pool. +/// +/// # Example +/// +/// ```rust,no_run +/// use rmcp::transport::{StreamableHttpClientTransport, UnixSocketHttpClient}; +/// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +/// +/// let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal/mcp"); +/// let config = StreamableHttpClientTransportConfig::with_uri("http://mcp-server.internal/mcp"); +/// let transport = StreamableHttpClientTransport::with_client(client, config); +/// ``` +#[derive(Clone, Debug)] +pub struct UnixSocketHttpClient { + socket_path: Arc, + host_header: HeaderValue, +} + +impl UnixSocketHttpClient { + /// Creates a new Unix socket HTTP client. + /// + /// # Arguments + /// + /// * `socket_path` - Path to the Unix domain socket. Use `@name` syntax for Linux + /// abstract sockets (e.g., `@egress.sock` becomes `\0egress.sock`). + /// * `uri` - The MCP server URI. The authority (host:port) is extracted for the + /// HTTP `Host` header, since hyper does not auto-set it for Unix socket connections. + /// + /// # Panics + /// + /// Panics if `socket_path` is empty or is `@` with no name (empty abstract socket). + pub fn new(socket_path: &str, uri: &str) -> Self { + assert!( + !socket_path.is_empty() && socket_path != "@", + "socket_path must not be empty or a bare '@' (empty abstract socket name)" + ); + + let host_header = uri + .parse::() + .ok() + .and_then(|u| u.authority().cloned()) + .and_then(|a| HeaderValue::from_str(a.as_str()).ok()) + .unwrap_or_else(|| HeaderValue::from_static("localhost")); + + Self { + socket_path: resolve_socket_path(socket_path).into(), + host_header, + } + } +} + +/// Converts the `@`-prefixed abstract socket notation to the null-byte prefix +/// expected by the Linux kernel. Filesystem socket paths are returned unchanged. +fn resolve_socket_path(raw: &str) -> String { + if let Some(name) = raw.strip_prefix('@') { + format!("\0{name}") + } else { + raw.to_string() + } +} + +async fn connect_unix(socket_path: &str) -> Result { + #[cfg(target_os = "linux")] + if let Some(abstract_name) = socket_path.strip_prefix('\0') { + let abstract_name = abstract_name.to_string(); + let std_stream = tokio::task::spawn_blocking(move || { + use std::os::linux::net::SocketAddrExt; + let addr = std::os::unix::net::SocketAddr::from_abstract_name(&abstract_name)?; + let stream = std::os::unix::net::UnixStream::connect_addr(&addr)?; + stream.set_nonblocking(true)?; + Ok::<_, std::io::Error>(stream) + }) + .await + .map_err(std::io::Error::other)??; + return UnixStream::from_std(std_stream); + } + + UnixStream::connect(socket_path).await +} + +/// Opens a new Unix socket connection and sends the HTTP request. +/// One connection per request — the sidecar proxy handles connection pooling. +async fn send_http_request( + socket_path: &str, + request: Request>, +) -> Result, UnixSocketError> { + let stream = connect_unix(socket_path).await?; + let io = TokioIo::new(stream); + let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?; + + tokio::spawn(async move { + if let Err(e) = conn.await { + tracing::warn!("unix socket HTTP/1.1 connection error: {e}"); + } + }); + + Ok(sender.send_request(request).await?) +} + +/// Applies custom headers to a request builder, rejecting reserved headers. +fn apply_custom_headers( + mut builder: http::request::Builder, + custom_headers: HashMap, +) -> Result> { + for (name, value) in custom_headers { + validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?; + builder = builder.header(name, value); + } + Ok(builder) +} + +impl StreamableHttpClient for UnixSocketHttpClient { + type Error = UnixSocketError; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + ) -> Result> { + let json_body = serde_json::to_string(&message) + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Json(e)))?; + + let mut builder = Request::builder() + .method(Method::POST) + .uri(uri.as_ref()) + .header(http::header::HOST, self.host_header.clone()) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .header( + http::header::ACCEPT, + format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), + ); + + if let Some(auth) = auth_token { + builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); + } + + builder = apply_custom_headers(builder, custom_headers)?; + + let session_was_attached = session_id.is_some(); + if let Some(sid) = session_id { + builder = builder.header(HEADER_SESSION_ID, sid.as_ref()); + } + + let request = builder + .body(Full::new(Bytes::from(json_body))) + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; + + let response = send_http_request(&self.socket_path, request) + .await + .map_err(StreamableHttpError::Client)?; + + let status = response.status(); + + if status == StatusCode::UNAUTHORIZED { + if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { + let www_authenticate_header = header + .to_str() + .map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header, + })); + } + } + + if status == StatusCode::FORBIDDEN { + if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); + } + } + + if matches!(status, StatusCode::ACCEPTED | StatusCode::NO_CONTENT) { + return Ok(StreamableHttpPostResponse::Accepted); + } + + if status == StatusCode::NOT_FOUND && session_was_attached { + return Err(StreamableHttpError::SessionExpired); + } + + if !status.is_success() { + let body = response + .into_body() + .collect() + .await + .map(|c| String::from_utf8_lossy(&c.to_bytes()).into_owned()) + .unwrap_or_else(|_| "".to_owned()); + return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + format!("HTTP {status}: {body}"), + ))); + } + + let content_type = response.headers().get(http::header::CONTENT_TYPE).cloned(); + let session_id = response + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + match content_type { + Some(ref ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { + let sse_stream = SseStream::new(response.into_body()).boxed(); + Ok(StreamableHttpPostResponse::Sse(sse_stream, session_id)) + } + Some(ref ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { + let body = response + .into_body() + .collect() + .await + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Hyper(e)))? + .to_bytes(); + match serde_json::from_slice::(&body) { + Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), + Err(e) => { + tracing::warn!( + "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" + ); + Ok(StreamableHttpPostResponse::Accepted) + } + } + } + _ => Err(StreamableHttpError::UnexpectedContentType( + content_type.map(|ct| String::from_utf8_lossy(ct.as_bytes()).into_owned()), + )), + } + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_token: Option, + custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + let mut builder = Request::builder() + .method(Method::DELETE) + .uri(uri.as_ref()) + .header(http::header::HOST, self.host_header.clone()) + .header(HEADER_SESSION_ID, session_id.as_ref()); + + if let Some(auth) = auth_token { + builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); + } + + builder = apply_custom_headers(builder, custom_headers)?; + + let request = builder + .body(Full::new(Bytes::new())) + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; + + let response = send_http_request(&self.socket_path, request) + .await + .map_err(StreamableHttpError::Client)?; + + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + tracing::debug!("this server doesn't support deleting session"); + return Ok(()); + } + + if !response.status().is_success() { + return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + format!("delete_session returned {}", response.status()), + ))); + } + + Ok(()) + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Arc, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + ) -> Result>, StreamableHttpError> + { + let mut builder = Request::builder() + .method(Method::GET) + .uri(uri.as_ref()) + .header(http::header::HOST, self.host_header.clone()) + .header( + http::header::ACCEPT, + format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), + ) + .header(HEADER_SESSION_ID, session_id.as_ref()); + + if let Some(last_id) = last_event_id { + builder = builder.header(HEADER_LAST_EVENT_ID, last_id); + } + + if let Some(auth) = auth_token { + builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); + } + + builder = apply_custom_headers(builder, custom_headers)?; + + let request = builder + .body(Full::new(Bytes::new())) + .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; + + let response = send_http_request(&self.socket_path, request) + .await + .map_err(StreamableHttpError::Client)?; + + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + return Err(StreamableHttpError::ServerDoesNotSupportSse); + } + + if response.status() == StatusCode::UNAUTHORIZED { + if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { + let www_authenticate_header = header + .to_str() + .map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header, + })); + } + } + + if response.status() == StatusCode::FORBIDDEN { + if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); + } + } + + if !response.status().is_success() { + return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + format!("get_stream returned {}", response.status()), + ))); + } + + match response.headers().get(http::header::CONTENT_TYPE) { + Some(ct) => { + if !ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) + && !ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) + { + return Err(StreamableHttpError::UnexpectedContentType(Some( + String::from_utf8_lossy(ct.as_bytes()).to_string(), + ))); + } + } + None => { + return Err(StreamableHttpError::UnexpectedContentType(None)); + } + } + + Ok(SseStream::new(response.into_body()).boxed()) + } +} + +impl StreamableHttpClientTransport { + /// Creates a new transport connecting through a Unix domain socket. + /// + /// # Arguments + /// + /// * `socket_path` - Path to the Unix domain socket. Use `@name` for Linux abstract sockets. + /// * `uri` - The MCP server URI (used for HTTP Host header and request path). + pub fn from_unix_socket(socket_path: &str, uri: impl Into>) -> Self { + let uri: Arc = uri.into(); + let client = UnixSocketHttpClient::new(socket_path, &uri); + let config = StreamableHttpClientTransportConfig { + uri, + ..Default::default() + }; + StreamableHttpClientTransport::with_client(client, config) + } + + /// Creates a new transport connecting through a Unix domain socket with custom config. + /// + /// # Arguments + /// + /// * `socket_path` - Path to the Unix domain socket. Use `@name` for Linux abstract sockets. + /// * `config` - Transport configuration (URI, retry policy, custom headers, etc.). + pub fn from_unix_socket_with_config( + socket_path: &str, + config: StreamableHttpClientTransportConfig, + ) -> Self { + let client = UnixSocketHttpClient::new(socket_path, &config.uri); + StreamableHttpClientTransport::with_client(client, config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_abstract_socket() { + assert_eq!(resolve_socket_path("@egress.sock"), "\0egress.sock"); + } + + #[test] + fn resolve_filesystem_socket() { + assert_eq!( + resolve_socket_path("/var/run/envoy.sock"), + "/var/run/envoy.sock" + ); + } + + #[test] + fn resolve_empty_abstract() { + assert_eq!(resolve_socket_path("@"), "\0"); + } + + #[test] + #[should_panic(expected = "socket_path must not be empty")] + fn rejects_bare_at_symbol() { + UnixSocketHttpClient::new("@", "http://localhost/mcp"); + } + + #[test] + #[should_panic(expected = "socket_path must not be empty")] + fn rejects_empty_path() { + UnixSocketHttpClient::new("", "http://localhost/mcp"); + } + + #[test] + fn host_header_auto_derived() { + let client = + UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal/mcp"); + assert_eq!(client.host_header, "mcp-server.internal"); + } + + #[test] + fn host_header_with_port() { + let client = + UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal:8080/mcp"); + assert_eq!(client.host_header, "mcp-server.internal:8080"); + } + + #[test] + fn host_header_fallback_on_path_only_uri() { + let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "/mcp"); + assert_eq!(client.host_header, "localhost"); + } + + #[test] + fn reserved_header_rejected() { + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("accept"), + HeaderValue::from_static("text/plain"), + ); + let builder = Request::builder(); + let result = apply_custom_headers(builder, headers); + assert!(matches!( + result, + Err(StreamableHttpError::ReservedHeaderConflict(_)) + )); + } + + #[test] + fn mcp_protocol_version_allowed_through() { + let mut headers = HashMap::new(); + headers.insert( + HeaderName::from_static("mcp-protocol-version"), + HeaderValue::from_static("2025-03-26"), + ); + let builder = Request::builder().uri("http://localhost/mcp").method("GET"); + let result = apply_custom_headers(builder, headers); + assert!(result.is_ok()); + } +} diff --git a/crates/rmcp/tests/test_unix_socket_transport.rs b/crates/rmcp/tests/test_unix_socket_transport.rs new file mode 100644 index 000000000..4c4ad52f1 --- /dev/null +++ b/crates/rmcp/tests/test_unix_socket_transport.rs @@ -0,0 +1,298 @@ +#![cfg(all( + unix, + feature = "transport-streamable-http-client-unix-socket", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use axum::{ + Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse, routing::post, +}; +use http::{HeaderName, HeaderValue}; +use hyper_util::rt::TokioIo; +use rmcp::{ + ServiceExt, + transport::{ + StreamableHttpClientTransport, UnixSocketHttpClient, + streamable_http_client::StreamableHttpClientTransportConfig, + }, +}; +use serde_json::json; +use tokio::sync::Mutex; + +#[derive(Clone)] +struct ServerState { + received_headers: Arc>>, + initialize_called: Arc, +} + +async fn mcp_handler( + State(state): State, + headers: http::HeaderMap, + body: Bytes, +) -> impl IntoResponse { + let mut headers_map = HashMap::new(); + for (name, value) in headers.iter() { + let name_str = name.as_str(); + if name_str.starts_with("x-") || name_str == "host" { + if let Ok(v) = value.to_str() { + headers_map.insert(name_str.to_string(), v.to_string()); + } + } + } + + let mut stored = state.received_headers.lock().await; + stored.extend(headers_map); + drop(stored); + + if let Ok(json_body) = serde_json::from_slice::(&body) { + if let Some(method) = json_body.get("method").and_then(|m| m.as_str()) { + if method == "initialize" { + state.initialize_called.notify_one(); + let response = json!({ + "jsonrpc": "2.0", + "id": json_body.get("id"), + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": { + "name": "test-unix-server", + "version": "1.0.0" + } + } + }); + return ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "unix-test-session", + ), + ], + response.to_string(), + ); + } else if method == "notifications/initialized" { + return ( + StatusCode::ACCEPTED, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "unix-test-session", + ), + ], + String::new(), + ); + } + } + } + + let request_id = serde_json::from_slice::(&body) + .ok() + .and_then(|j| j.get("id").cloned()) + .unwrap_or(serde_json::Value::Null); + let response = json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": {} + }); + ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "unix-test-session", + ), + ], + response.to_string(), + ) +} + +/// Spawns an HTTP/1.1 server on a Unix socket using hyper directly. +/// Avoids `axum::serve(UnixListener, ...)` which uses `spawn_local` on Linux. +fn spawn_unix_server( + listener: tokio::net::UnixListener, + app: Router, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let tower_service = app.clone(); + tokio::spawn(async move { + let io = TokioIo::new(stream); + let hyper_service = hyper::service::service_fn( + move |req: hyper::Request| { + let mut tower_service = tower_service.clone(); + async move { + use tower_service::Service; + tower_service.call(req).await + } + }, + ); + hyper::server::conn::http1::Builder::new() + .serve_connection(io, hyper_service) + .await + .ok(); + }); + } + }) +} + +/// Integration test: MCP client connects and completes handshake over a Unix domain socket. +#[tokio::test] +async fn test_unix_socket_mcp_handshake() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join(format!("rmcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let socket_path = dir.join("mcp.sock"); + + let _ = std::fs::remove_file(&socket_path); + + let state = ServerState { + received_headers: Arc::new(Mutex::new(HashMap::new())), + initialize_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let listener = tokio::net::UnixListener::bind(&socket_path)?; + let server_handle = spawn_unix_server(listener, app); + + let socket_str = socket_path.to_str().unwrap(); + let uri = "http://mcp-server.internal/mcp"; + let client = UnixSocketHttpClient::new(socket_str, uri); + let config = StreamableHttpClientTransportConfig::with_uri(uri); + let transport = StreamableHttpClientTransport::with_client(client, config); + + let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialize_called.notified(), + ) + .await + .expect("Initialize request should be received"); + + let headers = state.received_headers.lock().await; + assert_eq!( + headers.get("host"), + Some(&"mcp-server.internal".to_string()), + "Host header should be derived from URI" + ); + + drop(mcp_client); + server_handle.abort(); + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_dir(&dir); + + Ok(()) +} + +/// Integration test: Custom headers are sent through the Unix socket transport. +#[tokio::test] +async fn test_unix_socket_custom_headers() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join(format!("rmcp-test-headers-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let socket_path = dir.join("mcp.sock"); + let _ = std::fs::remove_file(&socket_path); + + let state = ServerState { + received_headers: Arc::new(Mutex::new(HashMap::new())), + initialize_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let listener = tokio::net::UnixListener::bind(&socket_path)?; + let server_handle = spawn_unix_server(listener, app); + + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static("x-test-header"), + HeaderValue::from_static("test-value-123"), + ); + custom_headers.insert( + HeaderName::from_static("x-client-id"), + HeaderValue::from_static("unix-test-client"), + ); + + let socket_str = socket_path.to_str().unwrap(); + let uri = "http://mcp-server.internal/mcp"; + let client = UnixSocketHttpClient::new(socket_str, uri); + let config = StreamableHttpClientTransportConfig::with_uri(uri).custom_headers(custom_headers); + let transport = StreamableHttpClientTransport::with_client(client, config); + + let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialize_called.notified(), + ) + .await + .expect("Initialize request should be received"); + + let headers = state.received_headers.lock().await; + assert_eq!( + headers.get("x-test-header"), + Some(&"test-value-123".to_string()), + "Custom header x-test-header should be received" + ); + assert_eq!( + headers.get("x-client-id"), + Some(&"unix-test-client".to_string()), + "Custom header x-client-id should be received" + ); + + drop(mcp_client); + server_handle.abort(); + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_dir(&dir); + + Ok(()) +} + +/// Integration test: Convenience constructor `from_unix_socket` works end-to-end. +#[tokio::test] +async fn test_unix_socket_convenience_constructor() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join(format!("rmcp-test-conv-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let socket_path = dir.join("mcp.sock"); + let _ = std::fs::remove_file(&socket_path); + + let state = ServerState { + received_headers: Arc::new(Mutex::new(HashMap::new())), + initialize_called: Arc::new(tokio::sync::Notify::new()), + }; + + let app = Router::new() + .route("/mcp", post(mcp_handler)) + .with_state(state.clone()); + + let listener = tokio::net::UnixListener::bind(&socket_path)?; + let server_handle = spawn_unix_server(listener, app); + + let socket_str = socket_path.to_str().unwrap(); + let transport = + StreamableHttpClientTransport::from_unix_socket(socket_str, "http://localhost/mcp"); + + let mcp_client = ().serve(transport).await.expect("MCP handshake should succeed"); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + state.initialize_called.notified(), + ) + .await + .expect("Initialize request should be received"); + + drop(mcp_client); + server_handle.abort(); + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_dir(&dir); + + Ok(()) +} From 6a3b32d3ab2f0720044fdd069576a34bbe27eab6 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:23:58 -0400 Subject: [PATCH 114/333] chore: add #[non_exhaustive] to remaining public structs (#768) * chore: add #[non_exhaustive] to remaining public structs * chore: add #[non_exhaustive] to remaining public types * chore: enable exhaustive_structs/enums clippy lints * test: add untagged ServerResult deserialization regression tests --- conformance/src/bin/client.rs | 8 +- conformance/src/bin/server.rs | 5 +- crates/rmcp/Cargo.toml | 4 + crates/rmcp/src/handler/server/common.rs | 2 + crates/rmcp/src/handler/server/prompt.rs | 3 + crates/rmcp/src/handler/server/router.rs | 1 + .../rmcp/src/handler/server/router/prompt.rs | 3 + crates/rmcp/src/handler/server/router/tool.rs | 4 + crates/rmcp/src/handler/server/tool.rs | 3 + .../rmcp/src/handler/server/wrapper/json.rs | 1 + .../src/handler/server/wrapper/parameters.rs | 1 + crates/rmcp/src/model.rs | 41 ++++++ crates/rmcp/src/model/annotated.rs | 1 + crates/rmcp/src/model/capabilities.rs | 15 +++ crates/rmcp/src/model/content.rs | 5 + crates/rmcp/src/model/elicitation_schema.rs | 14 +++ crates/rmcp/src/model/meta.rs | 1 + crates/rmcp/src/model/prompt.rs | 2 + crates/rmcp/src/model/resource.rs | 3 + crates/rmcp/src/model/task.rs | 4 + crates/rmcp/src/model/tool.rs | 1 + crates/rmcp/src/service.rs | 4 + crates/rmcp/src/service/client.rs | 1 + crates/rmcp/src/service/server.rs | 2 + crates/rmcp/src/task_manager.rs | 3 + crates/rmcp/src/transport.rs | 2 + crates/rmcp/src/transport/async_rw.rs | 3 + crates/rmcp/src/transport/auth.rs | 46 +++++++ .../src/transport/common/client_side_sse.rs | 4 + .../src/transport/common/server_side_http.rs | 1 + .../rmcp/src/transport/common/unix_socket.rs | 1 + crates/rmcp/src/transport/sink_stream.rs | 2 + .../src/transport/streamable_http_client.rs | 5 + .../streamable_http_server/session/local.rs | 7 ++ .../streamable_http_server/session/never.rs | 3 + .../transport/streamable_http_server/tower.rs | 28 +++++ crates/rmcp/src/transport/worker.rs | 5 + crates/rmcp/tests/test_complex_schema.rs | 2 + crates/rmcp/tests/test_deserialization.rs | 119 ++++++++++++++++++ crates/rmcp/tests/test_elicitation.rs | 11 +- .../rmcp/tests/test_json_schema_detection.rs | 1 + crates/rmcp/tests/test_message_protocol.rs | 81 ++---------- ...erver_json_rpc_message_schema_current.json | 6 +- crates/rmcp/tests/test_sampling.rs | 25 +--- .../rmcp/tests/test_sse_concurrent_streams.rs | 8 +- .../test_streamable_http_json_response.rs | 40 +++--- .../tests/test_streamable_http_priming.rs | 18 +-- .../test_streamable_http_stale_session.rs | 27 ++-- crates/rmcp/tests/test_structured_output.rs | 1 + .../rmcp/tests/test_tool_builder_methods.rs | 1 + crates/rmcp/tests/test_with_js.rs | 9 +- .../servers/src/complex_auth_streamhttp.rs | 48 +++---- examples/servers/src/counter_streamhttp.rs | 5 +- 53 files changed, 430 insertions(+), 211 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index b9c8cea9d..253451729 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -252,12 +252,8 @@ async fn perform_oauth_flow_preregistered( manager.set_metadata(metadata); // Configure with pre-registered credentials - let config = rmcp::transport::auth::OAuthClientConfig { - client_id: client_id.to_string(), - client_secret: Some(client_secret.to_string()), - scopes: vec![], - redirect_uri: REDIRECT_URI.to_string(), - }; + let config = rmcp::transport::auth::OAuthClientConfig::new(client_id, REDIRECT_URI) + .with_client_secret(client_secret); manager.configure_client(config)?; let scopes = manager.select_scopes(None, &[]); diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index bfa98f42c..5ca4b5922 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -818,10 +818,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting conformance server on {}", bind_addr); let server = ConformanceServer::new(); - let config = StreamableHttpServerConfig { - stateful_mode: true, - ..Default::default() - }; + let config = StreamableHttpServerConfig::default(); let service = StreamableHttpService::new( move || Ok(server.clone()), LocalSessionManager::default().into(), diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index cbf02ea48..bc59c5933 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -9,6 +9,10 @@ readme = { workspace = true } description = "Rust SDK for Model Context Protocol" documentation = "https://docs.rs/rmcp" +[lints.clippy] +exhaustive_structs = "warn" +exhaustive_enums = "warn" + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/rmcp/src/handler/server/common.rs b/crates/rmcp/src/handler/server/common.rs index 2ecab6386..74c49d887 100644 --- a/crates/rmcp/src/handler/server/common.rs +++ b/crates/rmcp/src/handler/server/common.rs @@ -138,6 +138,7 @@ where } } +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct Extension(pub T); impl FromContextPart for Extension @@ -182,6 +183,7 @@ where } } +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RequestId(pub crate::model::RequestId); impl FromContextPart for RequestId diff --git a/crates/rmcp/src/handler/server/prompt.rs b/crates/rmcp/src/handler/server/prompt.rs index 27a03e835..11ca4bf83 100644 --- a/crates/rmcp/src/handler/server/prompt.rs +++ b/crates/rmcp/src/handler/server/prompt.rs @@ -20,6 +20,7 @@ use crate::{ }; /// Context for prompt retrieval operations +#[non_exhaustive] pub struct PromptContext<'a, S> { pub server: &'a S, pub name: String, @@ -117,6 +118,7 @@ impl IntoGetPromptResult for Result // Future wrapper that automatically handles IntoGetPromptResult conversion pin_project_lite::pin_project! { #[project = IntoGetPromptResultFutProj] + #[non_exhaustive] pub enum IntoGetPromptResultFut { Pending { #[pin] @@ -151,6 +153,7 @@ where } // Prompt-specific extractor for prompt name +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct PromptName(pub String); impl FromContextPart> for PromptName { diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index 1f34ba5b2..08beb61d2 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -13,6 +13,7 @@ use crate::{ pub mod prompt; pub mod tool; +#[non_exhaustive] pub struct Router { pub tool_router: tool::ToolRouter, pub prompt_router: prompt::PromptRouter, diff --git a/crates/rmcp/src/handler/server/router/prompt.rs b/crates/rmcp/src/handler/server/router/prompt.rs index b5ea4a47f..e952b2a39 100644 --- a/crates/rmcp/src/handler/server/router/prompt.rs +++ b/crates/rmcp/src/handler/server/router/prompt.rs @@ -6,6 +6,7 @@ use crate::{ service::{MaybeBoxFuture, MaybeSend}, }; +#[non_exhaustive] pub struct PromptRoute { #[allow(clippy::type_complexity)] pub get: Arc>, @@ -90,6 +91,7 @@ where } /// Adapter for functions generated by the #\[prompt\] macro +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct PromptAttrGenerateFunctionAdapter; impl IntoPromptRoute for F @@ -103,6 +105,7 @@ where } #[derive(Debug)] +#[non_exhaustive] pub struct PromptRouter { #[allow(clippy::type_complexity)] pub map: std::collections::HashMap, PromptRoute>, diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 42c582c40..87f0db6c3 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -136,6 +136,7 @@ use crate::{ service::{MaybeBoxFuture, MaybeSend}, }; +#[non_exhaustive] pub struct ToolRoute { #[allow(clippy::type_complexity)] pub call: Arc>, @@ -216,6 +217,7 @@ where } } +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ToolAttrGenerateFunctionAdapter; impl IntoToolRoute for F where @@ -251,6 +253,7 @@ where } } +#[non_exhaustive] pub struct WithToolAttr where C: CallToolHandler + MaybeSend + Clone + 'static, @@ -292,6 +295,7 @@ where } } #[derive(Debug)] +#[non_exhaustive] pub struct ToolRouter { #[allow(clippy::type_complexity)] pub map: std::collections::HashMap, ToolRoute>, diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index 0ad8ce61a..03beface4 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -29,6 +29,7 @@ pub fn parse_json_object(input: JsonObject) -> Result { pub request_context: RequestContext, pub service: &'s S, @@ -104,6 +105,7 @@ impl IntoCallToolResult for Result { pin_project_lite::pin_project! { #[project = IntoCallToolResultFutProj] + #[non_exhaustive] pub enum IntoCallToolResultFut { Pending { #[pin] @@ -163,6 +165,7 @@ pub type DynCallToolHandler = -> futures::future::LocalBoxFuture<'s, Result>; // Tool-specific extractor for tool name +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ToolName(pub Cow<'static, str>); impl FromContextPart> for ToolName { diff --git a/crates/rmcp/src/handler/server/wrapper/json.rs b/crates/rmcp/src/handler/server/wrapper/json.rs index 8eae30268..bc2170a3b 100644 --- a/crates/rmcp/src/handler/server/wrapper/json.rs +++ b/crates/rmcp/src/handler/server/wrapper/json.rs @@ -14,6 +14,7 @@ use crate::{ /// serialized as structured JSON content with an associated schema. /// The framework will place the JSON in the `structured_content` field /// of the tool result rather than the regular `content` field. +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct Json(pub T); // Implement JsonSchema for Json to delegate to T's schema diff --git a/crates/rmcp/src/handler/server/wrapper/parameters.rs b/crates/rmcp/src/handler/server/wrapper/parameters.rs index 9de73dd66..ab3eb0ce1 100644 --- a/crates/rmcp/src/handler/server/wrapper/parameters.rs +++ b/crates/rmcp/src/handler/server/wrapper/parameters.rs @@ -42,6 +42,7 @@ use schemars::JsonSchema; /// - Returns appropriate error responses if deserialization fails #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(transparent)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct Parameters

(pub P); impl JsonSchema for Parameters

{ diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 482384354..ba6c35156 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -58,6 +58,7 @@ macro_rules! object { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Copy, Eq)] #[serde(deny_unknown_fields)] #[cfg_attr(feature = "server", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct EmptyObject {} pub trait ConstString: Default { @@ -70,6 +71,7 @@ pub trait ConstString: Default { macro_rules! const_string { ($name:ident = $value:literal) => { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $name; impl ConstString for $name { @@ -196,6 +198,7 @@ impl<'de> Deserialize<'de> for ProtocolVersion { /// This is commonly used for request IDs and other identifiers in JSON-RPC /// where the specification allows both numeric and string values. #[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum NumberOrString { /// A numeric identifier Number(i64), @@ -292,6 +295,7 @@ pub type RequestId = NumberOrString; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Hash, Eq)] #[serde(transparent)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ProgressToken(pub NumberOrString); // ============================================================================= @@ -338,6 +342,7 @@ impl GetExtensions for Request { #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RequestOptionalParam { pub method: M, // #[serde(skip_serializing_if = "Option::is_none")] @@ -361,6 +366,7 @@ impl RequestOptionalParam { #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RequestNoParam { pub method: M, /// extensions will carry anything possible in the context, including [`Meta`] @@ -403,6 +409,7 @@ impl Notification { #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct NotificationNoParam { pub method: M, /// extensions will carry anything possible in the context, including [`Meta`] @@ -414,6 +421,7 @@ pub struct NotificationNoParam { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct JsonRpcRequest { pub jsonrpc: JsonRpcVersion2_0, pub id: RequestId, @@ -435,6 +443,7 @@ impl JsonRpcRequest { type DefaultResponse = JsonObject; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct JsonRpcResponse { pub jsonrpc: JsonRpcVersion2_0, pub id: RequestId, @@ -443,6 +452,7 @@ pub struct JsonRpcResponse { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct JsonRpcError { pub jsonrpc: JsonRpcVersion2_0, pub id: RequestId, @@ -462,6 +472,7 @@ impl JsonRpcError { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct JsonRpcNotification { pub jsonrpc: JsonRpcVersion2_0, #[serde(flatten)] @@ -475,6 +486,7 @@ pub struct JsonRpcNotification { #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(transparent)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ErrorCode(pub i32); impl ErrorCode { @@ -493,6 +505,7 @@ impl ErrorCode { /// providing a standardized way to communicate errors between clients and servers. #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ErrorData { /// The error type that occurred (using standard JSON-RPC error codes) pub code: ErrorCode, @@ -552,6 +565,7 @@ impl ErrorData { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(untagged)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum JsonRpcMessage { /// A single request expecting a response Request(JsonRpcRequest), @@ -651,6 +665,7 @@ impl From for () { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(transparent)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CustomResult(pub Value); impl CustomResult { @@ -667,6 +682,7 @@ impl CustomResult { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CancelledNotificationParam { pub request_id: RequestId, pub reason: Option, @@ -691,6 +707,7 @@ pub type CancelledNotification = /// deserialize them into domain-specific types. #[derive(Debug, Clone)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CustomNotification { pub method: String, pub params: Option, @@ -725,6 +742,7 @@ impl CustomNotification { /// deserialize them into domain-specific types. #[derive(Debug, Clone)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CustomRequest { pub method: String, pub params: Option, @@ -1050,6 +1068,7 @@ const_string!(ProgressNotificationMethod = "notifications/progress"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ProgressNotificationParam { pub progress_token: ProgressToken, /// The progress thus far. This should increase every time progress is made, even if the total is unknown. @@ -1097,6 +1116,7 @@ macro_rules! paginated_result { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] + #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $t { #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -1293,6 +1313,7 @@ const_string!(ResourceUpdatedNotificationMethod = "notifications/resources/updat #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ResourceUpdatedNotificationParam { /// The URI of the resource that was updated pub uri: String, @@ -1392,6 +1413,7 @@ pub type ToolListChangedNotification = NotificationNoParam { Single(T), Multiple(Vec), @@ -1665,6 +1691,7 @@ pub struct SamplingMessage { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum SamplingMessageContent { Text(RawTextContent), Image(RawImageContent), @@ -1792,6 +1819,7 @@ impl TryFrom for SamplingContent { /// should be provided to the LLM when processing sampling requests. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum ContextInclusion { /// Include context from all connected MCP servers #[serde(rename = "allServers")] @@ -2119,6 +2147,7 @@ impl ModelHint { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CompletionContext { /// Previously resolved argument values that can inform completion suggestions #[serde(skip_serializing_if = "Option::is_none")] @@ -2209,6 +2238,7 @@ pub type CompleteRequest = Request #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CompletionInfo { pub values: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -2302,6 +2332,7 @@ impl CompleteResult { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(tag = "type")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum Reference { #[serde(rename = "ref/resource")] Resource(ResourceReference), @@ -2353,6 +2384,7 @@ impl Reference { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ResourceReference { pub uri: String, } @@ -2386,6 +2418,7 @@ const_string!(CompleteRequestMethod = "completion/complete"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ArgumentInfo { pub name: String, pub value: String, @@ -2460,6 +2493,7 @@ const_string!(ElicitationCompletionNotificationMethod = "notifications/elicitati #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "lowercase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum ElicitationAction { /// User accepts the request and provides the requested information Accept, @@ -2571,6 +2605,7 @@ impl TryFrom for CreateElicitati try_from = "CreateElicitationRequestParamDeserializeHelper" )] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum CreateElicitationRequestParams { #[serde(rename = "form", rename_all = "camelCase")] FormElicitationParams { @@ -2631,6 +2666,7 @@ pub type CreateElicitationRequestParam = CreateElicitationRequestParams; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CreateElicitationResult { /// The user's decision on how to handle the elicitation request pub action: ElicitationAction, @@ -2666,6 +2702,7 @@ pub type CreateElicitationRequest = #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ElicitationResponseNotificationParam { pub elicitation_id: String, } @@ -3032,6 +3069,7 @@ pub type GetTaskInfoRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct GetTaskInfoParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -3061,6 +3099,7 @@ pub type GetTaskResultRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CancelTaskParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -3161,6 +3201,7 @@ macro_rules! ts_union { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] + #[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub enum $U { $($declared)* diff --git a/crates/rmcp/src/model/annotated.rs b/crates/rmcp/src/model/annotated.rs index 9158e10be..e2e750824 100644 --- a/crates/rmcp/src/model/annotated.rs +++ b/crates/rmcp/src/model/annotated.rs @@ -39,6 +39,7 @@ impl Annotations { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct Annotated { #[serde(flatten)] pub raw: T, diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index b47a8a849..33aae6908 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -34,6 +34,7 @@ pub type ExtensionCapabilities = BTreeMap; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct PromptsCapability { #[serde(skip_serializing_if = "Option::is_none")] pub list_changed: Option, @@ -42,6 +43,7 @@ pub struct PromptsCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ResourcesCapability { #[serde(skip_serializing_if = "Option::is_none")] pub subscribe: Option, @@ -52,6 +54,7 @@ pub struct ResourcesCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ToolsCapability { #[serde(skip_serializing_if = "Option::is_none")] pub list_changed: Option, @@ -60,6 +63,7 @@ pub struct ToolsCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RootsCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub list_changed: Option, @@ -69,6 +73,7 @@ pub struct RootsCapabilities { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct TasksCapability { #[serde(skip_serializing_if = "Option::is_none")] pub requests: Option, @@ -82,6 +87,7 @@ pub struct TasksCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct TaskRequestsCapability { #[serde(skip_serializing_if = "Option::is_none")] pub sampling: Option, @@ -94,6 +100,7 @@ pub struct TaskRequestsCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct SamplingTaskCapability { #[serde(skip_serializing_if = "Option::is_none")] pub create_message: Option, @@ -102,6 +109,7 @@ pub struct SamplingTaskCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ElicitationTaskCapability { #[serde(skip_serializing_if = "Option::is_none")] pub create: Option, @@ -110,6 +118,7 @@ pub struct ElicitationTaskCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ToolsTaskCapability { #[serde(skip_serializing_if = "Option::is_none")] pub call: Option, @@ -190,6 +199,7 @@ impl TasksCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct FormElicitationCapability { /// Whether the client supports JSON Schema validation for elicitation responses. /// When true, the client will validate user input against the requested_schema @@ -201,6 +211,7 @@ pub struct FormElicitationCapability { /// Capability for URL mode elicitation. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct UrlElicitationCapability {} /// Elicitation allows servers to request interactive input from users during tool execution. @@ -209,6 +220,7 @@ pub struct UrlElicitationCapability {} #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ElicitationCapability { /// Whether client supports form-based elicitation. #[serde(skip_serializing_if = "Option::is_none")] @@ -222,6 +234,7 @@ pub struct ElicitationCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct SamplingCapability { /// Support for `tools` and `toolChoice` parameters #[serde(skip_serializing_if = "Option::is_none")] @@ -310,10 +323,12 @@ macro_rules! builder { ($Target: ident {$($f: ident: $T: ty),* $(,)?}) => { paste! { #[derive(Default, Clone, Copy, Debug)] + #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct [<$Target BuilderState>]< $(const [<$f:upper>]: bool = false,)* >; #[derive(Debug, Default)] + #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct [<$Target Builder>]]> { $(pub $f: Option<$T>,)* pub state: PhantomData diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index 83658b023..7054e2b0e 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -9,6 +9,7 @@ use super::{AnnotateAble, Annotated, resource::ResourceContents}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RawTextContent { pub text: String, /// Optional protocol-level metadata for this content block @@ -19,6 +20,7 @@ pub type TextContent = Annotated; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RawImageContent { /// The base64-encoded image pub data: String, @@ -32,6 +34,7 @@ pub type ImageContent = Annotated; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RawEmbeddedResource { /// Optional protocol-level metadata for this content block #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] @@ -63,6 +66,7 @@ impl EmbeddedResource { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RawAudioContent { pub data: String, pub mime_type: String, @@ -145,6 +149,7 @@ impl ToolResultContent { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum RawContent { Text(RawTextContent), Image(RawImageContent), diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index cdbb87d6d..73ab62257 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -49,6 +49,7 @@ const_string!(ArrayTypeConst = "array"); #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum PrimitiveSchema { /// Enum property (explicit enum schema) Enum(EnumSchema), @@ -70,6 +71,7 @@ pub enum PrimitiveSchema { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum StringFormat { /// Email address format Email, @@ -344,6 +346,7 @@ impl NumberSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct IntegerSchema { /// Type discriminator #[serde(rename = "type")] @@ -510,6 +513,7 @@ impl BooleanSchema { /// Represent single entry for titled item #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ConstTitle { #[serde(rename = "const")] pub const_: String, @@ -530,6 +534,7 @@ impl ConstTitle { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct LegacyEnumSchema { #[serde(rename = "type")] pub type_: StringTypeConst, @@ -595,6 +600,7 @@ impl TitledSingleSelectEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum SingleSelectEnumSchema { Untitled(UntitledSingleSelectEnumSchema), Titled(TitledSingleSelectEnumSchema), @@ -603,6 +609,7 @@ pub enum SingleSelectEnumSchema { /// Items for untitled multi-select options #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct UntitledItems { #[serde(rename = "type")] pub type_: StringTypeConst, @@ -613,6 +620,7 @@ pub struct UntitledItems { /// Items for titled multi-select options #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct TitledItems { // MCP spec requires "anyOf" for multi-select enums (allows any combination) // Alias "oneOf" for compatibility with schemars @@ -718,6 +726,7 @@ impl TitledMultiSelectEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum MultiSelectEnumSchema { Untitled(UntitledMultiSelectEnumSchema), Titled(TitledMultiSelectEnumSchema), @@ -741,6 +750,7 @@ pub enum MultiSelectEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum EnumSchema { Single(SingleSelectEnumSchema), Multi(MultiSelectEnumSchema), @@ -749,9 +759,11 @@ pub enum EnumSchema { /// Marker type for single-select enum builder #[derive(Debug)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct SingleSelect; /// Marker type for multi-select enum builder #[derive(Debug)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct MultiSelect; /// Builder for EnumSchema /// Allows to create various enum schema types (single/multi select, titled/untitled) @@ -1077,6 +1089,7 @@ impl EnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ElicitationSchema { /// Always "object" for elicitation schemas #[serde(rename = "type")] @@ -1221,6 +1234,7 @@ impl ElicitationSchema { /// .build(); /// ``` #[derive(Debug, Default)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ElicitationSchemaBuilder { pub properties: BTreeMap, pub required: Vec, diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index c60762a35..186db6a24 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -195,6 +195,7 @@ variant_extension! { #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(transparent)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct Meta(pub JsonObject); const PROGRESS_TOKEN_FIELD: &str = "progressToken"; impl Meta { diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index 531a86d25..72ea0e469 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -138,6 +138,7 @@ impl PromptArgument { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum PromptMessageRole { User, Assistant, @@ -147,6 +148,7 @@ pub enum PromptMessageRole { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum PromptMessageContent { /// Plain text content Text { text: String }, diff --git a/crates/rmcp/src/model/resource.rs b/crates/rmcp/src/model/resource.rs index 8a25e25ba..cd5c15d78 100644 --- a/crates/rmcp/src/model/resource.rs +++ b/crates/rmcp/src/model/resource.rs @@ -6,6 +6,7 @@ use super::{Annotated, Icon, Meta}; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RawResource { /// URI representing the resource location (e.g., "file:///path/to/file" or "str:///content") pub uri: String, @@ -39,6 +40,7 @@ pub type Resource = Annotated; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RawResourceTemplate { pub uri_template: String, pub name: String, @@ -58,6 +60,7 @@ pub type ResourceTemplate = Annotated; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(untagged)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum ResourceContents { #[serde(rename_all = "camelCase")] TextResourceContents { diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index 343c925ef..dbdc34068 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -7,6 +7,7 @@ use super::Meta; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum TaskStatus { /// The receiver accepted the request and is currently working on it. #[default] @@ -110,6 +111,7 @@ impl CreateTaskResult { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct GetTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -160,6 +162,7 @@ impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct CancelTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -171,6 +174,7 @@ pub struct CancelTaskResult { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct TaskList { pub tasks: Vec, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index ca6e56915..66d29bc10 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -51,6 +51,7 @@ pub struct Tool { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum TaskSupport { /// Clients MUST NOT invoke this tool as a task (default behavior). #[default] diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 3bad42519..65b5ee719 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -307,6 +307,7 @@ type Responder = tokio::sync::oneshot::Sender; /// /// or wait for response by call [`RequestHandle::await_response`] #[derive(Debug)] +#[non_exhaustive] pub struct RequestHandle { pub rx: tokio::sync::oneshot::Receiver>, pub options: PeerRequestOptions, @@ -398,6 +399,7 @@ impl std::fmt::Debug for Peer { type ProxyOutbound = mpsc::Receiver>; #[derive(Debug, Default)] +#[non_exhaustive] pub struct PeerRequestOptions { pub timeout: Option, pub meta: Option, @@ -648,6 +650,7 @@ pub enum QuitReason { /// Request execution context #[derive(Debug, Clone)] +#[non_exhaustive] pub struct RequestContext { /// this token will be cancelled when the [`CancelledNotification`] is received. pub ct: CancellationToken, @@ -673,6 +676,7 @@ impl RequestContext { /// Request execution context #[derive(Debug, Clone)] +#[non_exhaustive] pub struct NotificationContext { pub meta: Meta, pub extensions: Extensions, diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 8b49606e4..8031e66ae 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -140,6 +140,7 @@ where } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RoleClient; impl ServiceRole for RoleClient { diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 5946d23a4..dcf7993a6 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -27,6 +27,7 @@ use crate::{ }; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RoleServer; impl ServiceRole for RoleServer { @@ -571,6 +572,7 @@ macro_rules! elicit_safe { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum ElicitationMode { Form, Url, diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 774c542f8..32bcf8f0e 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -19,6 +19,7 @@ pub type OperationFuture = /// Describes metadata associated with an enqueued task. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct OperationDescriptor { pub operation_id: String, pub name: String, @@ -55,6 +56,7 @@ impl OperationDescriptor { } /// Operation message describing a unit of asynchronous work. +#[non_exhaustive] pub struct OperationMessage { pub descriptor: OperationDescriptor, pub future: OperationFuture, @@ -91,6 +93,7 @@ struct RunningTask { descriptor: OperationDescriptor, } +#[non_exhaustive] pub struct TaskResult { pub descriptor: OperationDescriptor, pub result: Result, Error>, diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 2a11f4fed..04a8e1c6a 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -153,6 +153,7 @@ where fn into_transport(self) -> impl Transport + 'static; } +#[non_exhaustive] pub enum TransportAdapterIdentity {} impl IntoTransport for T where @@ -233,6 +234,7 @@ where #[derive(Debug, thiserror::Error)] #[error("Transport [{transport_name}] error: {error}")] +#[non_exhaustive] pub struct DynamicTransportError { pub transport_name: Cow<'static, str>, pub transport_type_id: std::any::TypeId, diff --git a/crates/rmcp/src/transport/async_rw.rs b/crates/rmcp/src/transport/async_rw.rs index ff4ecc65b..b14d94c33 100644 --- a/crates/rmcp/src/transport/async_rw.rs +++ b/crates/rmcp/src/transport/async_rw.rs @@ -16,6 +16,7 @@ use tokio_util::{ use super::{IntoTransport, Transport}; use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; +#[non_exhaustive] pub enum TransportAdapterAsyncRW {} impl IntoTransport for (R, W) @@ -29,6 +30,7 @@ where } } +#[non_exhaustive] pub enum TransportAdapterAsyncCombinedRW {} impl IntoTransport for S where @@ -277,6 +279,7 @@ fn try_parse_with_compatibility( } #[derive(Debug, Error)] +#[non_exhaustive] pub enum JsonRpcMessageCodecError { #[error("max line length exceeded")] MaxLineLengthExceeded, diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 051349d84..349e65066 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -60,6 +60,7 @@ const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; /// Stored credentials for OAuth2 authorization #[derive(Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct StoredCredentials { pub client_id: String, pub token_response: Option, @@ -134,6 +135,7 @@ impl CredentialStore for InMemoryCredentialStore { /// Stored authorization state for OAuth2 PKCE flow #[derive(Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct StoredAuthorizationState { pub pkce_verifier: String, pub csrf_token: String, @@ -172,6 +174,7 @@ impl std::fmt::Debug for StoredAuthorizationState { /// } /// ``` #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[non_exhaustive] pub struct VendorExtraTokenFields(pub HashMap); impl ExtraTokenFields for VendorExtraTokenFields {} @@ -257,6 +260,7 @@ impl StateStore for InMemoryStateStore { /// HTTP client with OAuth 2.0 authorization #[derive(Clone)] +#[non_exhaustive] pub struct AuthClient { pub http_client: C, pub auth_manager: Arc>, @@ -350,6 +354,7 @@ pub enum AuthError { /// oauth2 metadata #[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[non_exhaustive] pub struct AuthorizationMetadata { pub authorization_endpoint: String, pub token_endpoint: String, @@ -373,6 +378,7 @@ struct ResourceServerMetadata { /// Parameters extracted from WWW-Authenticate header #[derive(Debug, Clone, Default)] +#[non_exhaustive] pub struct WWWAuthenticateParams { pub resource_metadata_url: Option, pub scope: Option, @@ -394,6 +400,7 @@ impl WWWAuthenticateParams { /// oauth2 client config #[derive(Debug, Clone)] +#[non_exhaustive] pub struct OAuthClientConfig { pub client_id: String, pub client_secret: Option, @@ -401,6 +408,27 @@ pub struct OAuthClientConfig { pub redirect_uri: String, } +impl OAuthClientConfig { + pub fn new(client_id: impl Into, redirect_uri: impl Into) -> Self { + Self { + client_id: client_id.into(), + client_secret: None, + scopes: Vec::new(), + redirect_uri: redirect_uri.into(), + } + } + + pub fn with_client_secret(mut self, secret: impl Into) -> Self { + self.client_secret = Some(secret.into()); + self + } + + pub fn with_scopes(mut self, scopes: Vec) -> Self { + self.scopes = scopes; + self + } +} + // add type aliases for oauth2 types type OAuthErrorResponse = oauth2::StandardErrorResponse; @@ -440,6 +468,7 @@ pub const EXTENSION_OAUTH_CLIENT_CREDENTIALS: &str = /// JWT signing algorithm for private_key_jwt authentication (SEP-1046) #[cfg(feature = "auth-client-credentials-jwt")] #[derive(Debug, Clone, Copy)] +#[non_exhaustive] pub enum JwtSigningAlgorithm { RS256, RS384, @@ -477,6 +506,7 @@ impl JwtSigningAlgorithm { /// - `ClientSecret`: credentials sent in the request body /// - `PrivateKeyJwt`: RFC 7523 signed JWT assertion (requires `auth-client-credentials-jwt` feature) #[derive(Debug, Clone)] +#[non_exhaustive] pub enum ClientCredentialsConfig { /// Client secret authentication (credentials in request body) ClientSecret { @@ -534,6 +564,7 @@ impl ClientCredentialsConfig { /// Configuration for scope upgrade behavior #[derive(Debug, Clone)] +#[non_exhaustive] pub struct ScopeUpgradeConfig { /// Maximum number of scope upgrade attempts before giving up pub max_upgrade_attempts: u32, @@ -579,6 +610,7 @@ pub(crate) struct ClientRegistrationRequest { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct ClientRegistrationResponse { pub client_id: String, pub client_secret: Option, @@ -589,6 +621,18 @@ pub struct ClientRegistrationResponse { pub additional_fields: HashMap, } +impl ClientRegistrationResponse { + pub fn new(client_id: impl Into, redirect_uris: Vec) -> Self { + Self { + client_id: client_id.into(), + client_secret: None, + client_name: None, + redirect_uris, + additional_fields: HashMap::new(), + } + } +} + /// SEP-991: URL-based Client IDs /// Validate that the client_id is a valid URL with https scheme and non-root pathname fn is_https_url(value: &str) -> bool { @@ -2045,6 +2089,7 @@ impl AuthorizationManager { } /// oauth2 authorization session, for guiding user to complete the authorization process +#[non_exhaustive] pub struct AuthorizationSession { pub auth_manager: AuthorizationManager, pub auth_url: String, @@ -2197,6 +2242,7 @@ impl AuthorizedHttpClient { /// OAuth state machine /// Use the OAuthState to manage the OAuth client is more recommend /// But also you can use the AuthorizationManager,AuthorizationSession,AuthorizedHttpClient directly +#[non_exhaustive] pub enum OAuthState { /// the AuthorizationManager Unauthorized(AuthorizationManager), diff --git a/crates/rmcp/src/transport/common/client_side_sse.rs b/crates/rmcp/src/transport/common/client_side_sse.rs index b826b12d4..fc9e15eb7 100644 --- a/crates/rmcp/src/transport/common/client_side_sse.rs +++ b/crates/rmcp/src/transport/common/client_side_sse.rs @@ -17,6 +17,7 @@ pub trait SseRetryPolicy: std::fmt::Debug + Send + Sync { } #[derive(Debug, Clone)] +#[non_exhaustive] pub struct FixedInterval { pub max_times: Option, pub duration: Duration, @@ -47,6 +48,7 @@ impl Default for FixedInterval { } #[derive(Debug, Clone)] +#[non_exhaustive] pub struct ExponentialBackoff { pub max_times: Option, pub base_duration: Duration, @@ -77,6 +79,7 @@ impl SseRetryPolicy for ExponentialBackoff { } #[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] pub struct NeverRetry; impl SseRetryPolicy for NeverRetry { @@ -169,6 +172,7 @@ impl SseAutoReconnectStream> { pin_project_lite::pin_project! { #[project = SseAutoReconnectStreamStateProj] + #[non_exhaustive] pub enum SseAutoReconnectStreamState { Connected { #[pin] diff --git a/crates/rmcp/src/transport/common/server_side_http.rs b/crates/rmcp/src/transport/common/server_side_http.rs index efa50fd09..d24b19af6 100644 --- a/crates/rmcp/src/transport/common/server_side_http.rs +++ b/crates/rmcp/src/transport/common/server_side_http.rs @@ -58,6 +58,7 @@ impl sse_stream::Timer for TokioTimer { } #[derive(Debug, Clone)] +#[non_exhaustive] pub struct ServerSseMessage { /// The event ID for this message. When set, clients can use this ID /// with the `Last-Event-ID` header to resume the stream from this point. diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs index 3af987973..9170c1296 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -21,6 +21,7 @@ use crate::{ }; #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum UnixSocketError { #[error("hyper error: {0}")] Hyper(#[from] hyper::Error), diff --git a/crates/rmcp/src/transport/sink_stream.rs b/crates/rmcp/src/transport/sink_stream.rs index f31743922..6286b53b3 100644 --- a/crates/rmcp/src/transport/sink_stream.rs +++ b/crates/rmcp/src/transport/sink_stream.rs @@ -50,6 +50,7 @@ where } } +#[non_exhaustive] pub enum TransportAdapterSinkStream {} impl IntoTransport for (Si, St) @@ -64,6 +65,7 @@ where } } +#[non_exhaustive] pub enum TransportAdapterAsyncCombinedRW {} impl IntoTransport for S where diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 9a27b4935..980e63db1 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -24,11 +24,13 @@ use crate::{ type BoxedSseStream = BoxStream<'static, Result>; #[derive(Debug)] +#[non_exhaustive] pub struct AuthRequiredError { pub www_authenticate_header: String, } #[derive(Debug)] +#[non_exhaustive] pub struct InsufficientScopeError { pub www_authenticate_header: String, pub required_scope: Option, @@ -212,6 +214,7 @@ pub trait StreamableHttpClient: Clone + Send + 'static { + '_; } +#[non_exhaustive] pub struct RetryConfig { pub max_times: Option, pub min_duration: Duration, @@ -253,6 +256,7 @@ struct SessionCleanupInfo { } #[derive(Debug, Clone, Default)] +#[non_exhaustive] pub struct StreamableHttpClientWorker { pub client: C, pub config: StreamableHttpClientTransportConfig, @@ -1046,6 +1050,7 @@ impl StreamableHttpClientTransport { } } #[derive(Debug, Clone)] +#[non_exhaustive] pub struct StreamableHttpClientTransportConfig { pub uri: Arc, pub retry_config: Arc, diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index cad533802..2d2059c59 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -29,12 +29,14 @@ use crate::{ }; #[derive(Debug, Default)] +#[non_exhaustive] pub struct LocalSessionManager { pub sessions: tokio::sync::RwLock>, pub session_config: SessionConfig, } #[derive(Debug, Error)] +#[non_exhaustive] pub enum LocalSessionManagerError { #[error("Session not found: {0}")] SessionNotFound(SessionId), @@ -148,6 +150,7 @@ impl std::fmt::Display for EventId { } #[derive(Debug, Clone, Error)] +#[non_exhaustive] pub enum EventIdParseError { #[error("Invalid index: {0}")] InvalidIndex(ParseIntError), @@ -310,6 +313,7 @@ impl LocalSessionWorker { } #[derive(Debug, Error)] +#[non_exhaustive] pub enum SessionError { #[error("Invalid request id: {0}")] DuplicatedRequestId(HttpRequestId), @@ -339,6 +343,7 @@ enum OutboundChannel { Common, } #[derive(Debug)] +#[non_exhaustive] pub struct StreamableHttpMessageReceiver { pub http_request_id: Option, pub inner: Receiver, @@ -657,6 +662,7 @@ impl LocalSessionWorker { } #[derive(Debug)] +#[non_exhaustive] pub enum SessionEvent { ClientMessage { message: ClientJsonRpcMessage, @@ -1059,6 +1065,7 @@ impl Worker for LocalSessionWorker { } #[derive(Debug, Clone)] +#[non_exhaustive] pub struct SessionConfig { /// the capacity of the channel for the session. Default is 16. pub channel_capacity: usize, diff --git a/crates/rmcp/src/transport/streamable_http_server/session/never.rs b/crates/rmcp/src/transport/streamable_http_server/session/never.rs index 436d4cfce..a2f72d820 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/never.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/never.rs @@ -10,9 +10,12 @@ use crate::{ #[derive(Debug, Clone, Error)] #[error("Session management is not supported")] +#[non_exhaustive] pub struct ErrorSessionManagementNotSupported; #[derive(Debug, Clone, Default)] +#[non_exhaustive] pub struct NeverSessionManager {} +#[non_exhaustive] pub enum NeverTransport {} impl Transport for NeverTransport { type Error = ErrorSessionManagementNotSupported; diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 0130467df..7f4d888c7 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -30,6 +30,7 @@ use crate::{ }; #[derive(Debug, Clone)] +#[non_exhaustive] pub struct StreamableHttpServerConfig { /// The ping message duration for SSE connections. pub sse_keep_alive: Option, @@ -62,6 +63,33 @@ impl Default for StreamableHttpServerConfig { } } +impl StreamableHttpServerConfig { + pub fn with_sse_keep_alive(mut self, duration: Option) -> Self { + self.sse_keep_alive = duration; + self + } + + pub fn with_sse_retry(mut self, duration: Option) -> Self { + self.sse_retry = duration; + self + } + + pub fn with_stateful_mode(mut self, stateful: bool) -> Self { + self.stateful_mode = stateful; + self + } + + pub fn with_json_response(mut self, json_response: bool) -> Self { + self.json_response = json_response; + self + } + + pub fn with_cancellation_token(mut self, token: CancellationToken) -> Self { + self.cancellation_token = token; + self + } +} + #[expect( clippy::result_large_err, reason = "BoxResponse is intentionally large; matches other handlers in this file" diff --git a/crates/rmcp/src/transport/worker.rs b/crates/rmcp/src/transport/worker.rs index d7c53afd4..a5d722d44 100644 --- a/crates/rmcp/src/transport/worker.rs +++ b/crates/rmcp/src/transport/worker.rs @@ -53,6 +53,7 @@ pub trait Worker: Sized + Send + 'static { } } +#[non_exhaustive] pub struct WorkerSendRequest { pub message: TxJsonRpcMessage, pub responder: tokio::sync::oneshot::Sender>, @@ -66,6 +67,7 @@ pub struct WorkerTransport { ct: CancellationToken, } +#[non_exhaustive] pub struct WorkerConfig { pub name: Option, pub channel_buffer_capacity: usize, @@ -79,6 +81,7 @@ impl Default for WorkerConfig { } } } +#[non_exhaustive] pub enum WorkerAdapter {} impl IntoTransport for W { @@ -143,11 +146,13 @@ impl WorkerTransport { } } +#[non_exhaustive] pub struct SendRequest { pub message: TxJsonRpcMessage, pub responder: tokio::sync::oneshot::Sender>, } +#[non_exhaustive] pub struct WorkerContext { pub to_handler_tx: tokio::sync::mpsc::Sender>, pub from_handler_rx: tokio::sync::mpsc::Receiver>, diff --git a/crates/rmcp/tests/test_complex_schema.rs b/crates/rmcp/tests/test_complex_schema.rs index a9c41a3c5..74b9be7c9 100644 --- a/crates/rmcp/tests/test_complex_schema.rs +++ b/crates/rmcp/tests/test_complex_schema.rs @@ -1,3 +1,5 @@ +#![allow(clippy::exhaustive_structs, clippy::exhaustive_enums)] + use rmcp::{ ErrorData as McpError, handler::server::wrapper::Parameters, model::*, schemars, tool, tool_router, diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index 73621f487..ffcb51eff 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -13,3 +13,122 @@ fn test_tool_list_result() { }) )); } + +/// Regression tests for `#[serde(untagged)]` deserialization of `ServerResult`. +/// +/// `ServerResult` is an untagged enum, so serde tries each variant in declaration +/// order. `GetTaskPayloadResult` has a custom `Deserialize` impl that always fails +/// so it is skipped, and `CustomResult(Value)` acts as the catch-all. If variant +/// ordering changes or the custom impl is removed, these tests will catch the +/// regression. +mod untagged_server_result { + use rmcp::model::{CallToolResult, JsonRpcResponse, ServerJsonRpcMessage, ServerResult}; + use serde_json::json; + + /// Helper: wrap a result value in a JSON-RPC response envelope. + fn wrap_response(result: serde_json::Value) -> serde_json::Value { + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": result + }) + } + + /// Parse a JSON-RPC response and return the inner `ServerResult`. + fn parse_result(json: serde_json::Value) -> ServerResult { + let msg: ServerJsonRpcMessage = serde_json::from_value(json).unwrap(); + match msg { + ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) => result, + other => panic!("expected Response, got {other:?}"), + } + } + + #[test] + fn initialize_result_deserializes_to_correct_variant() { + let result = parse_result(wrap_response(json!({ + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + }))); + assert!( + matches!(result, ServerResult::InitializeResult(_)), + "expected InitializeResult, got {result:?}" + ); + } + + #[test] + fn call_tool_result_deserializes_to_correct_variant() { + let result = parse_result(wrap_response(json!({ + "content": [ + { "type": "text", "text": "hello" } + ] + }))); + assert!( + matches!(result, ServerResult::CallToolResult(_)), + "expected CallToolResult, got {result:?}" + ); + } + + #[test] + fn empty_object_deserializes_to_empty_result() { + let result = parse_result(wrap_response(json!({}))); + assert!( + matches!(result, ServerResult::EmptyResult(_)), + "expected EmptyResult, got {result:?}" + ); + } + + #[test] + fn unknown_shape_falls_through_to_custom_result() { + // A value that doesn't match any known result type should land in + // CustomResult, NOT GetTaskPayloadResult. + let result = parse_result(wrap_response(json!({ + "some_unknown_field": "some_value", + "number": 42 + }))); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "expected CustomResult, got {result:?}" + ); + } + + #[test] + fn arbitrary_json_value_does_not_deserialize_as_get_task_payload_result() { + // GetTaskPayloadResult wraps a bare Value, but its custom Deserialize + // always fails so serde skips it during untagged resolution. + // Any JSON value must fall through to CustomResult instead. + for value in [json!(42), json!("hello"), json!(null), json!([1, 2, 3])] { + let result = parse_result(wrap_response(value.clone())); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "value {value} should deserialize as CustomResult, got {result:?}" + ); + } + } + + #[test] + fn round_trip_initialize_result_preserves_variant() { + let json = json!({ + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": { "name": "test", "version": "1.0" } + }); + // Parse as ServerResult, serialize back, parse again — must stay InitializeResult. + let result = parse_result(wrap_response(json.clone())); + assert!(matches!(&result, ServerResult::InitializeResult(_))); + let reserialized = serde_json::to_value(&result).unwrap(); + let result2 = parse_result(wrap_response(reserialized)); + assert!(matches!(result2, ServerResult::InitializeResult(_))); + } + + #[test] + fn round_trip_call_tool_result_preserves_variant() { + let original = CallToolResult::success(vec![rmcp::model::Content::text("hello world")]); + let json = serde_json::to_value(&original).unwrap(); + let result = parse_result(wrap_response(json)); + assert!(matches!(result, ServerResult::CallToolResult(_))); + } +} diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 7d946a2bf..bfa8cc493 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -1458,20 +1458,15 @@ async fn test_peer_request_options_timeout() { let timeout = Some(Duration::from_secs(15)); - let options = PeerRequestOptions { - timeout, - meta: None, - }; + let mut options = PeerRequestOptions::default(); + options.timeout = timeout; // Verify timeout is properly stored assert_eq!(options.timeout, timeout); assert!(options.meta.is_none()); // Test with no timeout - let options_no_timeout = PeerRequestOptions { - timeout: None, - meta: None, - }; + let options_no_timeout = PeerRequestOptions::default(); assert!(options_no_timeout.timeout.is_none()); } diff --git a/crates/rmcp/tests/test_json_schema_detection.rs b/crates/rmcp/tests/test_json_schema_detection.rs index af587319d..5d982cd66 100644 --- a/crates/rmcp/tests/test_json_schema_detection.rs +++ b/crates/rmcp/tests/test_json_schema_detection.rs @@ -1,3 +1,4 @@ +#![allow(clippy::exhaustive_structs)] //cargo test --test test_json_schema_detection --features "client server macros" use rmcp::{ Json, ServerHandler, handler::server::router::tool::ToolRouter, tool, tool_handler, tool_router, diff --git a/crates/rmcp/tests/test_message_protocol.rs b/crates/rmcp/tests/test_message_protocol.rs index 898040dbd..6fdb4285d 100644 --- a/crates/rmcp/tests/test_message_protocol.rs +++ b/crates/rmcp/tests/test_message_protocol.rs @@ -8,7 +8,6 @@ use rmcp::{ model::*, service::{RequestContext, Service}, }; -use tokio_util::sync::CancellationToken; // Tests start here #[tokio::test] @@ -48,13 +47,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(1), - meta: Default::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), ) .await?; @@ -85,13 +78,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(2), - meta: Default::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(2), client.peer().clone()), ) .await?; @@ -122,13 +109,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(3), - meta: Default::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(3), client.peer().clone()), ) .await?; @@ -179,13 +160,7 @@ async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(1), - meta: Meta::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), ) .await?; @@ -241,13 +216,7 @@ async fn test_message_sequence_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(1), - meta: Meta::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), ) .await?; @@ -307,13 +276,7 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(1), - meta: Meta::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), ) .await?; @@ -330,13 +293,7 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(2), - meta: Meta::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(2), client.peer().clone()), ) .await; @@ -370,13 +327,7 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(1), - meta: Meta::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), ) .await?; @@ -405,13 +356,7 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(2), - meta: Meta::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(2), client.peer().clone()), ) .await?; @@ -457,13 +402,7 @@ async fn test_context_inclusion() -> anyhow::Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(1), - meta: Meta::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), ) .await?; diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index bd8f744b0..c1aa13dea 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -388,7 +388,6 @@ "content": { "description": "The content returned by the tool (text, images, etc.)", "type": "array", - "default": [], "items": { "$ref": "#/definitions/Annotated" } @@ -403,7 +402,10 @@ "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } - } + }, + "required": [ + "content" + ] }, "CancelTaskResult": { "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 62bba1123..7bd6cd118 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -8,7 +8,6 @@ use rmcp::{ model::*, service::{RequestContext, Service}, }; -use tokio_util::sync::CancellationToken; #[tokio::test] async fn test_basic_sampling_message_creation() -> Result<()> { @@ -126,13 +125,7 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(1), - meta: Default::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(1), client.peer().clone()), ) .await?; @@ -189,13 +182,7 @@ async fn test_sampling_no_context_inclusion() -> Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(2), - meta: Default::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(2), client.peer().clone()), ) .await?; @@ -253,13 +240,7 @@ async fn test_sampling_error_invalid_message_sequence() -> Result<()> { let result = handler .handle_request( request.clone(), - RequestContext { - peer: client.peer().clone(), - ct: CancellationToken::new(), - id: NumberOrString::Number(3), - meta: Default::default(), - extensions: Default::default(), - }, + RequestContext::new(NumberOrString::Number(3), client.peer().clone()), ) .await; diff --git a/crates/rmcp/tests/test_sse_concurrent_streams.rs b/crates/rmcp/tests/test_sse_concurrent_streams.rs index a7821fe9d..37bbfd721 100644 --- a/crates/rmcp/tests/test_sse_concurrent_streams.rs +++ b/crates/rmcp/tests/test_sse_concurrent_streams.rs @@ -74,13 +74,7 @@ async fn start_test_server(ct: CancellationToken, trigger: Arc) -> Strin let service = StreamableHttpService::new( move || Ok(server.clone()), Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig { - stateful_mode: true, - sse_keep_alive: Some(Duration::from_secs(15)), - sse_retry: Some(Duration::from_secs(3)), - cancellation_token: ct.child_token(), - ..Default::default() - }, + StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); diff --git a/crates/rmcp/tests/test_streamable_http_json_response.rs b/crates/rmcp/tests/test_streamable_http_json_response.rs index b023acd06..09dd69ccd 100644 --- a/crates/rmcp/tests/test_streamable_http_json_response.rs +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -37,13 +37,13 @@ async fn spawn_server( #[tokio::test] async fn stateless_json_response_returns_application_json() -> anyhow::Result<()> { let ct = CancellationToken::new(); - let (client, url, ct) = spawn_server(StreamableHttpServerConfig { - stateful_mode: false, - json_response: true, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }) + let (client, url, ct) = spawn_server( + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) .await; let response = client @@ -79,13 +79,12 @@ async fn stateless_json_response_returns_application_json() -> anyhow::Result<() #[tokio::test] async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { let ct = CancellationToken::new(); - let (client, url, ct) = spawn_server(StreamableHttpServerConfig { - stateful_mode: false, - json_response: false, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }) + let (client, url, ct) = spawn_server( + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) .await; let response = client @@ -122,13 +121,12 @@ async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { async fn json_response_ignored_in_stateful_mode() -> anyhow::Result<()> { let ct = CancellationToken::new(); // json_response: true has no effect when stateful_mode: true — server still uses SSE - let (client, url, ct) = spawn_server(StreamableHttpServerConfig { - stateful_mode: true, - json_response: true, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }) + let (client, url, ct) = spawn_server( + StreamableHttpServerConfig::default() + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) .await; let response = client diff --git a/crates/rmcp/tests/test_streamable_http_priming.rs b/crates/rmcp/tests/test_streamable_http_priming.rs index 5e771024c..3be3700b8 100644 --- a/crates/rmcp/tests/test_streamable_http_priming.rs +++ b/crates/rmcp/tests/test_streamable_http_priming.rs @@ -18,12 +18,9 @@ async fn test_priming_on_stream_start() -> anyhow::Result<()> { StreamableHttpService::new( || Ok(Calculator::new()), Default::default(), - StreamableHttpServerConfig { - stateful_mode: true, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }, + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); @@ -87,12 +84,9 @@ async fn test_priming_on_stream_close() -> anyhow::Result<()> { let service = StreamableHttpService::new( || Ok(Calculator::new()), session_manager.clone(), - StreamableHttpServerConfig { - stateful_mode: true, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }, + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index b385cc52b..d96d83c73 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -32,12 +32,9 @@ async fn test_stale_session_id_returns_status_aware_error() -> anyhow::Result<() StreamableHttpService::new( || Ok(Calculator::new()), Default::default(), - StreamableHttpServerConfig { - stateful_mode: true, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }, + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); @@ -103,12 +100,9 @@ async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result let service = StreamableHttpService::new( || Ok(Calculator::new()), session_manager.clone(), - StreamableHttpServerConfig { - stateful_mode: true, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }, + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); @@ -183,12 +177,9 @@ async fn test_session_expired_error_when_reinit_disabled() -> anyhow::Result<()> let service = StreamableHttpService::new( || Ok(Calculator::new()), session_manager.clone(), - StreamableHttpServerConfig { - stateful_mode: true, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }, + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index b498d3120..7bb62e650 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -1,3 +1,4 @@ +#![allow(clippy::exhaustive_structs)] //cargo test --test test_structured_output --features "client server macros" use rmcp::{ Json, ServerHandler, diff --git a/crates/rmcp/tests/test_tool_builder_methods.rs b/crates/rmcp/tests/test_tool_builder_methods.rs index f93c05462..8be7e5c3e 100644 --- a/crates/rmcp/tests/test_tool_builder_methods.rs +++ b/crates/rmcp/tests/test_tool_builder_methods.rs @@ -1,3 +1,4 @@ +#![allow(clippy::exhaustive_structs)] //cargo test --test test_tool_builder_methods --features "client server macros" use rmcp::model::{JsonObject, Tool}; use schemars::JsonSchema; diff --git a/crates/rmcp/tests/test_with_js.rs b/crates/rmcp/tests/test_with_js.rs index 685ea1430..0dbd93f3f 100644 --- a/crates/rmcp/tests/test_with_js.rs +++ b/crates/rmcp/tests/test_with_js.rs @@ -69,12 +69,9 @@ async fn test_with_js_streamable_http_client() -> anyhow::Result<()> { StreamableHttpService::new( || Ok(Calculator::new()), Default::default(), - StreamableHttpServerConfig { - stateful_mode: true, - sse_keep_alive: None, - cancellation_token: ct.child_token(), - ..Default::default() - }, + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); let tcp_listener = tokio::net::TcpListener::bind(STREAMABLE_HTTP_BIND_ADDRESS).await?; diff --git a/examples/servers/src/complex_auth_streamhttp.rs b/examples/servers/src/complex_auth_streamhttp.rs index 4afacf8d2..34c4b1584 100644 --- a/examples/servers/src/complex_auth_streamhttp.rs +++ b/examples/servers/src/complex_auth_streamhttp.rs @@ -51,12 +51,9 @@ impl McpOAuthStore { let mut clients = HashMap::new(); clients.insert( "mcp-client".to_string(), - OAuthClientConfig { - client_id: "mcp-client".to_string(), - client_secret: Some("mcp-client-secret".to_string()), - scopes: vec!["profile".to_string(), "email".to_string()], - redirect_uri: "http://localhost:8080/callback".to_string(), - }, + OAuthClientConfig::new("mcp-client", "http://localhost:8080/callback") + .with_client_secret("mcp-client-secret") + .with_scopes(vec!["profile".to_string(), "email".to_string()]), ); Self { @@ -520,17 +517,16 @@ async fn oauth_authorization_server() -> impl IntoResponse { "response_types_supported".into(), Value::Array(vec![Value::String("code".into())]), ); - let metadata = AuthorizationMetadata { - authorization_endpoint: format!("http://{}/oauth/authorize", BIND_ADDRESS), - token_endpoint: format!("http://{}/oauth/token", BIND_ADDRESS), - scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]), - registration_endpoint: Some(format!("http://{}/oauth/register", BIND_ADDRESS)), - response_types_supported: Some(vec!["code".to_string()]), - code_challenge_methods_supported: Some(vec!["S256".to_string()]), - issuer: Some(BIND_ADDRESS.to_string()), - jwks_uri: Some(format!("http://{}/oauth/jwks", BIND_ADDRESS)), - additional_fields, - }; + let mut metadata = AuthorizationMetadata::default(); + metadata.authorization_endpoint = format!("http://{}/oauth/authorize", BIND_ADDRESS); + metadata.token_endpoint = format!("http://{}/oauth/token", BIND_ADDRESS); + metadata.scopes_supported = Some(vec!["profile".to_string(), "email".to_string()]); + metadata.registration_endpoint = Some(format!("http://{}/oauth/register", BIND_ADDRESS)); + metadata.response_types_supported = Some(vec!["code".to_string()]); + metadata.code_challenge_methods_supported = Some(vec!["S256".to_string()]); + metadata.issuer = Some(BIND_ADDRESS.to_string()); + metadata.jwks_uri = Some(format!("http://{}/oauth/jwks", BIND_ADDRESS)); + metadata.additional_fields = additional_fields; debug!("metadata: {:?}", metadata); (StatusCode::OK, Json(metadata)) } @@ -556,12 +552,8 @@ async fn oauth_register( let client_id = format!("client-{}", Uuid::new_v4()); let client_secret = generate_random_string(32); - let client = OAuthClientConfig { - client_id: client_id.clone(), - client_secret: Some(client_secret.clone()), - redirect_uri: req.redirect_uris[0].clone(), - scopes: vec![], - }; + let client = OAuthClientConfig::new(client_id.clone(), req.redirect_uris[0].clone()) + .with_client_secret(client_secret.clone()); state .clients @@ -570,13 +562,9 @@ async fn oauth_register( .insert(client_id.clone(), client); // return client information - let response = ClientRegistrationResponse { - client_id, - client_secret: Some(client_secret), - client_name: Some(req.client_name), - redirect_uris: req.redirect_uris, - additional_fields: HashMap::new(), - }; + let mut response = ClientRegistrationResponse::new(client_id, req.redirect_uris); + response.client_secret = Some(client_secret); + response.client_name = Some(req.client_name); (StatusCode::CREATED, Json(response)).into_response() } diff --git a/examples/servers/src/counter_streamhttp.rs b/examples/servers/src/counter_streamhttp.rs index db9b9df18..3811c09f4 100644 --- a/examples/servers/src/counter_streamhttp.rs +++ b/examples/servers/src/counter_streamhttp.rs @@ -25,10 +25,7 @@ async fn main() -> anyhow::Result<()> { let service = StreamableHttpService::new( || Ok(Counter::new()), LocalSessionManager::default().into(), - StreamableHttpServerConfig { - cancellation_token: ct.child_token(), - ..Default::default() - }, + StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); From 0b36a84f05fa7a416a92f1ef2c8f0274a6437b39 Mon Sep 17 00:00:00 2001 From: Guy Lichtman <1395797+glicht@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:26:44 +0200 Subject: [PATCH 115/333] feat: add "theme" to Icon (#766) * feat: add theme field to Icon * fix: update IconThem crates/rmcp/src/model.rs (non_exhaustive) Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> * fix: update IconThem crates/rmcp/src/model.rs (eq, hash) Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> * fix: update docs with full descriptions of theme from mcp spec --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp/src/model.rs | 33 +++++++++++++++++++ crates/rmcp/src/model/resource.rs | 3 ++ .../client_json_rpc_message_schema.json | 26 +++++++++++++++ ...lient_json_rpc_message_schema_current.json | 26 +++++++++++++++ .../server_json_rpc_message_schema.json | 26 +++++++++++++++ ...erver_json_rpc_message_schema_current.json | 26 +++++++++++++++ 6 files changed, 140 insertions(+) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index ba6c35156..8859bbbc6 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -907,6 +907,18 @@ impl Default for ClientInfo { } } +/// Icon themes supported by the MCP specification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, Copy)] +#[serde(rename_all = "lowercase")] //match spec +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum IconTheme { + /// Indicates the icon is designed to be used with a light background + Light, + /// Indicates the icon is designed to be used with a dark background + Dark, +} + /// A URL pointing to an icon resource or a base64-encoded data URI. /// /// Clients that support rendering icons MUST support at least the following MIME types: @@ -929,6 +941,10 @@ pub struct Icon { /// Size specification, each string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG #[serde(skip_serializing_if = "Option::is_none")] pub sizes: Option>, + /// Optional specifier for the theme this icon is designed for + /// If not provided, the client should assume the icon can be used with any theme. + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, } impl Icon { @@ -938,6 +954,7 @@ impl Icon { src: src.into(), mime_type: None, sizes: None, + theme: None, } } @@ -952,6 +969,12 @@ impl Icon { self.sizes = Some(sizes); self } + + /// Set the theme. + pub fn with_theme(mut self, theme: IconTheme) -> Self { + self.theme = Some(theme); + self + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] @@ -3725,12 +3748,14 @@ mod tests { src: "https://example.com/icon.png".to_string(), mime_type: Some("image/png".to_string()), sizes: Some(vec!["48x48".to_string()]), + theme: Some(IconTheme::Light), }; let json = serde_json::to_value(&icon).unwrap(); assert_eq!(json["src"], "https://example.com/icon.png"); assert_eq!(json["mimeType"], "image/png"); assert_eq!(json["sizes"][0], "48x48"); + assert_eq!(json["theme"], "light"); // Test deserialization let deserialized: Icon = serde_json::from_value(json).unwrap(); @@ -3743,12 +3768,14 @@ mod tests { src: "data:image/svg+xml;base64,PHN2Zy8+".to_string(), mime_type: None, sizes: None, + theme: None, }; let json = serde_json::to_value(&icon).unwrap(); assert_eq!(json["src"], "data:image/svg+xml;base64,PHN2Zy8+"); assert!(json.get("mimeType").is_none()); assert!(json.get("sizes").is_none()); + assert!(json.get("theme").is_none()); } #[test] @@ -3763,11 +3790,13 @@ mod tests { src: "https://example.com/icon.png".to_string(), mime_type: Some("image/png".to_string()), sizes: Some(vec!["48x48".to_string()]), + theme: Some(IconTheme::Dark), }, Icon { src: "https://example.com/icon.svg".to_string(), mime_type: Some("image/svg+xml".to_string()), sizes: Some(vec!["any".to_string()]), + theme: Some(IconTheme::Light), }, ]), website_url: Some("https://example.com".to_string()), @@ -3782,6 +3811,8 @@ mod tests { assert_eq!(json["icons"][0]["sizes"][0], "48x48"); assert_eq!(json["icons"][1]["mimeType"], "image/svg+xml"); assert_eq!(json["icons"][1]["sizes"][0], "any"); + assert_eq!(json["icons"][0]["theme"], "dark"); + assert_eq!(json["icons"][1]["theme"], "light"); } #[test] @@ -3814,6 +3845,7 @@ mod tests { src: "https://example.com/server.png".to_string(), mime_type: Some("image/png".to_string()), sizes: Some(vec!["48x48".to_string()]), + theme: Some(IconTheme::Light), }]), website_url: Some("https://docs.example.com".to_string()), }, @@ -3827,6 +3859,7 @@ mod tests { "https://example.com/server.png" ); assert_eq!(json["serverInfo"]["icons"][0]["sizes"][0], "48x48"); + assert_eq!(json["serverInfo"]["icons"][0]["theme"], "light"); assert_eq!(json["serverInfo"]["websiteUrl"], "https://docs.example.com"); } diff --git a/crates/rmcp/src/model/resource.rs b/crates/rmcp/src/model/resource.rs index cd5c15d78..c3c7e8e81 100644 --- a/crates/rmcp/src/model/resource.rs +++ b/crates/rmcp/src/model/resource.rs @@ -217,6 +217,7 @@ mod tests { use serde_json; use super::*; + use crate::model::IconTheme; #[test] fn test_resource_serialization() { @@ -268,6 +269,7 @@ mod tests { src: "https://example.com/icon.png".to_string(), mime_type: Some("image/png".to_string()), sizes: Some(vec!["48x48".to_string()]), + theme: Some(IconTheme::Light), }]), }; @@ -275,6 +277,7 @@ mod tests { assert!(json["icons"].is_array()); assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png"); assert_eq!(json["icons"][0]["sizes"][0], "48x48"); + assert_eq!(json["icons"][0]["theme"], "light"); } #[test] diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 940f03f1b..397fbe8bc 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -720,12 +720,38 @@ "src": { "description": "A standard URI pointing to an icon resource", "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] } }, "required": [ "src" ] }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, "Implementation": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 940f03f1b..397fbe8bc 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -720,12 +720,38 @@ "src": { "description": "A standard URI pointing to an icon resource", "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] } }, "required": [ "src" ] }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, "Implementation": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index bd8f744b0..db21c2ba6 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -1090,12 +1090,38 @@ "src": { "description": "A standard URI pointing to an icon resource", "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] } }, "required": [ "src" ] }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, "Implementation": { "type": "object", "properties": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index c1aa13dea..10f45c0a4 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -1092,12 +1092,38 @@ "src": { "description": "A standard URI pointing to an icon resource", "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for\nIf not provided, the client should assume the icon can be used with any theme.", + "anyOf": [ + { + "$ref": "#/definitions/IconTheme" + }, + { + "type": "null" + } + ] } }, "required": [ "src" ] }, + "IconTheme": { + "description": "Icon themes supported by the MCP specification", + "oneOf": [ + { + "description": "Indicates the icon is designed to be used with a light background", + "type": "string", + "const": "light" + }, + { + "description": "Indicates the icon is designed to be used with a dark background", + "type": "string", + "const": "dark" + } + ] + }, "Implementation": { "type": "object", "properties": { From ac749e3cedfc036a5b77960337669c7cf2338035 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:31:13 -0400 Subject: [PATCH 116/333] chore: release v1.3.0 (#747) * chore: release v2.0.0 * chore: version 1.3.0 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 10 ++++++++++ crates/rmcp/CHANGELOG.md | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ee9b43a15..e2839e58a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.2.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.2.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.3.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.3.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.2.0" +version = "1.3.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index e61fcb3d8..e5cb01832 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.2.0...rmcp-macros-v1.3.0) - 2026-03-24 + +### Added + +- add local feature for !Send tool handler support ([#740](https://github.com/modelcontextprotocol/rust-sdk/pull/740)) + +### Other + +- fix all clippy warnings across workspace ([#746](https://github.com/modelcontextprotocol/rust-sdk/pull/746)) + ## [1.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.1.1...rmcp-macros-v1.2.0) - 2026-03-11 ### Fixed diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 69bd72486..964101d6c 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.2.0...rmcp-v1.3.0) - 2026-03-24 + +### Added + +- *(transport)* add Unix domain socket client for streamable HTTP ([#749](https://github.com/modelcontextprotocol/rust-sdk/pull/749)) +- *(auth)* implement SEP-2207 OIDC-flavored refresh token guidance ([#676](https://github.com/modelcontextprotocol/rust-sdk/pull/676)) +- add configuration for transparent session re-init ([#760](https://github.com/modelcontextprotocol/rust-sdk/pull/760)) +- add local feature for !Send tool handler support ([#740](https://github.com/modelcontextprotocol/rust-sdk/pull/740)) + +### Fixed + +- prevent CallToolResult and GetTaskPayloadResult from shadowing CustomResult in untagged enums ([#771](https://github.com/modelcontextprotocol/rust-sdk/pull/771)) +- drain in-flight responses on stdin EOF ([#759](https://github.com/modelcontextprotocol/rust-sdk/pull/759)) +- remove default type param from StreamableHttpService ([#758](https://github.com/modelcontextprotocol/rust-sdk/pull/758)) +- use cfg-gated Send+Sync supertraits to avoid semver break ([#757](https://github.com/modelcontextprotocol/rust-sdk/pull/757)) +- *(rmcp)* surface JSON-RPC error bodies on HTTP 4xx responses ([#748](https://github.com/modelcontextprotocol/rust-sdk/pull/748)) +- default CallToolResult content to empty vec on missing field ([#752](https://github.com/modelcontextprotocol/rust-sdk/pull/752)) +- *(auth)* redact secrets in Debug output for StoredCredentials and StoredAuthorizationState ([#744](https://github.com/modelcontextprotocol/rust-sdk/pull/744)) + +### Other + +- fix all clippy warnings across workspace ([#746](https://github.com/modelcontextprotocol/rust-sdk/pull/746)) + ## [1.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.1.1...rmcp-v1.2.0) - 2026-03-11 ### Added From b74f5ca35b8d92cb5e86c69f04a53f0c8936f2be Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 27 Mar 2026 14:47:41 -0400 Subject: [PATCH 117/333] feat(auth): add StoredCredentials::new() constructor (#778) StoredCredentials is #[non_exhaustive] but has no constructor, making it impossible for external crates implementing CredentialStore to construct instances without a serde roundtrip workaround. Add a new() constructor matching the pattern used for other #[non_exhaustive] types in this crate. Fixes #777 --- crates/rmcp/src/transport/auth.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 349e65066..3f9b06e3d 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -84,6 +84,23 @@ impl std::fmt::Debug for StoredCredentials { } } +impl StoredCredentials { + /// Create a new `StoredCredentials` instance. + pub fn new( + client_id: String, + token_response: Option, + granted_scopes: Vec, + token_received_at: Option, + ) -> Self { + Self { + client_id, + token_response, + granted_scopes, + token_received_at, + } + } +} + /// Trait for storing and retrieving OAuth2 credentials /// /// Implementations of this trait can provide custom storage backends From 52c93e9508e2a523ca61f81c55b4cd67c8664ccc Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:47:59 -0400 Subject: [PATCH 118/333] ci: add semver check job and disable release-plz semver (#776) --- .github/workflows/release-plz.yml | 20 ++++++++++++-------- release-plz.toml | 5 +++++ 2 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 release-plz.toml diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index d63ebbc9e..580c31aec 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -25,10 +25,12 @@ jobs: fetch-depth: 0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Run release-plz - uses: release-plz/action@v0.5 - with: - command: release + # Using fork until semver_check_features support is released upstream. + # See: https://github.com/release-plz/release-plz/pull/2757 + - name: Install release-plz from fork + run: cargo install --git https://github.com/DaleSeo/release-plz --branch feat/semver-check-features release-plz + - name: Run release-plz release + run: release-plz release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} @@ -51,10 +53,12 @@ jobs: fetch-depth: 0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Run release-plz - uses: release-plz/action@v0.5 - with: - command: release-pr + # Using fork until semver_check_features support is released upstream. + # See: https://github.com/release-plz/release-plz/pull/2757 + - name: Install release-plz from fork + run: cargo install --git https://github.com/DaleSeo/release-plz --branch feat/semver-check-features release-plz + - name: Run release-plz release-pr + run: release-plz release-pr env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 000000000..edc1647d5 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,5 @@ +[[package]] +name = "rmcp" +# Only check default features for semver compatibility. +# The `local` feature intentionally changes the API surface. +semver_check_features = ["default"] From cf6988ac7c7c37e32f33017333537146370f8baf Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Thu, 2 Apr 2026 05:40:11 +0800 Subject: [PATCH 119/333] fix: exclude local feature from docs.rs build (#782) The `local` feature relaxes Send+Sync bounds, which causes items gated behind `cfg(not(feature = "local"))` to be excluded when docs.rs builds with all-features. Replace `all-features = true` with an explicit feature list that omits `local`. Signed-off-by: majiayu000 <1835304752@qq.com> --- crates/rmcp/Cargo.toml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index bc59c5933..57ce5651e 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -14,7 +14,32 @@ exhaustive_structs = "warn" exhaustive_enums = "warn" [package.metadata.docs.rs] -all-features = true +features = [ + "auth", + "auth-client-credentials-jwt", + "base64", + "client", + "client-side-sse", + "elicitation", + "macros", + "reqwest", + "reqwest-native-tls", + "reqwest-tls-no-provider", + "schemars", + "server", + "server-side-http", + "tower", + "transport-async-rw", + "transport-child-process", + "transport-io", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-client-unix-socket", + "transport-streamable-http-server", + "transport-streamable-http-server-session", + "transport-worker", + "uuid", +] rustdoc-args = ["--cfg", "docsrs"] [dependencies] From 012210baae3ff05836eca712dd4ba08bbf7dde05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Romero?= Date: Thu, 2 Apr 2026 01:02:31 +0200 Subject: [PATCH 120/333] fix: example clients_everything_stdio (#770) * fix: example clients_everything_stdio * Apply suggestion from @DaleSeo Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --------- Co-authored-by: Alex Hancock Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- examples/clients/src/everything_stdio.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/clients/src/everything_stdio.rs b/examples/clients/src/everything_stdio.rs index 8a7fce7de..f1eb56e0d 100644 --- a/examples/clients/src/everything_stdio.rs +++ b/examples/clients/src/everything_stdio.rs @@ -59,7 +59,9 @@ async fn main() -> Result<()> { // Read resource let resource = client - .read_resource(ReadResourceRequestParams::new("test://static/resource/3")) + .read_resource(ReadResourceRequestParams::new( + "demo://resource/static/document/architecture.md", + )) .await?; tracing::info!("Resource: {resource:#?}"); @@ -69,18 +71,18 @@ async fn main() -> Result<()> { // Get simple prompt let prompt = client - .get_prompt(GetPromptRequestParams::new("simple_prompt")) + .get_prompt(GetPromptRequestParams::new("simple-prompt")) .await?; tracing::info!("Prompt - simple: {prompt:#?}"); - // Get complex prompt (returns text & image) + // Get prompt with arguments let prompt = client .get_prompt( - GetPromptRequestParams::new("complex_prompt") - .with_arguments(object!({ "temperature": "0.5", "style": "formal" })), + GetPromptRequestParams::new("args-prompt") + .with_arguments(object!({ "city": "Dallas", "state": "Texas" })), ) .await?; - tracing::info!("Prompt - complex: {prompt:#?}"); + tracing::info!("Prompt - args: {prompt:#?}"); // List resource templates let resource_templates = client.list_all_resource_templates().await?; From 8e22aa2de28df5a285eed87c11cd89bf15fa90d3 Mon Sep 17 00:00:00 2001 From: jokemanfire Date: Thu, 2 Apr 2026 07:28:56 +0800 Subject: [PATCH 121/333] fix(http): add host check (#764) Signed-off-by: jokemanfire Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../rmcp/src/transport/common/http_header.rs | 2 + .../transport/streamable_http_server/tower.rs | 121 +++++++++++++- crates/rmcp/tests/test_custom_headers.rs | 158 ++++++++++++++++++ 3 files changed, 279 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/transport/common/http_header.rs b/crates/rmcp/src/transport/common/http_header.rs index 196d96fff..b215ab12a 100644 --- a/crates/rmcp/src/transport/common/http_header.rs +++ b/crates/rmcp/src/transport/common/http_header.rs @@ -7,6 +7,7 @@ pub const JSON_MIME_TYPE: &str = "application/json"; /// Reserved headers that must not be overridden by user-supplied custom headers. /// `MCP-Protocol-Version` is in this list but is allowed through because the worker /// injects it after initialization. +#[allow(dead_code)] pub(crate) const RESERVED_HEADERS: &[&str] = &[ "accept", HEADER_SESSION_ID, @@ -36,6 +37,7 @@ pub(crate) fn validate_custom_header(name: &http::HeaderName) -> Result<(), Stri /// Extracts the `scope=` parameter from a `WWW-Authenticate` header value. /// Handles both quoted (`scope="files:read files:write"`) and unquoted (`scope=read:data`) forms. +#[cfg(feature = "client-side-sse")] pub(crate) fn extract_scope_from_header(header: &str) -> Option { let header_lowercase = header.to_ascii_lowercase(); let scope_key = "scope="; diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 7f4d888c7..8f9c0a70c 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -2,7 +2,7 @@ use std::{convert::Infallible, fmt::Display, sync::Arc, time::Duration}; use bytes::Bytes; use futures::{StreamExt, future::BoxFuture}; -use http::{Method, Request, Response, header::ALLOW}; +use http::{HeaderMap, Method, Request, Response, header::ALLOW}; use http_body::Body; use http_body_util::{BodyExt, Full, combinators::BoxBody}; use tokio_stream::wrappers::ReceiverStream; @@ -29,8 +29,8 @@ use crate::{ }, }; -#[derive(Debug, Clone)] #[non_exhaustive] +#[derive(Debug, Clone)] pub struct StreamableHttpServerConfig { /// The ping message duration for SSE connections. pub sse_keep_alive: Option, @@ -49,6 +49,16 @@ pub struct StreamableHttpServerConfig { /// When this token is cancelled, all active sessions are terminated and /// the server stops accepting new requests. pub cancellation_token: CancellationToken, + /// Allowed hostnames or `host:port` authorities for inbound `Host` validation. + /// + /// By default, Streamable HTTP servers only accept loopback hosts to + /// prevent DNS rebinding attacks against locally running servers. Public + /// deployments should override this list with their own hostnames. + /// examples: + /// allowed_hosts = ["localhost", "127.0.0.1", "0.0.0.0"] + /// or with ports: + /// allowed_hosts = ["example.com", "example.com:8080"] + pub allowed_hosts: Vec, } impl Default for StreamableHttpServerConfig { @@ -59,11 +69,24 @@ impl Default for StreamableHttpServerConfig { stateful_mode: true, json_response: false, cancellation_token: CancellationToken::new(), + allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], } } } impl StreamableHttpServerConfig { + pub fn with_allowed_hosts( + mut self, + allowed_hosts: impl IntoIterator>, + ) -> Self { + self.allowed_hosts = allowed_hosts.into_iter().map(Into::into).collect(); + self + } + /// Disable allowed hosts. This will allow requests with any `Host` header, which is NOT recommended for public deployments. + pub fn disable_allowed_hosts(mut self) -> Self { + self.allowed_hosts.clear(); + self + } pub fn with_sse_keep_alive(mut self, duration: Option) -> Self { self.sse_keep_alive = duration; self @@ -130,6 +153,97 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box Ok(()) } +fn forbidden_response(message: impl Into) -> BoxResponse { + Response::builder() + .status(http::StatusCode::FORBIDDEN) + .body(Full::new(Bytes::from(message.into())).boxed()) + .expect("valid response") +} + +fn normalize_host(host: &str) -> String { + host.trim_matches('[') + .trim_matches(']') + .to_ascii_lowercase() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NormalizedAuthority { + host: String, + port: Option, +} + +fn normalize_authority(host: &str, port: Option) -> NormalizedAuthority { + NormalizedAuthority { + host: normalize_host(host), + port, + } +} + +fn parse_allowed_authority(allowed: &str) -> Option { + let allowed = allowed.trim(); + if allowed.is_empty() { + return None; + } + + if let Ok(authority) = http::uri::Authority::try_from(allowed) { + return Some(normalize_authority(authority.host(), authority.port_u16())); + } + + Some(normalize_authority(allowed, None)) +} + +fn host_is_allowed(host: &NormalizedAuthority, allowed_hosts: &[String]) -> bool { + if allowed_hosts.is_empty() { + // If the allowed hosts list is empty, allow all hosts (not recommended). + return true; + } + allowed_hosts + .iter() + .filter_map(|allowed| parse_allowed_authority(allowed)) + .any(|allowed| { + allowed.host == host.host + && match allowed.port { + Some(port) => host.port == Some(port), + None => true, + } + }) +} + +fn bad_request_response(message: &str) -> BoxResponse { + let body = Full::from(message.to_string()).boxed(); + + http::Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(body) + .expect("failed to build bad request response") +} + +fn parse_host_header(headers: &HeaderMap) -> Result { + let Some(host) = headers.get(http::header::HOST) else { + return Err(bad_request_response("Bad Request: missing Host header")); + }; + + let host = host + .to_str() + .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?; + let authority = http::uri::Authority::try_from(host) + .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?; + Ok(normalize_authority(authority.host(), authority.port_u16())) +} + +fn validate_dns_rebinding_headers( + headers: &HeaderMap, + config: &StreamableHttpServerConfig, +) -> Result<(), BoxResponse> { + let host = parse_host_header(headers)?; + if !host_is_allowed(&host, &config.allowed_hosts) { + return Err(forbidden_response("Forbidden: Host header is not allowed")); + } + + Ok(()) +} + /// # Streamable HTTP server /// /// An HTTP service that implements the @@ -279,6 +393,9 @@ where B: Body + Send + 'static, B::Error: Display, { + if let Err(response) = validate_dns_rebinding_headers(request.headers(), &self.config) { + return response; + } let method = request.method().clone(); let allowed_methods = match self.config.stateful_mode { true => "GET, POST, DELETE", diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 7d4316d3e..558ff623d 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -761,6 +761,7 @@ async fn test_server_rejects_unsupported_protocol_version() { .method(Method::POST) .header("Accept", "application/json, text/event-stream") .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") .body(Full::new(Bytes::from(init_body.to_string()))) .unwrap(); @@ -785,6 +786,7 @@ async fn test_server_rejects_unsupported_protocol_version() { .method(Method::POST) .header("Accept", "application/json, text/event-stream") .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") .header("mcp-session-id", &session_id) .header("mcp-protocol-version", "2025-03-26") .body(Full::new(Bytes::from(initialized_body.to_string()))) @@ -802,6 +804,7 @@ async fn test_server_rejects_unsupported_protocol_version() { .method(Method::POST) .header("Accept", "application/json, text/event-stream") .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") .header("mcp-session-id", &session_id) .header("mcp-protocol-version", "2025-03-26") .body(Full::new(Bytes::from(valid_body.to_string()))) @@ -823,6 +826,7 @@ async fn test_server_rejects_unsupported_protocol_version() { .method(Method::POST) .header("Accept", "application/json, text/event-stream") .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") .header("mcp-session-id", &session_id) .header("mcp-protocol-version", "9999-01-01") .body(Full::new(Bytes::from(invalid_body.to_string()))) @@ -844,6 +848,7 @@ async fn test_server_rejects_unsupported_protocol_version() { .method(Method::POST) .header("Accept", "application/json, text/event-stream") .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") .header("mcp-session-id", &session_id) .body(Full::new(Bytes::from(no_version_body.to_string()))) .unwrap(); @@ -870,3 +875,156 @@ fn test_protocol_version_utilities() { assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18)); } + +/// Integration test: Verify server validates only the Host header for DNS rebinding protection +#[tokio::test] +#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] +async fn test_server_validates_host_header_for_dns_rebinding_protection() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + let service = StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + let allowed_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .header("Origin", "http://localhost:8080") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(allowed_request).await; + assert_eq!(response.status(), http::StatusCode::OK); + + let bad_host_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "attacker.example") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(bad_host_request).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + + let ignored_origin_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .header("Origin", "http://attacker.example") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(ignored_origin_request).await; + assert_eq!(response.status(), http::StatusCode::OK); +} + +/// Integration test: Verify server can enforce an allowed Host port when configured +#[tokio::test] +#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] +async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + let service = StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default().with_allowed_hosts(["localhost:8080"]), + ); + + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + let allowed_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(allowed_request).await; + assert_eq!(response.status(), http::StatusCode::OK); + + let wrong_port_request = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:3000") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + + let response = service.handle(wrong_port_request).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); +} From cabf71aa74fa738c4fc54deefe1c7ba26b235867 Mon Sep 17 00:00:00 2001 From: Axel Date: Tue, 7 Apr 2026 16:28:23 +0530 Subject: [PATCH 122/333] feat(transport): add which_command for cross-platform executable resolution (#774) * feat(transport): add which_command for cross-platform executable resolution Adds a `which_command()` helper that resolves executable paths via the `which` crate before constructing a `tokio::process::Command`. This fixes Windows failures where `.cmd` shim scripts (e.g. `npx.cmd`) are not found by `Command::new()` without a fully-qualified path. Closes #456 * refactor(transport): move which_command behind opt-in feature flag Address review feedback: the `which` dependency is now gated behind a separate `which-command` feature flag instead of being bundled into `transport-child-process`. Users on Linux/macOS who don't need cross-platform executable resolution no longer pull in the extra crate. Also fixes the doc example import path to use the re-exported `rmcp::transport::which_command`. --- crates/rmcp/Cargo.toml | 7 +++ crates/rmcp/src/transport.rs | 2 + crates/rmcp/src/transport/child_process.rs | 50 ++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 57ce5651e..63a990e80 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -81,6 +81,9 @@ tower-service = { version = "0.3", optional = true } # for child process transport process-wrap = { version = "9.0", features = ["tokio1"], optional = true } +# for cross-platform executable path resolution +which = { version = "7", optional = true } + # for ws transport # tokio-tungstenite ={ version = "0.26", optional = true } @@ -163,6 +166,10 @@ transport-child-process = [ "tokio/process", "dep:process-wrap", ] +which-command = [ + "transport-child-process", + "dep:which", +] transport-streamable-http-server = [ "transport-streamable-http-server-session", "server-side-http", diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 04a8e1c6a..8969f1947 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -83,6 +83,8 @@ pub use worker::WorkerTransport; #[cfg(feature = "transport-child-process")] pub mod child_process; +#[cfg(feature = "which-command")] +pub use child_process::which_command; #[cfg(feature = "transport-child-process")] pub use child_process::{ConfigureCommandExt, TokioChildProcess}; diff --git a/crates/rmcp/src/transport/child_process.rs b/crates/rmcp/src/transport/child_process.rs index e33800b18..ebb6cc928 100644 --- a/crates/rmcp/src/transport/child_process.rs +++ b/crates/rmcp/src/transport/child_process.rs @@ -233,6 +233,56 @@ impl ConfigureCommandExt for tokio::process::Command { } } +/// Resolve the absolute path to an executable using the system `PATH`, +/// then return a [`tokio::process::Command`] pointing at it. +/// +/// This is especially useful on Windows where `.cmd` / `.exe` shim scripts +/// (e.g. `npx.cmd`) are not reliably found by [`tokio::process::Command`] +/// without a fully-qualified path. +/// +/// # Example +/// ```rust,no_run +/// use rmcp::transport::{which_command, ConfigureCommandExt}; +/// +/// # fn example() -> std::io::Result<()> { +/// let cmd = which_command("npx")? +/// .configure(|cmd| { +/// cmd.arg("-y").arg("@modelcontextprotocol/server-everything"); +/// }); +/// # Ok(()) +/// # } +/// ``` +#[cfg(feature = "which-command")] +pub fn which_command( + name: impl AsRef, +) -> std::io::Result { + let resolved = which::which(name.as_ref()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?; + Ok(tokio::process::Command::new(resolved)) +} + +#[cfg(feature = "which-command")] +#[cfg(test)] +mod tests_which { + #[test] + fn which_command_resolves_known_binary() { + // `ls` exists on every Unix system, `cmd` on Windows + #[cfg(unix)] + let result = super::which_command("ls"); + #[cfg(windows)] + let result = super::which_command("cmd"); + + assert!(result.is_ok()); + } + + #[test] + fn which_command_fails_for_nonexistent() { + let result = super::which_command("this_binary_definitely_does_not_exist_12345"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound); + } +} + #[cfg(unix)] #[cfg(test)] mod tests { From 929441e4432d11b3d12aaa2fe8b2591927d0a1f4 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:36:19 -0400 Subject: [PATCH 123/333] fix: default session keep_alive to 5 minutes (#780) --- .../streamable_http_server/session/local.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 2d2059c59..52f7962e9 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -1069,19 +1069,30 @@ impl Worker for LocalSessionWorker { pub struct SessionConfig { /// the capacity of the channel for the session. Default is 16. pub channel_capacity: usize, - /// if set, the session will be closed after this duration of inactivity. + /// The session will be closed after this duration of inactivity. + /// + /// This serves as a safety net for cleaning up sessions whose HTTP + /// connections have silently dropped (e.g., due to an HTTP/2 + /// `RST_STREAM`). Without a timeout, such sessions become zombies: + /// the session worker keeps running indefinitely because the session + /// handle's sender is still held in the session manager, preventing + /// the worker's event channel from closing. + /// + /// Defaults to 5 minutes. Set to `None` to disable (not recommended + /// for long-running servers behind proxies). pub keep_alive: Option, } impl SessionConfig { pub const DEFAULT_CHANNEL_CAPACITY: usize = 16; + pub const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(300); } impl Default for SessionConfig { fn default() -> Self { Self { channel_capacity: Self::DEFAULT_CHANNEL_CAPACITY, - keep_alive: None, + keep_alive: Some(Self::DEFAULT_KEEP_ALIVE), } } } From d98248ac22f2a0dcf6cddc882d7c6bf3594ec00a Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:39:00 -0400 Subject: [PATCH 124/333] ci: add --locked to release-plz install (#786) --- .github/workflows/release-plz.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 580c31aec..9d7339039 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -28,7 +28,7 @@ jobs: # Using fork until semver_check_features support is released upstream. # See: https://github.com/release-plz/release-plz/pull/2757 - name: Install release-plz from fork - run: cargo install --git https://github.com/DaleSeo/release-plz --branch feat/semver-check-features release-plz + run: cargo install --locked --git https://github.com/DaleSeo/release-plz --branch feat/semver-check-features release-plz - name: Run release-plz release run: release-plz release env: @@ -56,7 +56,7 @@ jobs: # Using fork until semver_check_features support is released upstream. # See: https://github.com/release-plz/release-plz/pull/2757 - name: Install release-plz from fork - run: cargo install --git https://github.com/DaleSeo/release-plz --branch feat/semver-check-features release-plz + run: cargo install --locked --git https://github.com/DaleSeo/release-plz --branch feat/semver-check-features release-plz - name: Run release-plz release-pr run: release-plz release-pr env: From 5891b45162cadecfe98f2d31e38573efcd997c33 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:39:18 -0400 Subject: [PATCH 125/333] refactor: unify IntoCallToolResult Result impls (#787) --- crates/rmcp/src/handler/server/tool.rs | 29 ++++++++++--------- .../rmcp/src/handler/server/wrapper/json.rs | 17 +---------- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index 03beface4..2b9fe62af 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -85,20 +85,29 @@ impl IntoCallToolResult for T { } } -impl IntoCallToolResult for Result { +impl IntoCallToolResult for CallToolResult { fn into_call_tool_result(self) -> Result { - match self { - Ok(value) => Ok(CallToolResult::success(value.into_contents())), - Err(error) => Ok(CallToolResult::error(error.into_contents())), - } + Ok(self) + } +} + +impl IntoCallToolResult for crate::ErrorData { + fn into_call_tool_result(self) -> Result { + Err(self) } } -impl IntoCallToolResult for Result { +impl IntoCallToolResult for Result { fn into_call_tool_result(self) -> Result { match self { Ok(value) => value.into_call_tool_result(), - Err(error) => Err(error), + Err(error) => match error.into_call_tool_result() { + Ok(mut result) => { + result.is_error = Some(true); + Ok(result) + } + Err(e) => Err(e), + }, } } } @@ -139,12 +148,6 @@ where } } -impl IntoCallToolResult for Result { - fn into_call_tool_result(self) -> Result { - self - } -} - pub trait CallToolHandler { fn call( self, diff --git a/crates/rmcp/src/handler/server/wrapper/json.rs b/crates/rmcp/src/handler/server/wrapper/json.rs index bc2170a3b..c03fbd032 100644 --- a/crates/rmcp/src/handler/server/wrapper/json.rs +++ b/crates/rmcp/src/handler/server/wrapper/json.rs @@ -3,10 +3,7 @@ use std::borrow::Cow; use schemars::JsonSchema; use serde::Serialize; -use crate::{ - handler::server::tool::IntoCallToolResult, - model::{CallToolResult, IntoContents}, -}; +use crate::{handler::server::tool::IntoCallToolResult, model::CallToolResult}; /// Json wrapper for structured output /// @@ -41,15 +38,3 @@ impl IntoCallToolResult for Json { Ok(CallToolResult::structured(value)) } } - -// Implementation for Result, E> -impl IntoCallToolResult - for Result, E> -{ - fn into_call_tool_result(self) -> Result { - match self { - Ok(value) => value.into_call_tool_result(), - Err(error) => Ok(CallToolResult::error(error.into_contents())), - } - } -} From be321a4abe83ab89ecae3794da8de1a6b091ed29 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:06:26 -0400 Subject: [PATCH 126/333] feat(macros): auto-generate get_info and default router (#785) * feat(macros): auto-generate get_info and default router * docs: simplify examples and docs with new defaults * feat(macros): add tool_router(server_handler) to elide separate #[tool_handler] impl * docs: add Tools section to README and simplify calculator examples with server_handler --- README.md | 71 ++++++ crates/rmcp-macros/README.md | 31 ++- crates/rmcp-macros/src/common.rs | 23 +- crates/rmcp-macros/src/lib.rs | 119 ++++++---- crates/rmcp-macros/src/prompt_handler.rs | 18 +- crates/rmcp-macros/src/task_handler.rs | 27 ++- crates/rmcp-macros/src/tool_handler.rs | 168 +++++++++++--- crates/rmcp-macros/src/tool_router.rs | 85 +++++-- crates/rmcp/src/handler/server/router/tool.rs | 12 +- crates/rmcp/tests/test_prompt_handler.rs | 6 +- .../rmcp/tests/test_tool_macro_annotations.rs | 2 +- crates/rmcp/tests/test_tool_macros.rs | 210 +++++++++++++++++- docs/readme/README.zh-cn.md | 71 ++++++ examples/servers/src/calculator_stdio.rs | 2 +- examples/servers/src/common/calculator.rs | 27 +-- .../servers/src/common/generic_service.rs | 16 +- examples/servers/src/common/progress_demo.rs | 6 +- examples/transport/src/common/calculator.rs | 27 +-- examples/transport/src/http_upgrade.rs | 2 +- examples/transport/src/named-pipe.rs | 2 +- examples/transport/src/tcp.rs | 2 +- examples/transport/src/unix_socket.rs | 2 +- examples/transport/src/websocket.rs | 2 +- examples/wasi/src/calculator.rs | 39 +--- examples/wasi/src/lib.rs | 5 +- 25 files changed, 742 insertions(+), 233 deletions(-) diff --git a/README.md b/README.md index 10378b607..670007af6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ For the full MCP specification, see [modelcontextprotocol.io](https://modelconte ## Table of Contents - [Usage](#usage) +- [Tools](#tools) - [Resources](#resources) - [Prompts](#prompts) - [Sampling](#sampling) @@ -129,6 +130,76 @@ let quit_reason = server.cancel().await?; --- +## Tools + +Tools let servers expose callable functions to clients. Each tool has a name, description, and a JSON Schema for its parameters. Clients discover tools via `list_tools` and invoke them via `call_tool`. + +**MCP Spec:** [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) + +### Server-side + +The `#[tool]`, `#[tool_router]`, and `#[tool_handler]` macros handle all the wiring. For a tools-only server you can use `#[tool_router(server_handler)]` to skip the separate `ServerHandler` impl: + +```rust,ignore +use rmcp::{tool, tool_router, ServiceExt, transport::stdio}; + +#[derive(Clone)] +struct Calculator; + +#[tool_router(server_handler)] +impl Calculator { + #[tool(description = "Add two numbers")] + fn add(&self, #[tool(param)] a: i32, #[tool(param)] b: i32) -> String { + (a + b).to_string() + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let service = Calculator.serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} +``` + +When you need custom server metadata or multiple capabilities (tools + prompts), use explicit `#[tool_handler]`: + +```rust,ignore +use rmcp::{tool, tool_router, tool_handler, ServerHandler, ServiceExt}; + +#[derive(Clone)] +struct Calculator; + +#[tool_router] +impl Calculator { + #[tool(description = "Add two numbers")] + fn add(&self, #[tool(param)] a: i32, #[tool(param)] b: i32) -> String { + (a + b).to_string() + } +} + +#[tool_handler(name = "calculator", version = "1.0.0", instructions = "A simple calculator")] +impl ServerHandler for Calculator {} +``` + +See [`crates/rmcp-macros`](crates/rmcp-macros/README.md) for full macro documentation. + +### Client-side + +```rust,ignore +use rmcp::model::CallToolRequestParams; + +// List all tools +let tools = client.list_all_tools().await?; + +// Call a tool by name +let result = client.call_tool(CallToolRequestParams::new("add")).await?; +``` + +**Example:** [`examples/servers/src/common/calculator.rs`](examples/servers/src/common/calculator.rs) (server), [`examples/servers/src/calculator_stdio.rs`](examples/servers/src/calculator_stdio.rs) (stdio runner) + +--- + ## Resources Resources let servers expose data (files, database records, API responses) that clients can read. Each resource is identified by a URI and returns content as text or binary (base64-encoded) data. Resource templates allow servers to declare URI patterns with dynamic parameters. diff --git a/crates/rmcp-macros/README.md b/crates/rmcp-macros/README.md index 3aa759aa1..cd9262edc 100644 --- a/crates/rmcp-macros/README.md +++ b/crates/rmcp-macros/README.md @@ -20,7 +20,7 @@ For **getting started** and **full MCP feature documentation**, see the [main RE | Macro | Description | |-------|-------------| | [`#[tool]`][tool] | Mark a function as an MCP tool handler | -| [`#[tool_router]`][tool_router] | Generate a tool router from an impl block | +| [`#[tool_router]`][tool_router] | Generate a tool router from an impl block (optional `server_handler` flag elides a separate `#[tool_handler]` block for tools-only servers) | | [`#[tool_handler]`][tool_handler] | Generate `call_tool` and `list_tools` handler methods | | [`#[prompt]`][prompt] | Mark a function as an MCP prompt handler | | [`#[prompt_router]`][prompt_router] | Generate a prompt router from an impl block | @@ -37,13 +37,30 @@ For **getting started** and **full MCP feature documentation**, see the [main RE ## Quick Example +Tools-only server with a single `impl` block (`server_handler` expands `#[tool_handler]` in a second macro pass): + ```rust,ignore -use rmcp::{tool, tool_router, tool_handler, ServerHandler, model::*}; +use rmcp::{tool, tool_router}; #[derive(Clone)] -struct MyServer { - tool_router: rmcp::handler::server::tool::ToolRouter, +struct MyServer; + +#[tool_router(server_handler)] +impl MyServer { + #[tool(description = "Say hello")] + async fn hello(&self) -> String { + "Hello, world!".into() + } } +``` + +If you need custom `#[tool_handler(...)]` arguments (e.g. `instructions`, `name`, or stacked `#[prompt_handler]` on the same `impl ServerHandler`), use two blocks instead: + +```rust,ignore +use rmcp::{tool, tool_router, tool_handler, ServerHandler}; + +#[derive(Clone)] +struct MyServer; #[tool_router] impl MyServer { @@ -54,11 +71,7 @@ impl MyServer { } #[tool_handler] -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() - } -} +impl ServerHandler for MyServer {} ``` See the [full documentation](https://docs.rs/rmcp-macros) for detailed usage of each macro. diff --git a/crates/rmcp-macros/src/common.rs b/crates/rmcp-macros/src/common.rs index 877f89955..0ca2fdae2 100644 --- a/crates/rmcp-macros/src/common.rs +++ b/crates/rmcp-macros/src/common.rs @@ -1,7 +1,7 @@ //! Common utilities shared between different macro implementations use quote::quote; -use syn::{Attribute, Expr, FnArg, ImplItemFn, Signature, Type}; +use syn::{Attribute, Expr, FnArg, ImplItem, ImplItemFn, ItemImpl, Signature, Type}; /// Parse a None expression pub fn none_expr() -> syn::Result { @@ -75,3 +75,24 @@ pub fn find_parameters_type_in_sig(sig: &Signature) -> Option> { pub fn find_parameters_type_impl(fn_item: &ImplItemFn) -> Option> { find_parameters_type_in_sig(&fn_item.sig) } + +/// Check whether an `impl` block already contains a method with the given name. +pub fn has_method(name: &str, item_impl: &ItemImpl) -> bool { + item_impl.items.iter().any(|item| match item { + ImplItem::Fn(func) => func.sig.ident == name, + _ => false, + }) +} + +/// Check whether an `impl` block carries a sibling handler attribute (e.g. +/// `#[prompt_handler]` visible from within `#[tool_handler]`). +/// +/// Matches both bare (`prompt_handler`) and path-qualified (`rmcp::prompt_handler`) forms. +pub fn has_sibling_handler(item_impl: &ItemImpl, handler_name: &str) -> bool { + item_impl.attrs.iter().any(|attr| { + attr.path() + .segments + .last() + .is_some_and(|seg| seg.ident == handler_name) + }) +} diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index b04255af5..e721338d9 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -47,13 +47,16 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream { /// /// It creates a function that returns a `ToolRouter` instance. /// -/// In most case, you need to add a field for handler to store the router information and initialize it when creating handler, or store it with a static variable. +/// The generated function is used by `#[tool_handler]` by default (via `Self::tool_router()`), +/// so in most cases you do not need to store the router in a field. +/// /// ## Usage /// -/// | field | type | usage | -/// | :- | :- | :- | -/// | `router` | `Ident` | The name of the router function to be generated. Defaults to `tool_router`. | -/// | `vis` | `Visibility` | The visibility of the generated router function. Defaults to empty. | +/// | field | type | usage | +/// | :- | :- | :- | +/// | `router` | `Ident` | The name of the router function to be generated. Defaults to `tool_router`. | +/// | `vis` | `Visibility` | The visibility of the generated router function. Defaults to empty. | +/// | `server_handler` | `flag` | When set, also emits `#[::rmcp::tool_handler]` on `impl ServerHandler for Self` so you can omit a separate `#[tool_handler]` block. | /// /// ## Example /// @@ -62,18 +65,33 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream { /// impl MyToolHandler { /// #[tool] /// pub fn my_tool() { -/// -/// } /// -/// pub fn new() -> Self { -/// Self { -/// // the default name of tool router will be `tool_router` -/// tool_router: Self::tool_router(), -/// } /// } /// } +/// +/// // #[tool_handler] calls Self::tool_router() automatically +/// #[tool_handler] +/// impl ServerHandler for MyToolHandler {} /// ``` /// +/// ### Eliding `#[tool_handler]` +/// +/// For a tools-only server, pass `server_handler` so the `impl ServerHandler` block is not written by hand: +/// +/// ```rust,ignore +/// #[tool_router(server_handler)] +/// impl MyToolHandler { +/// #[tool] +/// fn my_tool() {} +/// } +/// ``` +/// +/// This expands in two steps: first `#[tool_router]` emits the inherent impl plus +/// `#[::rmcp::tool_handler] impl ServerHandler for MyToolHandler {}`, then `#[tool_handler]` +/// fills in `call_tool`, `list_tools`, `get_info`, and related methods. If you combine tools with +/// prompts or tasks on the **same** `impl ServerHandler` block (stacked `#[tool_handler]` / +/// `#[prompt_handler]` attributes), keep using an explicit `#[tool_handler]` impl instead of `server_handler`. +/// /// Or specify the visibility and router name, which would be helpful when you want to combine multiple routers into one: /// /// ```rust,ignore @@ -114,50 +132,62 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream { /// # tool_handler /// -/// This macro will generate the handler for `tool_call` and `list_tools` methods in the implementation block, by using an existing `ToolRouter` instance. +/// This macro generates the `call_tool`, `list_tools`, `get_tool`, and (optionally) +/// `get_info` methods for a `ServerHandler` implementation, using a `ToolRouter`. /// /// ## Usage /// -/// | field | type | usage | -/// | :- | :- | :- | -/// | `router` | `Expr` | The expression to access the `ToolRouter` instance. Defaults to `self.tool_router`. | -/// ## Example +/// | field | type | usage | +/// | :- | :- | :- | +/// | `router` | `Expr` | The expression to access the `ToolRouter` instance. Defaults to `Self::tool_router()`. | +/// | `meta` | `Expr` | Optional metadata for `ListToolsResult`. | +/// | `name` | `String` | Custom server name. Defaults to `CARGO_CRATE_NAME`. | +/// | `version` | `String` | Custom server version. Defaults to `CARGO_PKG_VERSION`. | +/// | `instructions` | `String` | Optional human-readable instructions about using this server. | +/// +/// ## Minimal example (no boilerplate) +/// +/// The macro automatically generates `get_info()` with tools capability enabled +/// and reads the server name/version from `Cargo.toml`: +/// /// ```rust,ignore -/// #[tool_handler] -/// impl ServerHandler for MyToolHandler { -/// // ...implement other handler +/// struct TimeServer; +/// +/// #[tool_router] +/// impl TimeServer { +/// #[tool(description = "Get current time")] +/// async fn get_time(&self) -> String { "12:00".into() } /// } +/// +/// #[tool_handler] +/// impl ServerHandler for TimeServer {} /// ``` /// -/// or using a custom router expression: +/// ## Custom server info +/// +/// ```rust,ignore +/// #[tool_handler(name = "my-server", version = "1.0.0", instructions = "A helpful server")] +/// impl ServerHandler for MyToolHandler {} +/// ``` +/// +/// ## Custom router expression +/// /// ```rust,ignore -/// #[tool_handler(router = self.get_router().await)] +/// #[tool_handler(router = self.tool_router)] /// impl ServerHandler for MyToolHandler { /// // ...implement other handler /// } /// ``` /// -/// ## Explain +/// ## Manual `get_info()` +/// +/// If you provide your own `get_info()`, the macro will not generate one: /// -/// This macro will be expended to something like this: /// ```rust,ignore +/// #[tool_handler] /// impl ServerHandler for MyToolHandler { -/// async fn call_tool( -/// &self, -/// request: CallToolRequestParams, -/// context: RequestContext, -/// ) -> Result { -/// let tcc = ToolCallContext::new(self, request, context); -/// self.tool_router.call(tcc).await -/// } -/// -/// async fn list_tools( -/// &self, -/// _request: Option, -/// _context: RequestContext, -/// ) -> Result { -/// let items = self.tool_router.list_all(); -/// Ok(ListToolsResult::with_all_items(items)) +/// fn get_info(&self) -> ServerInfo { +/// ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) /// } /// } /// ``` @@ -237,13 +267,16 @@ pub fn prompt_router(attr: TokenStream, input: TokenStream) -> TokenStream { /// # prompt_handler /// -/// This macro generates handler methods for `get_prompt` and `list_prompts` in the implementation block, using an existing `PromptRouter` instance. +/// This macro generates handler methods for `get_prompt` and `list_prompts` in the +/// implementation block, using a `PromptRouter`. It also auto-generates `get_info()` +/// with prompts capability enabled if not already provided. /// /// ## Usage /// /// | field | type | usage | /// | :- | :- | :- | -/// | `router` | `Expr` | The expression to access the `PromptRouter` instance. Defaults to `self.prompt_router`. | +/// | `router` | `Expr` | The expression to access the `PromptRouter` instance. Defaults to `Self::prompt_router()`. | +/// | `meta` | `Expr` | Optional metadata for `ListPromptsResult`. | /// /// ## Example /// ```rust,ignore @@ -255,7 +288,7 @@ pub fn prompt_router(attr: TokenStream, input: TokenStream) -> TokenStream { /// /// or using a custom router expression: /// ```rust,ignore -/// #[prompt_handler(router = self.get_prompt_router())] +/// #[prompt_handler(router = self.prompt_router)] /// impl ServerHandler for MyPromptHandler { /// // ...implement other handler methods /// } diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 7e534f92f..4f2541ac6 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -3,6 +3,11 @@ use proc_macro2::TokenStream; use quote::quote; use syn::{Expr, ImplItem, ItemImpl, parse_quote}; +use crate::{ + common::{has_method, has_sibling_handler}, + tool_handler::{CallerCapability, build_get_info}, +}; + #[derive(FromMeta, Debug, Default)] #[darling(default)] pub struct PromptHandlerAttribute { @@ -22,7 +27,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result syn::Result { let attr_args = NestedMeta::parse_meta_list(attr)?; let TaskHandlerAttribute { processor } = TaskHandlerAttribute::from_list(&attr_args)?; - let mut item_impl = syn::parse2::(input.clone())?; - - let has_method = |name: &str, item_impl: &ItemImpl| -> bool { - item_impl.items.iter().any(|item| match item { - ImplItem::Fn(func) => func.sig.ident == name, - _ => false, - }) - }; + let mut item_impl = syn::parse2::(input)?; if !has_method("list_tasks", &item_impl) { let list_fn = quote! { @@ -262,5 +257,21 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result(cancel_fn)?); } + // Auto-generate get_info() if not already provided and no sibling tool/prompt handler + // will generate it (they take priority since they run as outer attributes). + if !has_method("get_info", &item_impl) + && !has_sibling_handler(&item_impl, "tool_handler") + && !has_sibling_handler(&item_impl, "prompt_handler") + { + let get_info_fn = crate::tool_handler::build_get_info( + &item_impl, + None, + None, + None, + crate::tool_handler::CallerCapability::Tasks, + )?; + item_impl.items.push(get_info_fn); + } + Ok(item_impl.into_token_stream()) } diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index e28c0ca47..0cb323b5a 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -3,39 +3,57 @@ use proc_macro2::TokenStream; use quote::{ToTokens, quote}; use syn::{Expr, ImplItem, ItemImpl}; +use crate::common::{has_method, has_sibling_handler}; + #[derive(FromMeta)] #[darling(default)] pub struct ToolHandlerAttribute { pub router: Expr, pub meta: Option, + pub name: Option, + pub version: Option, + pub instructions: Option, } impl Default for ToolHandlerAttribute { fn default() -> Self { Self { router: syn::parse2(quote! { - self.tool_router + Self::tool_router() }) .unwrap(), meta: None, + name: None, + version: None, + instructions: None, } } } pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result { let attr_args = NestedMeta::parse_meta_list(attr)?; - let ToolHandlerAttribute { router, meta } = ToolHandlerAttribute::from_list(&attr_args)?; - let mut item_impl = syn::parse2::(input.clone())?; - let tool_call_fn = quote! { - async fn call_tool( - &self, - request: rmcp::model::CallToolRequestParams, - context: rmcp::service::RequestContext, - ) -> Result { - let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); - #router.call(tcc).await - } - }; + let ToolHandlerAttribute { + router, + meta, + name, + version, + instructions, + } = ToolHandlerAttribute::from_list(&attr_args)?; + let mut item_impl = syn::parse2::(input)?; + + if !has_method("call_tool", &item_impl) { + let tool_call_fn = syn::parse2::(quote! { + async fn call_tool( + &self, + request: rmcp::model::CallToolRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + #router.call(tcc).await + } + })?; + item_impl.items.push(tool_call_fn); + } let result_meta = if let Some(meta) = meta { quote! { Some(#meta) } @@ -43,31 +61,109 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - _context: rmcp::service::RequestContext, - ) -> Result { - Ok(rmcp::model::ListToolsResult{ - tools: #router.list_all(), - meta: #result_meta, - next_cursor: None, - }) - } - }; + if !has_method("list_tools", &item_impl) { + let tool_list_fn = syn::parse2::(quote! { + async fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(rmcp::model::ListToolsResult{ + tools: #router.list_all(), + meta: #result_meta, + next_cursor: None, + }) + } + })?; + item_impl.items.push(tool_list_fn); + } + + if !has_method("get_tool", &item_impl) { + let get_tool_fn = syn::parse2::(quote! { + fn get_tool(&self, name: &str) -> Option { + #router.get(name).cloned() + } + })?; + item_impl.items.push(get_tool_fn); + } + + // Auto-generate get_info() if not already provided + if !has_method("get_info", &item_impl) { + let get_info_fn = build_get_info( + &item_impl, + name, + version, + instructions, + CallerCapability::Tools, + )?; + item_impl.items.push(get_info_fn); + } + + Ok(item_impl.into_token_stream()) +} + +/// Which handler macro is generating `get_info()`. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum CallerCapability { + Tools, + Prompts, + Tasks, +} + +/// Build a `get_info()` method that returns `ServerInfo` with the appropriate capabilities. +/// +/// The caller declares its own capability via `caller`. Sibling handler attributes +/// (`prompt_handler`, `task_handler`, `tool_handler`) are detected automatically +/// and their capabilities are included. +pub(crate) fn build_get_info( + item_impl: &ItemImpl, + name: Option, + version: Option, + instructions: Option, + caller: CallerCapability, +) -> syn::Result { + let has_tools = + caller == CallerCapability::Tools || has_sibling_handler(item_impl, "tool_handler"); + let has_prompts = + caller == CallerCapability::Prompts || has_sibling_handler(item_impl, "prompt_handler"); + let has_tasks = + caller == CallerCapability::Tasks || has_sibling_handler(item_impl, "task_handler"); + + let mut capability_calls = Vec::new(); + if has_tools { + capability_calls.push(quote! { .enable_tools() }); + } + if has_prompts { + capability_calls.push(quote! { .enable_prompts() }); + } + if has_tasks { + capability_calls.push(quote! { .enable_tasks() }); + } - let get_tool_fn = quote! { - fn get_tool(&self, name: &str) -> Option { - #router.get(name).cloned() + let server_info_expr = match (name, version) { + (Some(n), Some(v)) => quote! { rmcp::model::Implementation::new(#n, #v) }, + (Some(n), None) => { + quote! { rmcp::model::Implementation::new(#n, env!("CARGO_PKG_VERSION")) } } + (None, Some(v)) => { + quote! { rmcp::model::Implementation::new(env!("CARGO_CRATE_NAME"), #v) } + } + (None, None) => quote! { rmcp::model::Implementation::from_build_env() }, }; - let tool_call_fn = syn::parse2::(tool_call_fn)?; - let tool_list_fn = syn::parse2::(tool_list_fn)?; - let get_tool_fn = syn::parse2::(get_tool_fn)?; - item_impl.items.push(tool_call_fn); - item_impl.items.push(tool_list_fn); - item_impl.items.push(get_tool_fn); - Ok(item_impl.into_token_stream()) + let mut builder_calls = vec![quote! { .with_server_info(#server_info_expr) }]; + if let Some(i) = instructions { + builder_calls.push(quote! { .with_instructions(#i.to_string()) }); + } + + syn::parse2::(quote! { + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo::new( + rmcp::model::ServerCapabilities::builder() + #(#capability_calls)* + .build() + ) + #(#builder_calls)* + } + }) } diff --git a/crates/rmcp-macros/src/tool_router.rs b/crates/rmcp-macros/src/tool_router.rs index 91fa8d9db..edc8630b4 100644 --- a/crates/rmcp-macros/src/tool_router.rs +++ b/crates/rmcp-macros/src/tool_router.rs @@ -1,10 +1,8 @@ -//! ```ignore -//! #[rmcp::tool_router(router)] -//! impl Handler { -//! -//! } -//! ``` +//! Procedural macro implementation for `#[tool_router]` (see `lib.rs`). //! +//! When `server_handler` is set, we emit a second `impl ServerHandler` item decorated with +//! `#[::rmcp::tool_handler]` so `tool_handler` expands in a later proc-macro pass—keeping all +//! tool dispatch and `get_info` logic in `tool_handler.rs` without duplicating it here. use darling::{FromMeta, ast::NestedMeta}; use proc_macro2::TokenStream; @@ -16,6 +14,9 @@ use syn::{Ident, ImplItem, ItemImpl, Visibility}; pub struct ToolRouterAttribute { pub router: Ident, pub vis: Option, + /// When set, also emit `#[::rmcp::tool_handler]` on `impl ServerHandler for Self` so callers + /// can skip a separate `#[tool_handler]` block (expanded in a later macro pass). + pub server_handler: bool, } impl Default for ToolRouterAttribute { @@ -23,14 +24,19 @@ impl Default for ToolRouterAttribute { Self { router: format_ident!("tool_router"), vis: None, + server_handler: false, } } } pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result { let attr_args = NestedMeta::parse_meta_list(attr)?; - let ToolRouterAttribute { router, vis } = ToolRouterAttribute::from_list(&attr_args)?; - let mut item_impl = syn::parse2::(input.clone())?; + let ToolRouterAttribute { + router, + vis, + server_handler, + } = ToolRouterAttribute::from_list(&attr_args)?; + let mut item_impl = syn::parse2::(input)?; // find all function marked with `#[rmcp::tool]` let tool_attr_fns: Vec<_> = item_impl .items @@ -52,7 +58,7 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result Result<(), Box> { + fn tool_router_attribute_parses_router_visibility_and_defaults_server_handler_off() + -> syn::Result<()> { let attr = quote! { router = test_router, vis = "pub(crate)" }; let attr_args = NestedMeta::parse_meta_list(attr)?; - let ToolRouterAttribute { router, vis } = ToolRouterAttribute::from_list(&attr_args)?; - println!("router: {}", router); - if let Some(vis) = vis { - println!("visibility: {}", vis.to_token_stream()); - } else { - println!("visibility: None"); - } + let ToolRouterAttribute { + router, + vis, + server_handler, + } = ToolRouterAttribute::from_list(&attr_args)?; + assert_eq!(router.to_string(), "test_router"); + assert!(vis.is_some(), "vis = \"pub(crate)\" should parse"); + assert!( + !server_handler, + "server_handler should default to false when omitted" + ); + Ok(()) + } + + #[test] + fn tool_router_attribute_parses_server_handler_flag() -> syn::Result<()> { + let attr = quote! { + router = custom_router, + server_handler + }; + let attr_args = NestedMeta::parse_meta_list(attr)?; + let ToolRouterAttribute { + router, + server_handler, + .. + } = ToolRouterAttribute::from_list(&attr_args)?; + assert_eq!(router.to_string(), "custom_router"); + assert!(server_handler); Ok(()) } } diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 87f0db6c3..79a228ffe 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -10,9 +10,8 @@ //! # schemars //! # }; //! # use serde::{Serialize, Deserialize}; -//! struct Server { -//! tool_router: ToolRouter, -//! } +//! struct Server; +//! //! #[derive(Deserialize, schemars::JsonSchema, Default)] //! struct AddParameter { //! left: usize, @@ -22,7 +21,7 @@ //! struct AddOutput { //! sum: usize //! } -//! #[tool_router] +//! #[tool_router(server_handler)] //! impl Server { //! #[tool(name = "adder", description = "Modular add two integers")] //! fn add( @@ -34,6 +33,11 @@ //! } //! ``` //! +//! The `server_handler` flag emits `#[tool_handler]` for you (tools-only servers). For custom +//! `#[tool_handler(...)]` options or multiple handler macros on one `impl ServerHandler`, write +//! `#[tool_router]` and `#[tool_handler] impl ServerHandler for ...` explicitly—see +//! [`tool_router`][crate::tool_router] and [`tool_handler`][crate::tool_handler]. +//! //! Using the macro-based code pattern above is suitable for small MCP servers with simple interfaces. //! When the business logic become larger, it is recommended that each tool should reside //! in individual file, combined into MCP server using [`SyncTool`] and [`AsyncTool`] traits. diff --git a/crates/rmcp/tests/test_prompt_handler.rs b/crates/rmcp/tests/test_prompt_handler.rs index ca4418bcc..6288cddc0 100644 --- a/crates/rmcp/tests/test_prompt_handler.rs +++ b/crates/rmcp/tests/test_prompt_handler.rs @@ -30,7 +30,7 @@ impl TestPromptServer { } } -#[prompt_handler] +#[prompt_handler(router = self.prompt_router)] impl ServerHandler for TestPromptServer {} #[derive(Debug, Clone)] @@ -80,7 +80,7 @@ impl GenericPromptServer { } } -#[prompt_handler] +#[prompt_handler(router = self.prompt_router)] impl ServerHandler for GenericPromptServer {} #[test] @@ -148,7 +148,7 @@ mod nested { } } - #[prompt_handler] + #[prompt_handler(router = self.prompt_router)] impl ServerHandler for NestedServer {} #[test] diff --git a/crates/rmcp/tests/test_tool_macro_annotations.rs b/crates/rmcp/tests/test_tool_macro_annotations.rs index e945a10fe..34b17625f 100644 --- a/crates/rmcp/tests/test_tool_macro_annotations.rs +++ b/crates/rmcp/tests/test_tool_macro_annotations.rs @@ -19,7 +19,7 @@ mod tests { format!("Direct: {}", input) } } - #[tool_handler] + #[tool_handler(router = self.tool_router)] impl ServerHandler for AnnotatedServer {} #[test] diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index 450a00033..902b314f1 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use rmcp::{ ClientHandler, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParams, ClientInfo}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, tool, tool_handler, tool_router, }; use schemars::JsonSchema; @@ -365,3 +365,211 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { server_handle.await??; Ok(()) } + +// --- Tests for field-free minimal server pattern (issue #711) --- + +/// Minimal server: no tool_router field, no new(), no get_info(). +#[derive(Debug, Clone)] +pub struct MinimalServer; + +#[tool_router] +impl MinimalServer { + #[tool(description = "Say hello")] + fn hello(&self) -> String { + "hello".to_string() + } +} + +#[tool_handler] +impl ServerHandler for MinimalServer {} + +#[test] +fn test_minimal_server_get_info_auto_generated() { + let server = MinimalServer; + let info = server.get_info(); + + assert!( + info.capabilities.tools.is_some(), + "tools capability should be enabled" + ); + assert!( + info.capabilities.prompts.is_none(), + "prompts should not be auto-enabled" + ); + assert!( + info.capabilities.tasks.is_none(), + "tasks should not be auto-enabled" + ); + assert!( + !info.server_info.name.is_empty(), + "server name should not be empty" + ); + assert!( + !info.server_info.version.is_empty(), + "server version should not be empty" + ); + assert!( + info.instructions.is_none(), + "instructions should be None by default" + ); +} + +#[tokio::test] +async fn test_minimal_server_tool_call() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + MinimalServer + .serve(server_transport) + .await? + .waiting() + .await?; + anyhow::Ok(()) + }); + + let client = DummyClientHandler::default() + .serve(client_transport) + .await?; + + let result = client + .call_tool(CallToolRequestParams::new("hello")) + .await?; + + let text = result + .content + .first() + .and_then(|c| c.raw.as_text()) + .map(|t| t.text.as_str()) + .expect("Expected text content"); + + assert_eq!(text, "hello"); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +/// Same minimal pattern as [`MinimalServer`], but `#[tool_handler]` is omitted using +/// `#[tool_router(server_handler)]` (emits `#[tool_handler]` for a second macro pass). +#[derive(Debug, Clone)] +pub struct ElidedToolHandlerServer; + +#[tool_router(server_handler)] +impl ElidedToolHandlerServer { + #[tool(description = "Say hi")] + fn hi(&self) -> String { + "hi".to_string() + } +} + +#[test] +fn test_tool_router_server_handler_flag_matches_minimal_server_get_info() { + let server = ElidedToolHandlerServer; + let info = server.get_info(); + + assert!(info.capabilities.tools.is_some()); + assert!( + info.capabilities.prompts.is_none(), + "prompts should not be auto-enabled" + ); +} + +#[tokio::test] +async fn test_tool_router_server_handler_flag_end_to_end_tool_call() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + ElidedToolHandlerServer + .serve(server_transport) + .await? + .waiting() + .await?; + anyhow::Ok(()) + }); + + let client = DummyClientHandler::default() + .serve(client_transport) + .await?; + + let result = client.call_tool(CallToolRequestParams::new("hi")).await?; + + let text = result + .content + .first() + .and_then(|c| c.raw.as_text()) + .map(|t| t.text.as_str()) + .expect("Expected text content"); + + assert_eq!(text, "hi"); + + client.cancel().await?; + server_handle.await??; + Ok(()) +} + +/// Server with custom name/version/instructions via tool_handler attributes. +#[derive(Debug, Clone)] +pub struct CustomInfoServer; + +#[tool_router] +impl CustomInfoServer { + #[tool(description = "Ping")] + fn ping(&self) -> String { + "pong".to_string() + } +} + +#[tool_handler( + name = "my-custom-server", + version = "2.0.0", + instructions = "A custom server" +)] +impl ServerHandler for CustomInfoServer {} + +#[test] +fn test_custom_info_server() { + let server = CustomInfoServer; + let info = server.get_info(); + + assert_eq!(info.server_info.name, "my-custom-server"); + assert_eq!(info.server_info.version, "2.0.0"); + assert_eq!(info.instructions.as_deref(), Some("A custom server")); + assert!(info.capabilities.tools.is_some()); +} + +/// Server that provides its own get_info() — macro should not override it. +#[derive(Debug, Clone)] +pub struct ManualInfoServer; + +#[tool_router] +impl ManualInfoServer { + #[tool(description = "Noop")] + fn noop(&self) {} +} + +#[tool_handler] +impl ServerHandler for ManualInfoServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info(rmcp::model::Implementation::new("manual", "9.9.9")) + } +} + +#[test] +fn test_manual_get_info_not_overridden() { + let server = ManualInfoServer; + let info = server.get_info(); + + assert_eq!(info.server_info.name, "manual"); + assert_eq!(info.server_info.version, "9.9.9"); + assert!(info.capabilities.tools.is_some()); + assert!( + info.capabilities.resources.is_some(), + "manual resources should be preserved" + ); +} diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md index ecdf8f564..56261633b 100644 --- a/docs/readme/README.zh-cn.md +++ b/docs/readme/README.zh-cn.md @@ -22,6 +22,7 @@ ## 目录 - [使用](#使用) +- [工具](#工具) - [资源](#资源) - [提示词](#提示词) - [采样](#采样) @@ -129,6 +130,76 @@ let quit_reason = server.cancel().await?; --- +## 工具 + +工具允许服务端向客户端暴露可调用的函数。每个工具都有名称、描述和参数的 JSON Schema。客户端通过 `list_tools` 发现工具,通过 `call_tool` 调用工具。 + +**MCP 规范:** [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) + +### 服务端 + +`#[tool]`、`#[tool_router]` 和 `#[tool_handler]` 宏负责所有连接工作。对于纯工具服务端,可以使用 `#[tool_router(server_handler)]` 来省略单独的 `ServerHandler` 实现: + +```rust,ignore +use rmcp::{tool, tool_router, ServiceExt, transport::stdio}; + +#[derive(Clone)] +struct Calculator; + +#[tool_router(server_handler)] +impl Calculator { + #[tool(description = "Add two numbers")] + fn add(&self, #[tool(param)] a: i32, #[tool(param)] b: i32) -> String { + (a + b).to_string() + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let service = Calculator.serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} +``` + +当需要自定义服务端元数据或多种能力(工具 + 提示词)时,使用显式的 `#[tool_handler]`: + +```rust,ignore +use rmcp::{tool, tool_router, tool_handler, ServerHandler, ServiceExt}; + +#[derive(Clone)] +struct Calculator; + +#[tool_router] +impl Calculator { + #[tool(description = "Add two numbers")] + fn add(&self, #[tool(param)] a: i32, #[tool(param)] b: i32) -> String { + (a + b).to_string() + } +} + +#[tool_handler(name = "calculator", version = "1.0.0", instructions = "A simple calculator")] +impl ServerHandler for Calculator {} +``` + +完整的宏文档请参阅 [`crates/rmcp-macros`](../../crates/rmcp-macros/README.md)。 + +### 客户端 + +```rust,ignore +use rmcp::model::CallToolRequestParams; + +// 列出所有工具 +let tools = client.list_all_tools().await?; + +// 按名称调用工具 +let result = client.call_tool(CallToolRequestParams::new("add")).await?; +``` + +**示例:** [`examples/servers/src/common/calculator.rs`](../../examples/servers/src/common/calculator.rs)(服务端),[`examples/servers/src/calculator_stdio.rs`](../../examples/servers/src/calculator_stdio.rs)(stdio 运行器) + +--- + ## 资源 资源允许服务端向客户端暴露数据(文件、数据库记录、API 响应)供其读取。每个资源通过 URI 标识,返回文本或二进制(base64 编码)内容。资源模板允许服务端声明带有动态参数的 URI 模式。 diff --git a/examples/servers/src/calculator_stdio.rs b/examples/servers/src/calculator_stdio.rs index 6af82042f..06666329e 100644 --- a/examples/servers/src/calculator_stdio.rs +++ b/examples/servers/src/calculator_stdio.rs @@ -17,7 +17,7 @@ async fn main() -> Result<()> { tracing::info!("Starting Calculator MCP server"); // Create an instance of our calculator router - let service = Calculator::new().serve(stdio()).await.inspect_err(|e| { + let service = Calculator.serve(stdio()).await.inspect_err(|e| { tracing::error!("serving error: {:?}", e); })?; diff --git a/examples/servers/src/common/calculator.rs b/examples/servers/src/common/calculator.rs index 2b0ab8e33..b010dc805 100644 --- a/examples/servers/src/common/calculator.rs +++ b/examples/servers/src/common/calculator.rs @@ -1,11 +1,6 @@ #![allow(dead_code)] -use rmcp::{ - ServerHandler, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{ServerCapabilities, ServerInfo}, - schemars, tool, tool_handler, tool_router, -}; +use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router}; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct SumRequest { @@ -23,18 +18,10 @@ pub struct SubRequest { } #[derive(Debug, Clone)] -pub struct Calculator { - tool_router: ToolRouter, -} +pub struct Calculator; -#[tool_router] +#[tool_router(server_handler)] impl Calculator { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } - #[tool(description = "Calculate the sum of two numbers")] fn sum(&self, Parameters(SumRequest { a, b }): Parameters) -> String { (a + b).to_string() @@ -45,11 +32,3 @@ impl Calculator { (a - b).to_string() } } - -#[tool_handler] -impl ServerHandler for Calculator { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_instructions("A simple calculator".to_string()) - } -} diff --git a/examples/servers/src/common/generic_service.rs b/examples/servers/src/common/generic_service.rs index 8034d5214..324f08f0c 100644 --- a/examples/servers/src/common/generic_service.rs +++ b/examples/servers/src/common/generic_service.rs @@ -1,10 +1,7 @@ use std::sync::Arc; use rmcp::{ - ServerHandler, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{ServerCapabilities, ServerInfo}, - schemars, tool, tool_handler, tool_router, + ServerHandler, handler::server::wrapper::Parameters, schemars, tool, tool_handler, tool_router, }; #[allow(dead_code)] @@ -41,7 +38,6 @@ impl DataService for MemoryDataService { pub struct GenericService { #[allow(dead_code)] data_service: Arc, - tool_router: ToolRouter, } #[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] @@ -55,7 +51,6 @@ impl GenericService { pub fn new(data_service: DS) -> Self { Self { data_service: Arc::new(data_service), - tool_router: Self::tool_router(), } } @@ -74,10 +69,5 @@ impl GenericService { } } -#[tool_handler] -impl ServerHandler for GenericService { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_instructions("generic data service".to_string()) - } -} +#[tool_handler(instructions = "generic data service")] +impl ServerHandler for GenericService {} diff --git a/examples/servers/src/common/progress_demo.rs b/examples/servers/src/common/progress_demo.rs index 1a613e0c7..341b3e70a 100644 --- a/examples/servers/src/common/progress_demo.rs +++ b/examples/servers/src/common/progress_demo.rs @@ -6,8 +6,8 @@ use std::{ use futures::Stream; use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, handler::server::tool::ToolRouter, model::*, - service::RequestContext, tool, tool_handler, tool_router, + ErrorData as McpError, RoleServer, ServerHandler, model::*, service::RequestContext, tool, + tool_handler, tool_router, }; use serde_json::json; use tokio_stream::StreamExt; @@ -54,7 +54,6 @@ impl Stream for StreamDataSource { #[derive(Clone)] pub struct ProgressDemo { data_source: StreamDataSource, - tool_router: ToolRouter, } #[tool_router] @@ -62,7 +61,6 @@ impl ProgressDemo { #[allow(dead_code)] pub fn new() -> Self { Self { - tool_router: Self::tool_router(), data_source: StreamDataSource::from_text("Hello, world!"), } } diff --git a/examples/transport/src/common/calculator.rs b/examples/transport/src/common/calculator.rs index f6d4c2a74..7ce3b5f8e 100644 --- a/examples/transport/src/common/calculator.rs +++ b/examples/transport/src/common/calculator.rs @@ -2,11 +2,7 @@ use rmcp::{ ServerHandler, - handler::server::{ - router::tool::ToolRouter, - wrapper::{Json, Parameters}, - }, - model::{ServerCapabilities, ServerInfo}, + handler::server::wrapper::{Json, Parameters}, schemars, tool, tool_handler, tool_router, }; @@ -26,17 +22,7 @@ pub struct SubRequest { } #[derive(Debug, Clone)] -pub struct Calculator { - tool_router: ToolRouter, -} - -impl Calculator { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} +pub struct Calculator; #[tool_router] impl Calculator { @@ -50,10 +36,5 @@ impl Calculator { Json(a - b) } } -#[tool_handler] -impl ServerHandler for Calculator { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_instructions("A simple calculator") - } -} +#[tool_handler(instructions = "A simple calculator")] +impl ServerHandler for Calculator {} diff --git a/examples/transport/src/http_upgrade.rs b/examples/transport/src/http_upgrade.rs index 6a15add30..1c0cf6ad2 100644 --- a/examples/transport/src/http_upgrade.rs +++ b/examples/transport/src/http_upgrade.rs @@ -24,7 +24,7 @@ async fn main() -> anyhow::Result<()> { async fn http_server(req: Request) -> Result, hyper::Error> { tokio::spawn(async move { let upgraded = hyper::upgrade::on(req).await?; - let service = Calculator::new().serve(TokioIo::new(upgraded)).await?; + let service = Calculator.serve(TokioIo::new(upgraded)).await?; service.waiting().await?; anyhow::Result::<()>::Ok(()) }); diff --git a/examples/transport/src/named-pipe.rs b/examples/transport/src/named-pipe.rs index 6f08ef221..79d1c58c1 100644 --- a/examples/transport/src/named-pipe.rs +++ b/examples/transport/src/named-pipe.rs @@ -16,7 +16,7 @@ async fn main() -> anyhow::Result<()> { let stream = server; server = ServerOptions::new().create(name)?; tokio::spawn(async move { - match serve_server(Calculator::new(), stream).await { + match serve_server(Calculator, stream).await { Ok(server) => { println!("Server initialized successfully"); if let Err(e) = server.waiting().await { diff --git a/examples/transport/src/tcp.rs b/examples/transport/src/tcp.rs index 683fb6cff..72428fe65 100644 --- a/examples/transport/src/tcp.rs +++ b/examples/transport/src/tcp.rs @@ -13,7 +13,7 @@ async fn server() -> anyhow::Result<()> { let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:8001").await?; while let Ok((stream, _)) = tcp_listener.accept().await { tokio::spawn(async move { - let server = serve_server(Calculator::new(), stream).await?; + let server = serve_server(Calculator, stream).await?; server.waiting().await?; anyhow::Ok(()) }); diff --git a/examples/transport/src/unix_socket.rs b/examples/transport/src/unix_socket.rs index a8eb6271d..6766034db 100644 --- a/examples/transport/src/unix_socket.rs +++ b/examples/transport/src/unix_socket.rs @@ -14,7 +14,7 @@ async fn main() -> anyhow::Result<()> { while let Ok((stream, addr)) = unix_listener.accept().await { println!("Client connected: {:?}", addr); tokio::spawn(async move { - match serve_server(Calculator::new(), stream).await { + match serve_server(Calculator, stream).await { Ok(server) => { println!("Server initialized successfully"); if let Err(e) = server.waiting().await { diff --git a/examples/transport/src/websocket.rs b/examples/transport/src/websocket.rs index 5ba235460..0d0fec729 100644 --- a/examples/transport/src/websocket.rs +++ b/examples/transport/src/websocket.rs @@ -40,7 +40,7 @@ async fn start_server() -> anyhow::Result<()> { tokio::spawn(async move { let ws_stream = tokio_tungstenite::accept_async(stream).await?; let transport = WebsocketTransport::new_server(ws_stream); - let server = Calculator::new().serve(transport).await?; + let server = Calculator.serve(transport).await?; server.waiting().await?; Ok::<(), anyhow::Error>(()) }); diff --git a/examples/wasi/src/calculator.rs b/examples/wasi/src/calculator.rs index a6f63fbe5..9182dc51f 100644 --- a/examples/wasi/src/calculator.rs +++ b/examples/wasi/src/calculator.rs @@ -1,13 +1,8 @@ #![allow(dead_code)] use rmcp::{ - ServerHandler, - handler::server::{ - router::tool::ToolRouter, - wrapper::{Json, Parameters}, - }, - model::{ServerCapabilities, ServerInfo}, - schemars, tool, tool_handler, tool_router, + handler::server::wrapper::{Json, Parameters}, + schemars, tool, tool_router, }; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -25,26 +20,10 @@ pub struct SubRequest { pub b: i32, } -#[derive(Debug, Clone)] -pub struct Calculator { - tool_router: ToolRouter, -} +#[derive(Debug, Clone, Default)] +pub struct Calculator; -impl Calculator { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -impl Default for Calculator { - fn default() -> Self { - Self::new() - } -} - -#[tool_router] +#[tool_router(server_handler)] impl Calculator { #[tool(description = "Calculate the sum of two numbers")] fn sum(&self, Parameters(SumRequest { a, b }): Parameters) -> String { @@ -56,11 +35,3 @@ impl Calculator { Json(a - b) } } - -#[tool_handler] -impl ServerHandler for Calculator { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_instructions("A simple calculator") - } -} diff --git a/examples/wasi/src/lib.rs b/examples/wasi/src/lib.rs index 2690cc734..3b2904a81 100644 --- a/examples/wasi/src/lib.rs +++ b/examples/wasi/src/lib.rs @@ -112,10 +112,7 @@ impl wasi::exports::cli::run::Guest for TokioCliRunner { .with_writer(std::io::stderr) .with_ansi(false) .init(); - let server = calculator::Calculator::new() - .serve(wasi_io()) - .await - .unwrap(); + let server = calculator::Calculator.serve(wasi_io()).await.unwrap(); server.waiting().await.unwrap(); }); Ok(()) From 5f432834a1db5e1247a198cd2b580c154b84634b Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 8 Apr 2026 12:07:04 -0700 Subject: [PATCH 127/333] feat: add meta to elicitation results (#792) --- conformance/src/bin/client.rs | 2 ++ crates/rmcp/src/handler/client.rs | 3 ++ crates/rmcp/src/model.rs | 11 +++++++ crates/rmcp/tests/test_elicitation.rs | 31 ++++++++++++++++++- .../client_json_rpc_message_schema.json | 8 +++++ ...lient_json_rpc_message_schema_current.json | 8 +++++ .../server_json_rpc_message_schema.json | 8 +++++ ...erver_json_rpc_message_schema_current.json | 14 ++++++--- 8 files changed, 80 insertions(+), 5 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 253451729..49afb75fd 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -122,6 +122,7 @@ impl ClientHandler for ElicitationDefaultsClientHandler { Ok(CreateElicitationResult { action: ElicitationAction::Accept, content, + meta: None, }) } } @@ -174,6 +175,7 @@ impl ClientHandler for FullClientHandler { Ok(CreateElicitationResult { action: ElicitationAction::Accept, content: Some(json!({"username": "testuser", "email": "test@example.com"})), + meta: None, }) } } diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 1b9c1e38e..926aafcb5 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -146,6 +146,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { /// Ok(CreateElicitationResult { /// action: ElicitationAction::Accept, /// content: Some(user_input), + /// meta: None, /// }) /// } /// CreateElicitationRequestParam::UrlElicitationParam {meta, message, url, elicitation_id,} => { @@ -154,6 +155,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { /// Ok(CreateElicitationResult { /// action: ElicitationAction::Accept, /// content: None, + /// meta: None, /// }) /// } /// } @@ -171,6 +173,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { std::future::ready(Ok(CreateElicitationResult { action: ElicitationAction::Decline, content: None, + meta: None, })) } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 8859bbbc6..001f4b600 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2699,6 +2699,10 @@ pub struct CreateElicitationResult { /// Only present when action is Accept. #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, + + /// Optional protocol-level metadata for this result. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl CreateElicitationResult { @@ -2707,6 +2711,7 @@ impl CreateElicitationResult { Self { action, content: None, + meta: None, } } @@ -2715,6 +2720,12 @@ impl CreateElicitationResult { self.content = Some(content); self } + + /// Set the metadata on this result. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } } /// Request type for creating an elicitation to gather user input diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index bfa8cc493..04a112f5b 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -1,6 +1,6 @@ //cargo test --test test_elicitation --features "client server" -use rmcp::{model::*, service::*}; +use rmcp::{model::*, object, service::*}; // For typed elicitation tests #[cfg(feature = "schemars")] use schemars::JsonSchema; @@ -98,6 +98,7 @@ async fn test_elicitation_result_serialization() { let accept_result = CreateElicitationResult { action: ElicitationAction::Accept, content: Some(json!({"email": "user@example.com"})), + meta: None, }; let json = serde_json::to_value(&accept_result).unwrap(); @@ -111,6 +112,7 @@ async fn test_elicitation_result_serialization() { let decline_result = CreateElicitationResult { action: ElicitationAction::Decline, content: None, + meta: None, }; let json = serde_json::to_value(&decline_result).unwrap(); @@ -124,6 +126,26 @@ async fn test_elicitation_result_serialization() { let deserialized: CreateElicitationResult = serde_json::from_value(expected).unwrap(); assert_eq!(deserialized.action, ElicitationAction::Decline); assert_eq!(deserialized.content, None); + assert_eq!(deserialized.meta, None); + + // Test protocol-level metadata round-trips as _meta. + let meta_result = + CreateElicitationResult::new(ElicitationAction::Accept).with_meta(Meta(object!({ + "traceId": "elicitation-123" + }))); + + let json = serde_json::to_value(&meta_result).unwrap(); + let expected = json!({ + "action": "accept", + "_meta": {"traceId": "elicitation-123"} + }); + assert_eq!(json, expected); + + let deserialized: CreateElicitationResult = serde_json::from_value(expected).unwrap(); + assert_eq!( + deserialized.meta, + Some(Meta(object!({ "traceId": "elicitation-123" }))) + ); } /// Test that elicitation requests can be created and handled through the JSON-RPC protocol @@ -843,6 +865,7 @@ async fn test_elicitation_direction_server_to_client() { let client_result = ClientResult::CreateElicitationResult(CreateElicitationResult { action: ElicitationAction::Accept, content: Some(json!("John Doe")), + meta: None, }); // Verify client result can be serialized @@ -893,6 +916,7 @@ async fn test_elicitation_json_rpc_direction() { ClientResult::CreateElicitationResult(CreateElicitationResult { action: ElicitationAction::Accept, content: Some(json!(true)), + meta: None, }), RequestId::Number(1), ); @@ -928,6 +952,7 @@ async fn test_elicitation_actions_compliance() { ElicitationAction::Accept => Some(serde_json::json!("some data")), _ => None, }, + meta: None, }; let json = serde_json::to_value(&result).unwrap(); @@ -958,6 +983,7 @@ async fn test_elicitation_result_in_client_result() { let result = ClientResult::CreateElicitationResult(CreateElicitationResult { action: ElicitationAction::Decline, content: None, + meta: None, }); match result { @@ -2161,6 +2187,7 @@ async fn test_url_elicitation_action_workflow() { let accept_result = CreateElicitationResult { action: ElicitationAction::Accept, content: None, // URL elicitation doesn't return content, just confirmation + meta: None, }; let json = serde_json::to_value(&accept_result).unwrap(); @@ -2172,6 +2199,7 @@ async fn test_url_elicitation_action_workflow() { let decline_result = CreateElicitationResult { action: ElicitationAction::Decline, content: None, + meta: None, }; let json = serde_json::to_value(&decline_result).unwrap(); @@ -2181,6 +2209,7 @@ async fn test_url_elicitation_action_workflow() { let cancel_result = CreateElicitationResult { action: ElicitationAction::Cancel, content: None, + meta: None, }; let json = serde_json::to_value(&cancel_result).unwrap(); diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 397fbe8bc..8e082db94 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -418,6 +418,14 @@ "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "action": { "description": "The user's decision on how to handle the elicitation request", "allOf": [ diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 397fbe8bc..8e082db94 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -418,6 +418,14 @@ "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "action": { "description": "The user's decision on how to handle the elicitation request", "allOf": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index db21c2ba6..405b3e022 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -655,6 +655,14 @@ "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "action": { "description": "The user's decision on how to handle the elicitation request", "allOf": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 10f45c0a4..405b3e022 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -388,6 +388,7 @@ "content": { "description": "The content returned by the tool (text, images, etc.)", "type": "array", + "default": [], "items": { "$ref": "#/definitions/Annotated" } @@ -402,10 +403,7 @@ "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } - }, - "required": [ - "content" - ] + } }, "CancelTaskResult": { "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", @@ -657,6 +655,14 @@ "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "action": { "description": "The user's decision on how to handle the elicitation request", "allOf": [ From 45a4cc5316592f62701bdfb35457248f3e20b3ef Mon Sep 17 00:00:00 2001 From: Eren Atas Date: Wed, 8 Apr 2026 22:39:16 +0200 Subject: [PATCH 128/333] feat: add Default and constructors to ServerSseMessage (#794) * feat: add Default and constructors to ServerSseMessage * fix: add tests, missing feature gates, small test issues --- .../rmcp/src/transport/common/http_header.rs | 5 ++ .../src/transport/common/server_side_http.rs | 79 ++++++++++++++++++- .../streamable_http_server/session/local.rs | 13 +-- .../transport/streamable_http_server/tower.rs | 33 ++------ .../tests/test_inflight_response_drain.rs | 2 +- 5 files changed, 92 insertions(+), 40 deletions(-) diff --git a/crates/rmcp/src/transport/common/http_header.rs b/crates/rmcp/src/transport/common/http_header.rs index b215ab12a..283f0daa7 100644 --- a/crates/rmcp/src/transport/common/http_header.rs +++ b/crates/rmcp/src/transport/common/http_header.rs @@ -65,8 +65,10 @@ pub(crate) fn extract_scope_from_header(header: &str) -> Option { #[cfg(test)] mod tests { + #[cfg(feature = "client-side-sse")] use super::*; + #[cfg(feature = "client-side-sse")] #[test] fn extract_scope_quoted() { let header = r#"Bearer error="insufficient_scope", scope="files:read files:write""#; @@ -76,6 +78,7 @@ mod tests { ); } + #[cfg(feature = "client-side-sse")] #[test] fn extract_scope_unquoted() { let header = r#"Bearer scope=read:data, error="insufficient_scope""#; @@ -85,12 +88,14 @@ mod tests { ); } + #[cfg(feature = "client-side-sse")] #[test] fn extract_scope_missing() { let header = r#"Bearer error="invalid_token""#; assert_eq!(extract_scope_from_header(header), None); } + #[cfg(feature = "client-side-sse")] #[test] fn extract_scope_empty_header() { assert_eq!(extract_scope_from_header("Bearer"), None); diff --git a/crates/rmcp/src/transport/common/server_side_http.rs b/crates/rmcp/src/transport/common/server_side_http.rs index d24b19af6..39a321f9b 100644 --- a/crates/rmcp/src/transport/common/server_side_http.rs +++ b/crates/rmcp/src/transport/common/server_side_http.rs @@ -57,7 +57,7 @@ impl sse_stream::Timer for TokioTimer { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct ServerSseMessage { /// The event ID for this message. When set, clients can use this ID @@ -71,6 +71,37 @@ pub struct ServerSseMessage { pub retry: Option, } +impl ServerSseMessage { + /// Create a message carrying a JSON-RPC response/notification with an event ID. + pub fn new(event_id: impl Into, message: ServerJsonRpcMessage) -> Self { + Self { + event_id: Some(event_id.into()), + message: Some(Arc::new(message)), + retry: None, + } + } + + /// Wrap a JSON-RPC message without an event ID or retry hint. + pub fn from_message(message: ServerJsonRpcMessage) -> Self { + Self { + event_id: None, + message: Some(Arc::new(message)), + retry: None, + } + } + + /// Create a priming event that tells the client to reconnect after `retry` + /// if the connection drops. + /// See [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699). + pub fn priming(event_id: impl Into, retry: Duration) -> Self { + Self { + event_id: Some(event_id.into()), + message: None, + retry: Some(retry), + } + } +} + pub(crate) fn sse_stream_response( stream: impl futures::Stream + Send + Sync + 'static, keep_alive: Option, @@ -169,3 +200,49 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{EmptyResult, JsonRpcResponse, JsonRpcVersion2_0, RequestId, ServerResult}; + + fn dummy_message() -> ServerJsonRpcMessage { + ServerJsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: JsonRpcVersion2_0, + id: RequestId::Number(1), + result: ServerResult::EmptyResult(EmptyResult {}), + }) + } + + #[test] + fn default_has_all_none() { + let msg = ServerSseMessage::default(); + assert!(msg.event_id.is_none()); + assert!(msg.message.is_none()); + assert!(msg.retry.is_none()); + } + + #[test] + fn new_sets_event_id_and_message() { + let msg = ServerSseMessage::new("42", dummy_message()); + assert_eq!(msg.event_id.as_deref(), Some("42")); + assert!(msg.message.is_some()); + assert!(msg.retry.is_none()); + } + + #[test] + fn from_message_has_no_event_id() { + let msg = ServerSseMessage::from_message(dummy_message()); + assert!(msg.event_id.is_none()); + assert!(msg.message.is_some()); + assert!(msg.retry.is_none()); + } + + #[test] + fn priming_sets_event_id_and_retry() { + let msg = ServerSseMessage::priming("0", Duration::from_secs(5)); + assert_eq!(msg.event_id.as_deref(), Some("0")); + assert!(msg.message.is_none()); + assert_eq!(msg.retry, Some(Duration::from_secs(5))); + } +} diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 52f7962e9..dcaf204cb 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -1,7 +1,6 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, num::ParseIntError, - sync::Arc, time::Duration, }; @@ -222,21 +221,13 @@ impl CachedTx { async fn send(&mut self, message: ServerJsonRpcMessage) { let event_id = self.next_event_id(); - let message = ServerSseMessage { - event_id: Some(event_id.to_string()), - message: Some(Arc::new(message)), - retry: None, - }; + let message = ServerSseMessage::new(event_id.to_string(), message); self.cache_and_send(message).await; } async fn send_priming(&mut self, retry: Duration) { let event_id = self.next_event_id(); - let message = ServerSseMessage { - event_id: Some(event_id.to_string()), - message: None, - retry: Some(retry), - }; + let message = ServerSseMessage::priming(event_id.to_string(), retry); self.cache_and_send(message).await; } diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 8f9c0a70c..5dc7996c2 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -499,11 +499,7 @@ where .map_err(internal_error_response("create standalone stream"))?; // Prepend priming event if sse_retry configured let stream = if let Some(retry) = self.config.sse_retry { - let priming = ServerSseMessage { - event_id: Some("0".into()), - message: None, - retry: Some(retry), - }; + let priming = ServerSseMessage::priming("0", retry); futures::stream::once(async move { priming }) .chain(stream) .left_stream() @@ -609,11 +605,7 @@ where .map_err(internal_error_response("get session"))?; // Prepend priming event if sse_retry configured let stream = if let Some(retry) = self.config.sse_retry { - let priming = ServerSseMessage { - event_id: Some("0".into()), - message: None, - retry: Some(retry), - }; + let priming = ServerSseMessage::priming("0", retry); futures::stream::once(async move { priming }) .chain(stream) .left_stream() @@ -687,20 +679,11 @@ where .initialize_session(&session_id, message) .await .map_err(internal_error_response("create stream"))?; - let stream = futures::stream::once(async move { - ServerSseMessage { - event_id: None, - message: Some(Arc::new(response)), - retry: None, - } - }); + let stream = + futures::stream::once(async move { ServerSseMessage::from_message(response) }); // Prepend priming event if sse_retry configured let stream = if let Some(retry) = self.config.sse_retry { - let priming = ServerSseMessage { - event_id: Some("0".into()), - message: None, - retry: Some(retry), - }; + let priming = ServerSseMessage::priming("0", retry); futures::stream::once(async move { priming }) .chain(stream) .left_stream() @@ -774,11 +757,7 @@ where // SSE mode (default): original behaviour preserved unchanged let stream = ReceiverStream::new(receiver).map(|message| { tracing::trace!(?message); - ServerSseMessage { - event_id: None, - message: Some(Arc::new(message)), - retry: None, - } + ServerSseMessage::from_message(message) }); Ok(sse_stream_response( stream, diff --git a/crates/rmcp/tests/test_inflight_response_drain.rs b/crates/rmcp/tests/test_inflight_response_drain.rs index b5fc160e2..2381644d9 100644 --- a/crates/rmcp/tests/test_inflight_response_drain.rs +++ b/crates/rmcp/tests/test_inflight_response_drain.rs @@ -1,4 +1,4 @@ -#![cfg(not(feature = "local"))] +#![cfg(all(feature = "client", feature = "server", not(feature = "local")))] // cargo test --test test_inflight_response_drain --features "client server" use std::{ From 34d0bc6cd20a6a45d21e9fde35e1c139ef00418f Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 8 Apr 2026 21:35:34 -0400 Subject: [PATCH 129/333] fix: upgrade rustc in actions (#796) --- .github/workflows/release-plz.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 9d7339039..804af2253 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -25,6 +25,8 @@ jobs: fetch-depth: 0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92" # Using fork until semver_check_features support is released upstream. # See: https://github.com/release-plz/release-plz/pull/2757 - name: Install release-plz from fork @@ -53,6 +55,8 @@ jobs: fetch-depth: 0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92" # Using fork until semver_check_features support is released upstream. # See: https://github.com/release-plz/release-plz/pull/2757 - name: Install release-plz from fork From 8a8c036ccbd1ee259f495cddf01c02f4b5cd1958 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:09:59 -0400 Subject: [PATCH 130/333] chore: update Rust toolchain to 1.92 (#797) --- crates/rmcp/src/model.rs | 9 ++------- crates/rmcp/src/transport/auth.rs | 2 +- rust-toolchain.toml | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 001f4b600..dbfd5ae62 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1545,12 +1545,13 @@ pub enum Role { } /// Tool selection mode (SEP-1577). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] pub enum ToolChoiceMode { /// Model decides whether to use tools + #[default] Auto, /// Model must use at least one tool Required, @@ -1558,12 +1559,6 @@ pub enum ToolChoiceMode { None, } -impl Default for ToolChoiceMode { - fn default() -> Self { - Self::Auto - } -} - /// Tool choice configuration (SEP-1577). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 3f9b06e3d..34674df76 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; use tokio::sync::{Mutex, RwLock}; -use tracing::{debug, error, warn}; +use tracing::{debug, warn}; use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0968bb9dd..f04d1f29b 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.90" +channel = "1.92" components = ["rustc", "rust-std", "cargo", "clippy", "rustfmt", "rust-docs"] From a7b570062e69502fa7e209fb2e224c687ce23a60 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:59:41 -0400 Subject: [PATCH 131/333] fix: pass GIT_TOKEN to release-plz CLI (#798) * fix: pass GIT_TOKEN to release-plz CLI * fix: bump Node.js to 22 in CI for SDK compatibility --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release-plz.yml | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e329e10d..4c3b3dc0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install commitlint run: | @@ -79,7 +79,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install uv uses: astral-sh/setup-uv@v7 @@ -108,7 +108,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install uv uses: astral-sh/setup-uv@v7 @@ -144,7 +144,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install uv uses: astral-sh/setup-uv@v7 @@ -179,7 +179,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install uv uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 804af2253..cb92ee8ca 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -34,6 +34,7 @@ jobs: - name: Run release-plz release run: release-plz release env: + GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} @@ -64,5 +65,6 @@ jobs: - name: Run release-plz release-pr run: release-plz release-pr env: + GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file From 65d2b29da5fb3f4c4679b5850e09b371de293c2e Mon Sep 17 00:00:00 2001 From: Anar Azadaliyev Date: Fri, 10 Apr 2026 02:23:59 +0300 Subject: [PATCH 132/333] fix(server): remove initialized notification gate to support Streamable HTTP (#788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): remove initialized notification gate to support Streamable HTTP The server's init handshake loop fatally rejected any request arriving before the `notifications/initialized` message. This breaks Streamable HTTP clients where each JSON-RPC message is a separate POST with no ordering guarantee — `tools/list` can easily arrive before `initialized`. Remove the ~40-line wait loop and enter `serve_inner` immediately after sending `InitializeResult`. The `initialized` notification is now handled as a regular notification by the main service loop, matching the TypeScript SDK behavior (validated in typescript-sdk#578). Also remove the now-unreachable `ExpectedInitializedNotification` error variant from `ServerInitializeError`. Closes #783 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(server): keep ExpectedInitializedNotification as deprecated Retain the variant for semver compatibility — removing it would be a breaking change caught by cargo-semver-checks. Mark it deprecated with a note that it is never constructed and will be removed in a future major release. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Anar Azadaliyev Co-authored-by: Claude Opus 4.6 (1M context) --- .gitignore | 2 + crates/rmcp/src/service/server.rs | 53 +++---------- .../rmcp/tests/test_server_initialization.rs | 77 ++++++++++++++++--- 3 files changed, 78 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index bb88a0e2f..2288665b1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ __pycache__/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +node_modules/ +.DS_Store diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index dcf7993a6..82db47b8c 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -53,6 +53,10 @@ pub enum ServerInitializeError { #[error("expect initialized request, but received: {0:?}")] ExpectedInitializeRequest(Option), + #[deprecated( + since = "1.4.0", + note = "The server no longer gates on the initialized notification. This variant is never constructed and will be removed in a future major release." + )] #[error("expect initialized notification, but received: {0:?}")] ExpectedInitializedNotification(Option), @@ -243,49 +247,12 @@ where ServerInitializeError::transport::(error, "sending initialize response") })?; - // Wait for initialized notification. The MCP spec permits logging/setLevel and ping - // before initialized; VS Code sends setLevel immediately after the initialize response. - let notification = loop { - let msg = expect_next_message(&mut transport, "initialize notification").await?; - match msg { - ClientJsonRpcMessage::Notification(n) - if matches!( - n.notification, - ClientNotification::InitializedNotification(_) - ) => - { - break n.notification; - } - ClientJsonRpcMessage::Request(req) - if matches!( - req.request, - ClientRequest::SetLevelRequest(_) | ClientRequest::PingRequest(_) - ) => - { - transport - .send(ServerJsonRpcMessage::response( - ServerResult::EmptyResult(EmptyResult {}), - req.id, - )) - .await - .map_err(|error| { - ServerInitializeError::transport::(error, "sending pre-init response") - })?; - } - other => { - return Err(ServerInitializeError::ExpectedInitializedNotification( - Some(other), - )); - } - } - }; - let context = NotificationContext { - meta: notification.get_meta().clone(), - extensions: notification.extensions().clone(), - peer: peer.clone(), - }; - let _ = service.handle_notification(notification, context).await; - // Continue processing service + // Enter the main service loop immediately after sending InitializeResult. + // The initialized notification will be handled as a regular notification by serve_inner. + // This matches the TypeScript SDK behavior: no init gate, no waiting for initialized. + // Streamable HTTP has no ordering guarantee between POSTs, and the MCP spec uses + // SHOULD NOT (not MUST NOT) for pre-initialized messages, so any request arriving + // before initialized is processed normally. Ok(serve_inner(service, transport, peer, peer_rx, ct)) } diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index c240e4256..8cf5c2c41 100644 --- a/crates/rmcp/tests/test_server_initialization.rs +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -6,7 +6,6 @@ use common::handlers::TestServer; use rmcp::{ ServiceExt, model::{ClientJsonRpcMessage, ServerJsonRpcMessage, ServerResult}, - service::ServerInitializeError, transport::{IntoTransport, Transport}, }; @@ -54,7 +53,7 @@ async fn do_initialize(client: &mut impl Transport) { let _response = client.receive().await.unwrap(); } -// Server responds with EmptyResult to setLevel received before initialized. +// Server handles setLevel sent before initialized notification (processed by serve_inner). #[tokio::test] async fn server_init_set_level_response_is_empty_result() { let (server_transport, client_transport) = tokio::io::duplex(4096); @@ -64,7 +63,14 @@ async fn server_init_set_level_response_is_empty_result() { do_initialize(&mut client).await; client.send(set_level_request(2)).await.unwrap(); - let response = client.receive().await.unwrap(); + // The handler may send logging notifications before the response; + // skip notifications to find the EmptyResult response. + let response = loop { + let msg = client.receive().await.unwrap(); + if matches!(msg, ServerJsonRpcMessage::Response(_)) { + break msg; + } + }; assert!( matches!( response, @@ -85,7 +91,13 @@ async fn server_init_succeeds_after_set_level_before_initialized() { do_initialize(&mut client).await; client.send(set_level_request(2)).await.unwrap(); - let _response = client.receive().await.unwrap(); + // Skip notifications until we get the response + loop { + let msg = client.receive().await.unwrap(); + if matches!(msg, ServerJsonRpcMessage::Response(_)) { + break; + } + } client.send(initialized_notification()).await.unwrap(); let result = server_handle.await.unwrap(); @@ -179,23 +191,66 @@ async fn server_init_succeeds_after_ping_before_initialized() { result.unwrap().cancel().await.unwrap(); } -// Server returns ExpectedInitializedNotification for any other message before initialized. +// Server buffers tools/list sent before initialized and processes it after initialization. #[tokio::test] -async fn server_init_rejects_unexpected_message_before_initialized() { +async fn server_init_buffers_request_before_initialized() { let (server_transport, client_transport) = tokio::io::duplex(4096); let server_handle = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); let mut client = IntoTransport::::into_transport(client_transport); do_initialize(&mut client).await; + // Send tools/list before initialized notification client.send(list_tools_request(2)).await.unwrap(); + // Now send initialized notification + client.send(initialized_notification()).await.unwrap(); + + // The buffered tools/list should be processed — expect a response + let response = client.receive().await.unwrap(); + assert!( + matches!(response, ServerJsonRpcMessage::Response(_)), + "expected response for buffered tools/list, got: {response:?}" + ); let result = server_handle.await.unwrap(); assert!( - matches!( - result, - Err(ServerInitializeError::ExpectedInitializedNotification(_)) - ), - "expected ExpectedInitializedNotification error" + result.is_ok(), + "server should initialize successfully when buffering pre-init messages" ); + result.unwrap().cancel().await.unwrap(); +} + +// Server buffers multiple requests before initialized and processes them in order. +#[tokio::test] +async fn server_init_buffers_multiple_requests_before_initialized() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_handle = + tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + // Send two requests before initialized + client.send(list_tools_request(2)).await.unwrap(); + client.send(ping_request(3)).await.unwrap(); + // Now send initialized notification + client.send(initialized_notification()).await.unwrap(); + + // Both buffered messages should get responses + let response1 = client.receive().await.unwrap(); + let response2 = client.receive().await.unwrap(); + assert!( + matches!(response1, ServerJsonRpcMessage::Response(_)), + "expected response for first buffered message, got: {response1:?}" + ); + assert!( + matches!(response2, ServerJsonRpcMessage::Response(_)), + "expected response for second buffered message, got: {response2:?}" + ); + + let result = server_handle.await.unwrap(); + assert!( + result.is_ok(), + "server should initialize successfully with multiple buffered messages" + ); + result.unwrap().cancel().await.unwrap(); } From 4628720f89d27a01d4a126ea9f82f0775df9ed52 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:40:38 -0400 Subject: [PATCH 133/333] chore: release v1.4.0 (#779) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e2839e58a..19e041e30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.3.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.3.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.4.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.4.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.3.0" +version = "1.4.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index e5cb01832..01a820830 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.3.0...rmcp-macros-v1.4.0) - 2026-04-09 + +### Added + +- *(macros)* auto-generate get_info and default router ([#785](https://github.com/modelcontextprotocol/rust-sdk/pull/785)) + ## [1.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.2.0...rmcp-macros-v1.3.0) - 2026-03-24 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 964101d6c..4051c9f3b 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.3.0...rmcp-v1.4.0) - 2026-04-09 + +### Added + +- add Default and constructors to ServerSseMessage ([#794](https://github.com/modelcontextprotocol/rust-sdk/pull/794)) +- add meta to elicitation results ([#792](https://github.com/modelcontextprotocol/rust-sdk/pull/792)) +- *(macros)* auto-generate get_info and default router ([#785](https://github.com/modelcontextprotocol/rust-sdk/pull/785)) +- *(transport)* add which_command for cross-platform executable resolution ([#774](https://github.com/modelcontextprotocol/rust-sdk/pull/774)) +- *(auth)* add StoredCredentials::new() constructor ([#778](https://github.com/modelcontextprotocol/rust-sdk/pull/778)) + +### Fixed + +- *(server)* remove initialized notification gate to support Streamable HTTP ([#788](https://github.com/modelcontextprotocol/rust-sdk/pull/788)) +- default session keep_alive to 5 minutes ([#780](https://github.com/modelcontextprotocol/rust-sdk/pull/780)) +- *(http)* add host check ([#764](https://github.com/modelcontextprotocol/rust-sdk/pull/764)) +- exclude local feature from docs.rs build ([#782](https://github.com/modelcontextprotocol/rust-sdk/pull/782)) + +### Other + +- update Rust toolchain to 1.92 ([#797](https://github.com/modelcontextprotocol/rust-sdk/pull/797)) +- unify IntoCallToolResult Result impls ([#787](https://github.com/modelcontextprotocol/rust-sdk/pull/787)) + ## [1.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.2.0...rmcp-v1.3.0) - 2026-03-24 ### Added From a64be231527f923e9f84d4dd7bf3c3bd695ee53e Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:21:48 -0400 Subject: [PATCH 134/333] feat: add 2025-11-25 protocol version support (#802) --- crates/rmcp/src/model.rs | 16 +++++++++++++--- crates/rmcp/tests/test_custom_headers.rs | 4 +++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index dbfd5ae62..b473e9ac5 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -152,14 +152,19 @@ impl std::fmt::Display for ProtocolVersion { } impl ProtocolVersion { + pub const V_2025_11_25: Self = Self(Cow::Borrowed("2025-11-25")); pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); - pub const LATEST: Self = Self::V_2025_06_18; + pub const LATEST: Self = Self::V_2025_11_25; /// All protocol versions known to this SDK. - pub const KNOWN_VERSIONS: &[Self] = - &[Self::V_2024_11_05, Self::V_2025_03_26, Self::V_2025_06_18]; + pub const KNOWN_VERSIONS: &[Self] = &[ + Self::V_2024_11_05, + Self::V_2025_03_26, + Self::V_2025_06_18, + Self::V_2025_11_25, + ]; /// Returns the string representation of this protocol version. pub fn as_str(&self) -> &str { @@ -187,6 +192,7 @@ impl<'de> Deserialize<'de> for ProtocolVersion { "2024-11-05" => return Ok(ProtocolVersion::V_2024_11_05), "2025-03-26" => return Ok(ProtocolVersion::V_2025_03_26), "2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18), + "2025-11-25" => return Ok(ProtocolVersion::V_2025_11_25), _ => {} } Ok(ProtocolVersion(Cow::Owned(s))) @@ -3745,7 +3751,11 @@ mod tests { fn test_protocol_version_order() { let v1 = ProtocolVersion::V_2024_11_05; let v2 = ProtocolVersion::V_2025_03_26; + let v3 = ProtocolVersion::V_2025_06_18; + let v4 = ProtocolVersion::V_2025_11_25; assert!(v1 < v2); + assert!(v2 < v3); + assert!(v3 < v4); } #[test] diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 558ff623d..0cdd1bc42 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -866,14 +866,16 @@ async fn test_server_rejects_unsupported_protocol_version() { fn test_protocol_version_utilities() { use rmcp::model::ProtocolVersion; + assert_eq!(ProtocolVersion::V_2025_11_25.as_str(), "2025-11-25"); assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18"); assert_eq!(ProtocolVersion::V_2025_03_26.as_str(), "2025-03-26"); assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05"); - assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 3); + assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 4); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_11_25)); } /// Integration test: Verify server validates only the Host header for DNS rebinding protection From a743f15654c52828c5f875a907dc15c22bc05438 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:10:42 -0400 Subject: [PATCH 135/333] chore(deps): update which requirement from 7 to 8 (#807) Updates the requirements on [which](https://github.com/harryfei/which-rs) to permit the latest version. - [Release notes](https://github.com/harryfei/which-rs/releases) - [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/harryfei/which-rs/compare/7.0.0...8.0.2) --- updated-dependencies: - dependency-name: which dependency-version: 8.0.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- crates/rmcp/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 63a990e80..4467b0a34 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -82,7 +82,7 @@ tower-service = { version = "0.3", optional = true } process-wrap = { version = "9.0", features = ["tokio1"], optional = true } # for cross-platform executable path resolution -which = { version = "7", optional = true } +which = { version = "8", optional = true } # for ws transport # tokio-tungstenite ={ version = "0.26", optional = true } From ad3997268d2c0f7eff80d166c3e837cb789206f2 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 13 Apr 2026 16:31:26 -0400 Subject: [PATCH 136/333] feat(transport): add constructors for non_exhaustive error types (#806) AuthRequiredError, InsufficientScopeError, and DynamicTransportError were marked #[non_exhaustive] in #715/#768 but don't have constructors usable by external crates. Add new() for the error types and from_parts() for DynamicTransportError (the existing new() requires a Transport type parameter, making it unusable for test fixtures). Fixes #805 --- crates/rmcp/src/transport.rs | 18 +++++++++++++ .../common/reqwest/streamable_http_client.rs | 25 ++++++++++++------- .../src/transport/streamable_http_client.rs | 17 +++++++++++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 8969f1947..89568b3dd 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -252,6 +252,24 @@ impl DynamicTransportError { error: Box::new(e), } } + + /// Create a `DynamicTransportError` from raw parts. + /// + /// Unlike [`new`](Self::new), this does not require a concrete [`Transport`] type, + /// making it usable in test fixtures and other contexts where a real transport + /// implementation is not available. + pub fn from_parts( + transport_name: impl Into>, + transport_type_id: std::any::TypeId, + error: Box, + ) -> Self { + Self { + transport_name: transport_name.into(), + transport_type_id, + error, + } + } + pub fn downcast + 'static, R: ServiceRole>(self) -> Result { if !self.is::() { Err(self) diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index dea98c7b9..b72617bf3 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -284,21 +284,28 @@ impl StreamableHttpClientTransport { #[cfg(test)] mod tests { use super::parse_json_rpc_error; - use crate::{model::JsonRpcMessage, transport::streamable_http_client::InsufficientScopeError}; + use crate::{ + model::JsonRpcMessage, + transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError}, + }; + + #[test] + fn auth_required_error_new() { + let err = AuthRequiredError::new("Bearer realm=\"test\"".to_string()); + assert_eq!(err.www_authenticate_header, "Bearer realm=\"test\""); + } #[test] fn insufficient_scope_error_can_upgrade() { - let with_scope = InsufficientScopeError { - www_authenticate_header: "Bearer scope=\"admin\"".to_string(), - required_scope: Some("admin".to_string()), - }; + let with_scope = InsufficientScopeError::new( + "Bearer scope=\"admin\"".to_string(), + Some("admin".to_string()), + ); assert!(with_scope.can_upgrade()); assert_eq!(with_scope.get_required_scope(), Some("admin")); - let without_scope = InsufficientScopeError { - www_authenticate_header: "Bearer error=\"insufficient_scope\"".to_string(), - required_scope: None, - }; + let without_scope = + InsufficientScopeError::new("Bearer error=\"insufficient_scope\"".to_string(), None); assert!(!without_scope.can_upgrade()); assert_eq!(without_scope.get_required_scope(), None); } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 980e63db1..53794ce5e 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -29,6 +29,15 @@ pub struct AuthRequiredError { pub www_authenticate_header: String, } +impl AuthRequiredError { + /// Create a new `AuthRequiredError` instance. + pub fn new(www_authenticate_header: String) -> Self { + Self { + www_authenticate_header, + } + } +} + #[derive(Debug)] #[non_exhaustive] pub struct InsufficientScopeError { @@ -37,6 +46,14 @@ pub struct InsufficientScopeError { } impl InsufficientScopeError { + /// Create a new `InsufficientScopeError` instance. + pub fn new(www_authenticate_header: String, required_scope: Option) -> Self { + Self { + www_authenticate_header, + required_scope, + } + } + /// check if scope upgrade is possible (i.e., we know what scope is required) pub fn can_upgrade(&self) -> bool { self.required_scope.is_some() From c99903a67a5ef8461135a8d5fdfa05f1c937ac3d Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:33:30 -0400 Subject: [PATCH 137/333] fix(http): drain SSE stream for connection reuse (#790) * fix(http): reduce latency on subsequent StreamableHttp calls * refactor: rely on stream drain for connection reuse * refactor: clean up comments and naming * fix: restore pool_max_idle_per_host(0) for Linux --- crates/rmcp/Cargo.toml | 13 ++ .../common/reqwest/streamable_http_client.rs | 16 ++- .../src/transport/streamable_http_client.rs | 118 ++++++++--------- .../streamable_http_server/session/local.rs | 10 +- .../test_streamable_http_connection_reuse.rs | 122 ++++++++++++++++++ 5 files changed, 210 insertions(+), 69 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_connection_reuse.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 4467b0a34..5006681d4 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -339,3 +339,16 @@ required-features = [ ] path = "tests/test_streamable_http_stale_session.rs" +[[test]] +name = "test_streamable_http_connection_reuse" +required-features = [ + "server", + "client", + "macros", + "schemars", + "transport-streamable-http-server", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", +] +path = "tests/test_streamable_http_connection_reuse.rs" + diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index b72617bf3..32de491db 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -262,7 +262,7 @@ impl StreamableHttpClientTransport { /// This method requires the `transport-streamable-http-client-reqwest` feature. pub fn from_uri(uri: impl Into>) -> Self { StreamableHttpClientTransport::with_client( - reqwest::Client::default(), + Self::default_http_client(), StreamableHttpClientTransportConfig { uri: uri.into(), auth_header: None, @@ -277,7 +277,19 @@ impl StreamableHttpClientTransport { /// /// * `config` - The config to use with this transport pub fn from_config(config: StreamableHttpClientTransportConfig) -> Self { - StreamableHttpClientTransport::with_client(reqwest::Client::default(), config) + StreamableHttpClientTransport::with_client(Self::default_http_client(), config) + } + + /// Build the default reqwest client for this transport. + /// + /// Disables idle connection pooling to avoid ~40 ms stalls caused by + /// TCP Delayed ACK on Linux when the previous response body was not + /// fully consumed before the pool attempts to reuse the connection. + fn default_http_client() -> reqwest::Client { + reqwest::Client::builder() + .pool_max_idle_per_host(0) + .build() + .expect("failed to build default reqwest client") } } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 53794ce5e..a2c1a7b19 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -298,6 +298,37 @@ impl StreamableHttpClientWorker { } impl StreamableHttpClientWorker { + /// Convert a raw SSE stream into a JSON-RPC message stream without + /// reconnection logic. + fn raw_sse_to_jsonrpc( + stream: BoxedSseStream, + ) -> impl Stream>> + Send + 'static + { + stream.filter_map(|event| async { + match event { + Err(e) => Some(Err(StreamableHttpError::Sse(e))), + Ok(sse) => { + let is_message = + matches!(sse.event.as_deref(), None | Some("") | Some("message")); + if !is_message { + return None; + } + let data = sse.data?; + if data.trim().is_empty() { + return None; + } + match serde_json::from_str::(&data) { + Ok(msg) => Some(Ok(msg)), + Err(e) => { + tracing::debug!("failed to deserialize server message: {e}"); + None + } + } + } + } + }) + } + async fn execute_sse_stream( sse_stream: impl Stream>> + Send @@ -320,14 +351,23 @@ impl StreamableHttpClientWorker { let Some(message) = message.transpose()? else { break; }; - let is_response = matches!(message, ServerJsonRpcMessage::Response(_)); + let is_response = matches!( + message, + ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) + ); let yield_result = sse_worker_tx.send(message).await; if yield_result.is_err() { tracing::trace!("streamable http transport worker dropped, exiting"); break; } if close_on_response && is_response { - tracing::debug!("got response, closing sse stream"); + tracing::debug!("got response, draining sse stream for connection reuse"); + // Consume the remaining stream so the HTTP/1.1 connection + // returns to the pool cleanly. + let _ = tokio::time::timeout(std::time::Duration::from_millis(50), async { + while sse_stream.next().await.is_some() {} + }) + .await; break; } } @@ -735,38 +775,12 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { - if let Some(sid) = &session_id { - let sse_stream = SseAutoReconnectStream::new( - stream, - StreamableHttpClientReconnect { - client: self.client.clone(), - session_id: sid.clone(), - uri: config.uri.clone(), - auth_header: config.auth_header.clone(), - custom_headers: protocol_headers - .clone(), - }, - self.config.retry_config.clone(), - ); - streams.spawn(Self::execute_sse_stream( - sse_stream, - sse_worker_tx.clone(), - true, - transport_task_ct.child_token(), - )); - } else { - let sse_stream = - SseAutoReconnectStream::never_reconnect( - stream, - StreamableHttpError::::UnexpectedEndOfStream, - ); - streams.spawn(Self::execute_sse_stream( - sse_stream, - sse_worker_tx.clone(), - true, - transport_task_ct.child_token(), - )); - } + streams.spawn(Self::execute_sse_stream( + Self::raw_sse_to_jsonrpc(stream), + sse_worker_tx.clone(), + true, + transport_task_ct.child_token(), + )); tracing::trace!("got new sse stream after re-init"); Ok(()) } @@ -786,36 +800,12 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { - if let Some(session_id) = &session_id { - let sse_stream = SseAutoReconnectStream::new( - stream, - StreamableHttpClientReconnect { - client: self.client.clone(), - session_id: session_id.clone(), - uri: config.uri.clone(), - auth_header: config.auth_header.clone(), - custom_headers: protocol_headers.clone(), - }, - self.config.retry_config.clone(), - ); - streams.spawn(Self::execute_sse_stream( - sse_stream, - sse_worker_tx.clone(), - true, - transport_task_ct.child_token(), - )); - } else { - let sse_stream = SseAutoReconnectStream::never_reconnect( - stream, - StreamableHttpError::::UnexpectedEndOfStream, - ); - streams.spawn(Self::execute_sse_stream( - sse_stream, - sse_worker_tx.clone(), - true, - transport_task_ct.child_token(), - )); - } + streams.spawn(Self::execute_sse_stream( + Self::raw_sse_to_jsonrpc(stream), + sse_worker_tx.clone(), + true, + transport_task_ct.child_token(), + )); tracing::trace!("got new sse stream"); Ok(()) } diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index dcaf204cb..814f317d9 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -470,7 +470,7 @@ impl LocalSessionWorker { { OutboundChannel::RequestWise { id: *id, - close: false, + close: true, } } else { OutboundChannel::Common @@ -483,7 +483,7 @@ impl LocalSessionWorker { { OutboundChannel::RequestWise { id: *id, - close: false, + close: true, } } else { OutboundChannel::Common @@ -501,7 +501,11 @@ impl LocalSessionWorker { if let Some(request_wise) = self.tx_router.get_mut(&id) { request_wise.tx.send(message).await; if close { - self.tx_router.remove(&id); + if let Some(channel) = self.tx_router.remove(&id) { + for resource in channel.resources { + self.resource_router.remove(&resource); + } + } } } else { return Err(SessionError::ChannelClosed(Some(id))); diff --git a/crates/rmcp/tests/test_streamable_http_connection_reuse.rs b/crates/rmcp/tests/test_streamable_http_connection_reuse.rs new file mode 100644 index 000000000..553448eae --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_connection_reuse.rs @@ -0,0 +1,122 @@ +#![cfg(not(feature = "local"))] + +use std::time::Instant; + +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, + schemars, tool, tool_handler, tool_router, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct SumRequest { + a: i32, + b: i32, +} + +#[derive(Debug, Clone)] +struct SumServer { + tool_router: ToolRouter, +} + +impl SumServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[tool_router] +impl SumServer { + #[tool(description = "Sum two numbers")] + fn sum(&self, Parameters(SumRequest { a, b }): Parameters) -> String { + (a + b).to_string() + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for SumServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } +} + +/// Verify that subsequent tool calls do not regress in latency due to +/// HTTP/1.1 connection pool exhaustion. Before the fix, each POST SSE +/// response was dropped without fully consuming the body, preventing +/// connection reuse and forcing a new TCP connection (~40 ms) per call. +#[tokio::test] +async fn test_subsequent_tool_calls_reuse_connections() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + + let service: StreamableHttpService = StreamableHttpService::new( + || Ok(SumServer::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let server_handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), + ); + let client = ClientInfo::default().serve(transport).await?; + + // Warm up: first call may include one-time setup costs. + let args: serde_json::Map = + serde_json::from_value(serde_json::json!({"a": 1, "b": 2}))?; + let _ = client + .call_tool(CallToolRequestParams::new("sum").with_arguments(args)) + .await?; + + // Measure subsequent calls. + let mut durations = Vec::new(); + for i in 0..5i32 { + let args: serde_json::Map = + serde_json::from_value(serde_json::json!({"a": i, "b": i + 1}))?; + let start = Instant::now(); + let result = client + .call_tool(CallToolRequestParams::new("sum").with_arguments(args)) + .await?; + let elapsed = start.elapsed(); + durations.push(elapsed); + + assert!(result.is_error != Some(true)); + } + + let _ = client.cancel().await; + ct.cancel(); + server_handle.await?; + + // With connection reuse, localhost calls should complete well under 20 ms. + // Before the fix, they consistently took ~42 ms due to new TCP connections. + let max_allowed = std::time::Duration::from_millis(20); + for d in &durations { + assert!(*d < max_allowed); + } + + Ok(()) +} From 6603c1ff157fc4d46344f67bf0e5febdd4435519 Mon Sep 17 00:00:00 2001 From: WeekendsuperHero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:56:19 -0700 Subject: [PATCH 138/333] =?UTF-8?q?=20fix(macros):=20respect=20`local`=20f?= =?UTF-8?q?eature=20in=20`#[prompt]`=20macro=20=E2=80=94=20omit=20`+=20Sen?= =?UTF-8?q?d`=20bound=20(#803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(prompt): update return type handling * fix(prompt): add omit send and test --- crates/rmcp-macros/src/prompt.rs | 53 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/rmcp-macros/src/prompt.rs b/crates/rmcp-macros/src/prompt.rs index 20492a668..3bf02d2b9 100644 --- a/crates/rmcp-macros/src/prompt.rs +++ b/crates/rmcp-macros/src/prompt.rs @@ -20,6 +20,9 @@ pub struct PromptAttribute { pub icons: Option, /// Optional metadata for the prompt pub meta: Option, + /// When true, the generated future will not require `Send`. Useful for `!Send` handlers + /// (e.g. single-threaded database connections). Also enabled globally by the `local` crate feature. + pub local: bool, } pub struct ResolvedPromptAttribute { @@ -78,6 +81,7 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result }; let mut fn_item = syn::parse2::(input.clone())?; let fn_ident = &fn_item.sig.ident; + let omit_send = cfg!(feature = "local") || attribute.local; let prompt_attr_fn_ident = format_ident!("{}_prompt_attr", fn_ident); @@ -123,7 +127,8 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result // Modify the input function for async support (same as tool macro) if fn_item.sig.asyncness.is_some() { // 1. remove asyncness from sig - // 2. make return type: `futures::future::BoxFuture<'_, #ReturnType>` + // 2. make return type: `std::pin::Pin + Send + '_>>` + // (omit `+ Send` when the `local` crate feature is active or `#[prompt(local)]` is used) // 3. make body: { Box::pin(async move { #body }) } let new_output = syn::parse2::({ let mut lt = quote! { 'static }; @@ -138,10 +143,18 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result } match &fn_item.sig.output { syn::ReturnType::Default => { - quote! { -> ::std::pin::Pin + Send + #lt>> } + if omit_send { + quote! { -> ::std::pin::Pin + #lt>> } + } else { + quote! { -> ::std::pin::Pin + Send + #lt>> } + } } syn::ReturnType::Type(_, ty) => { - quote! { -> ::std::pin::Pin + Send + #lt>> } + if omit_send { + quote! { -> ::std::pin::Pin + #lt>> } + } else { + quote! { -> ::std::pin::Pin + Send + #lt>> } + } } } })?; @@ -226,4 +239,38 @@ mod test { Ok(()) } + + #[test] + fn test_async_prompt_default_send_behavior() -> syn::Result<()> { + let attr = quote! {}; + let input = quote! { + async fn test_prompt_default_send(&self) -> String { + "ok".to_string() + } + }; + let result = prompt(attr, input)?; + + let result_str = result.to_string(); + if cfg!(feature = "local") { + assert!(!result_str.contains("+ Send +")); + } else { + assert!(result_str.contains("+ Send +")); + } + Ok(()) + } + + #[test] + fn test_async_prompt_local_omits_send() -> syn::Result<()> { + let attr = quote! { local }; + let input = quote! { + async fn test_prompt_local_no_send(&self) -> String { + "ok".to_string() + } + }; + let result = prompt(attr, input)?; + + let result_str = result.to_string(); + assert!(!result_str.contains("+ Send +")); + Ok(()) + } } From 3e56d527641b6deebdba38e798ddf1294960f971 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:02:35 -0400 Subject: [PATCH 139/333] fix: include http_request_id in request-wise priming event IDs (#799) * fix: include http_request_id in request-wise priming event IDs * refactor: use Option::into_iter and usize::from for priming * fix: retain event cache for completed request-wise channels * fix: track completed_at for cache eviction and resume * fix: log resume failures at warn level * test: add completed_cache_ttl eviction test * fix: return empty stream on failed resume * test: add resume after completion test --- .../streamable_http_server/session/local.rs | 132 ++++--- .../transport/streamable_http_server/tower.rs | 84 +++-- .../tests/test_streamable_http_priming.rs | 340 +++++++++++++++++- 3 files changed, 467 insertions(+), 89 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 814f317d9..501fbb246 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -1,10 +1,10 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, num::ParseIntError, - time::Duration, + time::{Duration, Instant}, }; -use futures::Stream; +use futures::{Stream, StreamExt}; use thiserror::Error; use tokio::sync::{ mpsc::{Receiver, Sender}, @@ -86,10 +86,17 @@ impl SessionManager for LocalSessionManager { .get(id) .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; let receiver = handle.establish_request_wise_channel().await?; - handle - .push_message(message, receiver.http_request_id) - .await?; - Ok(ReceiverStream::new(receiver.inner)) + let http_request_id = receiver.http_request_id; + handle.push_message(message, http_request_id).await?; + + let priming = self.session_config.sse_retry.map(|retry| { + let event_id = match http_request_id { + Some(id) => format!("0/{id}"), + None => "0".into(), + }; + ServerSseMessage::priming(event_id, retry) + }); + Ok(futures::stream::iter(priming).chain(ReceiverStream::new(receiver.inner))) } async fn create_standalone_stream( @@ -188,23 +195,29 @@ struct CachedTx { cache: VecDeque, http_request_id: Option, capacity: usize, + starting_index: usize, } impl CachedTx { - fn new(tx: Sender, http_request_id: Option) -> Self { + fn new( + tx: Sender, + http_request_id: Option, + starting_index: usize, + ) -> Self { Self { cache: VecDeque::with_capacity(tx.capacity()), capacity: tx.capacity(), tx, http_request_id, + starting_index, } } fn new_common(tx: Sender) -> Self { - Self::new(tx, None) + Self::new(tx, None, 0) } fn next_event_id(&self) -> EventId { - let index = self.cache.back().map_or(0, |m| { + let index = self.cache.back().map_or(self.starting_index, |m| { m.event_id .as_deref() .unwrap_or_default() @@ -272,6 +285,7 @@ impl CachedTx { struct HttpRequestWise { resources: HashSet, tx: CachedTx, + completed_at: Option, } type HttpRequestId = u64; @@ -342,23 +356,27 @@ pub struct StreamableHttpMessageReceiver { impl LocalSessionWorker { fn unregister_resource(&mut self, resource: &ResourceKey) { - if let Some(http_request_id) = self.resource_router.remove(resource) { - tracing::trace!(?resource, http_request_id, "unregister resource"); - if let Some(channel) = self.tx_router.get_mut(&http_request_id) { - // It's okey to do so, since we don't handle batch json rpc request anymore - // and this can be refactored after the batch request is removed in the coming version. - if channel.resources.is_empty() || matches!(resource, ResourceKey::McpRequestId(_)) - { - tracing::debug!(http_request_id, "close http request wise channel"); - if let Some(channel) = self.tx_router.remove(&http_request_id) { - for resource in channel.resources { - self.resource_router.remove(&resource); - } - } - } - } else { - tracing::warn!(http_request_id, "http request wise channel not found"); - } + let Some(http_request_id) = self.resource_router.remove(resource) else { + return; + }; + tracing::trace!(?resource, http_request_id, "unregister resource"); + let Some(channel) = self.tx_router.get_mut(&http_request_id) else { + tracing::warn!(http_request_id, "http request wise channel not found"); + return; + }; + if !channel.resources.is_empty() && !matches!(resource, ResourceKey::McpRequestId(_)) { + return; + } + tracing::debug!(http_request_id, "close http request wise channel"); + let resources: Vec<_> = channel.resources.drain().collect(); + channel.completed_at = Some(Instant::now()); + // Close the sender so the client's SSE stream ends, + // but keep the entry so the cache is available for + // late resume requests. + let (closed_tx, _) = tokio::sync::mpsc::channel(1); + channel.tx.tx = closed_tx; + for resource in resources { + self.resource_router.remove(&resource); } } fn register_resource(&mut self, resource: ResourceKey, http_request_id: HttpRequestId) { @@ -395,6 +413,11 @@ impl LocalSessionWorker { self.unregister_resource(&resource); } } + fn evict_expired_channels(&mut self) { + let ttl = self.session_config.completed_cache_ttl; + self.tx_router + .retain(|_, rw| rw.completed_at.is_none_or(|at| at.elapsed() < ttl)); + } fn next_http_request_id(&mut self) -> HttpRequestId { let id = self.next_http_request_id; self.next_http_request_id = self.next_http_request_id.wrapping_add(1); @@ -405,11 +428,13 @@ impl LocalSessionWorker { ) -> Result { let http_request_id = self.next_http_request_id(); let (tx, rx) = tokio::sync::mpsc::channel(self.session_config.channel_capacity); + let starting_index = usize::from(self.session_config.sse_retry.is_some()); self.tx_router.insert( http_request_id, HttpRequestWise { resources: Default::default(), - tx: CachedTx::new(tx, Some(http_request_id)), + tx: CachedTx::new(tx, Some(http_request_id), starting_index), + completed_at: None, }, ); tracing::debug!(http_request_id, "establish new request wise channel"); @@ -524,28 +549,25 @@ impl LocalSessionWorker { match last_event_id.http_request_id { Some(http_request_id) => { - if let Some(request_wise) = self.tx_router.get_mut(&http_request_id) { - // Resume existing request-wise channel - let channel = tokio::sync::mpsc::channel(self.session_config.channel_capacity); - let (tx, rx) = channel; - request_wise.tx.tx = tx; - let index = last_event_id.index; - // sync messages after index - request_wise.tx.sync(index).await?; - Ok(StreamableHttpMessageReceiver { - http_request_id: Some(http_request_id), - inner: rx, - }) - } else { - // Request-wise channel completed (POST response already delivered). - // The client's EventSource is reconnecting after the POST SSE stream - // ended. Fall through to common channel handling below. - tracing::debug!( - http_request_id, - "Request-wise channel completed, falling back to common channel" - ); - self.resume_or_shadow_common(last_event_id.index).await + let request_wise = self + .tx_router + .get_mut(&http_request_id) + .ok_or(SessionError::ChannelClosed(Some(http_request_id)))?; + let is_completed = request_wise.completed_at.is_some(); + let (tx, rx) = tokio::sync::mpsc::channel(self.session_config.channel_capacity); + request_wise.tx.tx = tx; + let index = last_event_id.index; + request_wise.tx.sync(index).await?; + if is_completed { + // Drop the sender after replaying so the stream ends + // instead of hanging indefinitely. + let (closed_tx, _) = tokio::sync::mpsc::channel(1); + request_wise.tx.tx = closed_tx; } + Ok(StreamableHttpMessageReceiver { + http_request_id: Some(http_request_id), + inner: rx, + }) } None => self.resume_or_shadow_common(last_event_id.index).await, } @@ -955,6 +977,7 @@ impl Worker for LocalSessionWorker { let ct = context.cancellation_token.clone(); let keep_alive = self.session_config.keep_alive.unwrap_or(Duration::MAX); loop { + self.evict_expired_channels(); let keep_alive_timeout = tokio::time::sleep(keep_alive); let event = tokio::select! { event = self.event_rx.recv() => { @@ -1076,11 +1099,22 @@ pub struct SessionConfig { /// Defaults to 5 minutes. Set to `None` to disable (not recommended /// for long-running servers behind proxies). pub keep_alive: Option, + /// SSE retry interval for priming events on request-wise streams. + /// When set, the session layer prepends a priming event with the correct + /// stream-identifying event ID to each request-wise SSE stream. + /// Default is 3 seconds, matching `StreamableHttpServerConfig::default()`. + pub sse_retry: Option, + /// How long to retain completed request-wise channel caches for late + /// resume requests. After this duration, completed entries are evicted + /// and resume will return an error. Default is 60 seconds. + pub completed_cache_ttl: Duration, } impl SessionConfig { pub const DEFAULT_CHANNEL_CAPACITY: usize = 16; pub const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(300); + pub const DEFAULT_SSE_RETRY: Duration = Duration::from_secs(3); + pub const DEFAULT_COMPLETED_CACHE_TTL: Duration = Duration::from_secs(60); } impl Default for SessionConfig { @@ -1088,6 +1122,8 @@ impl Default for SessionConfig { Self { channel_capacity: Self::DEFAULT_CHANNEL_CAPACITY, keep_alive: Some(Self::DEFAULT_KEEP_ALIVE), + sse_retry: Some(Self::DEFAULT_SSE_RETRY), + completed_cache_ttl: Self::DEFAULT_COMPLETED_CACHE_TTL, } } } diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 5dc7996c2..f2035bd7d 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -478,40 +478,52 @@ where .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned()); if let Some(last_event_id) = last_event_id { - // check if session has this event id - let stream = self + match self .session_manager .resume(&session_id, last_event_id) .await - .map_err(internal_error_response("resume session"))?; - // Resume doesn't need priming - client already has the event ID - Ok(sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )) - } else { - // create standalone stream - let stream = self - .session_manager - .create_standalone_stream(&session_id) - .await - .map_err(internal_error_response("create standalone stream"))?; - // Prepend priming event if sse_retry configured - let stream = if let Some(retry) = self.config.sse_retry { - let priming = ServerSseMessage::priming("0", retry); - futures::stream::once(async move { priming }) - .chain(stream) - .left_stream() - } else { - stream.right_stream() - }; - Ok(sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )) + { + Ok(stream) => { + return Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )); + } + Err(e) => { + // Return 200 with an immediately-closed empty stream. + // Returning an HTTP error would cause EventSource to retry + // with the same Last-Event-ID in an infinite loop. An empty + // 200 cleanly terminates the EventSource without delivering + // events from a different stream. + tracing::warn!("Resume failed ({e}), returning empty stream"); + return Ok(sse_stream_response( + futures::stream::empty(), + None, + self.config.cancellation_token.child_token(), + )); + } + } } + // No Last-Event-ID — create standalone stream + let stream = self + .session_manager + .create_standalone_stream(&session_id) + .await + .map_err(internal_error_response("create standalone stream"))?; + let stream = if let Some(retry) = self.config.sse_retry { + let priming = ServerSseMessage::priming("0", retry); + futures::stream::once(async move { priming }) + .chain(stream) + .left_stream() + } else { + stream.right_stream() + }; + Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )) } async fn handle_post(&self, request: Request) -> Result @@ -598,20 +610,14 @@ where match message { ClientJsonRpcMessage::Request(_) => { + // Priming for request-wise streams is handled by the + // session layer (SessionManager::create_stream) which + // has access to the http_request_id for correct event IDs. let stream = self .session_manager .create_stream(&session_id, message) .await .map_err(internal_error_response("get session"))?; - // Prepend priming event if sse_retry configured - let stream = if let Some(retry) = self.config.sse_retry { - let priming = ServerSseMessage::priming("0", retry); - futures::stream::once(async move { priming }) - .chain(stream) - .left_stream() - } else { - stream.right_stream() - }; Ok(sse_stream_response( stream, self.config.sse_keep_alive, diff --git a/crates/rmcp/tests/test_streamable_http_priming.rs b/crates/rmcp/tests/test_streamable_http_priming.rs index 3be3700b8..436d48227 100644 --- a/crates/rmcp/tests/test_streamable_http_priming.rs +++ b/crates/rmcp/tests/test_streamable_http_priming.rs @@ -2,7 +2,8 @@ use std::time::Duration; use rmcp::transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + StreamableHttpServerConfig, StreamableHttpService, + session::{SessionId, local::LocalSessionManager}, }; use tokio_util::sync::CancellationToken; @@ -54,7 +55,7 @@ async fn test_priming_on_stream_start() -> anyhow::Result<()> { let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); assert!(events.len() >= 2); - // Verify priming event (first event) + // Verify priming event (first event) — initialize uses "0" (no http_request_id) let priming_event = events[0]; assert!(priming_event.contains("id: 0")); assert!(priming_event.contains("retry: 3000")); @@ -71,6 +72,341 @@ async fn test_priming_on_stream_start() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn test_request_wise_priming_includes_http_request_id() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Calculator::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = tcp_listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + // Initialize the session + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + assert_eq!(response.status(), 200); + let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); + + // Send notifications/initialized + let status = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await? + .status(); + assert_eq!(status, 202); + + // First tool call — should get http_request_id 0 + let body = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) + .send() + .await? + .text() + .await?; + + let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); + assert!( + events.len() >= 2, + "expected priming + response, got: {body}" + ); + + // Priming event should encode the http_request_id (0) + let priming = events[0]; + assert!( + priming.contains("id: 0/0"), + "first request priming should be 0/0, got: {priming}" + ); + assert!(priming.contains("retry: 3000")); + + // Response event should use index 1 (since priming occupies index 0) + let response_event = events[1]; + assert!( + response_event.contains("id: 1/0"), + "first response event id should be 1/0, got: {response_event}" + ); + assert!(response_event.contains(r#""id":2"#)); + + // Second tool call — should get http_request_id 1 + let body = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sum","arguments":{"a":3,"b":4}}}"#) + .send() + .await? + .text() + .await?; + + let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); + assert!( + events.len() >= 2, + "expected priming + response, got: {body}" + ); + + let priming = events[0]; + assert!( + priming.contains("id: 0/1"), + "second request priming should be 0/1, got: {priming}" + ); + + let response_event = events[1]; + assert!( + response_event.contains("id: 1/1"), + "second response event id should be 1/1, got: {response_event}" + ); + assert!(response_event.contains(r#""id":3"#)); + + ct.cancel(); + handle.await?; + + Ok(()) +} + +#[tokio::test] +async fn test_resume_after_request_wise_channel_completed() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(Calculator::new()), + Default::default(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = tcp_listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + // Initialize session + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + assert_eq!(response.status(), 200); + let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); + + // Complete handshake + let status = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await? + .status(); + assert_eq!(status, 202); + + // Call a tool and consume the full response (channel completes) + let body = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) + .send() + .await? + .text() + .await?; + + let events: Vec<&str> = body.split("\n\n").filter(|e| !e.is_empty()).collect(); + assert!( + events.len() >= 2, + "expected priming + response, got: {body}" + ); + assert!(events[0].contains("id: 0/0")); + assert!(events[1].contains(r#""id":2"#)); + + // Resume with Last-Event-ID after the channel has completed. + // The server returns 200 — either with replayed cached events + // (if the channel is still retained) or an empty stream (if the + // session worker hasn't processed the completion yet). + let resume = client + .get(format!("http://{addr}/mcp")) + .header("Accept", "text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .header("last-event-id", "0/0") + .send() + .await?; + assert_eq!(resume.status(), 200); + + let resume_body = resume.text().await?; + // The stream should complete (not hang), regardless of whether + // it contains replayed events or is empty. + assert!( + !resume_body.contains("standalone"), + "should not receive events from a different stream" + ); + + ct.cancel(); + handle.await?; + + Ok(()) +} + +#[tokio::test] +async fn test_completed_cache_ttl_eviction() -> anyhow::Result<()> { + use std::sync::Arc; + + let ct = CancellationToken::new(); + let mut session_manager = LocalSessionManager::default(); + session_manager.session_config.completed_cache_ttl = Duration::from_millis(200); + let session_manager = Arc::new(session_manager); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = tcp_listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + // Initialize session + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + assert_eq!(response.status(), 200); + let session_id: SessionId = response.headers()["mcp-session-id"].to_str()?.into(); + + // Complete handshake + client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await?; + + // Call a tool and consume the response (channel completes) + let body = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}"#) + .send() + .await? + .text() + .await?; + assert!(body.contains(r#""id":2"#)); + + // Wait for TTL to expire (200ms) plus margin + tokio::time::sleep(Duration::from_millis(400)).await; + + // Send a notification to trigger an event loop iteration (runs eviction) + client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await?; + + // Small delay to ensure the eviction ran + tokio::time::sleep(Duration::from_millis(50)).await; + + // Resume after TTL — channel should be evicted. The server returns + // 200 with an empty stream (no events from a different stream). + let resume = client + .get(format!("http://{addr}/mcp")) + .header("Accept", "text/event-stream") + .header("mcp-session-id", session_id.to_string()) + .header("Mcp-Protocol-Version", "2025-06-18") + .header("last-event-id", "0/0") + .send() + .await?; + assert_eq!(resume.status(), 200); + + let body = resume.text().await?; + assert!( + !body.contains(r#""id":2"#), + "should NOT contain the old tool response after eviction, got: {body}" + ); + + ct.cancel(); + handle.await?; + + Ok(()) +} + #[tokio::test] async fn test_priming_on_stream_close() -> anyhow::Result<()> { use std::sync::Arc; From 01a6666429273ce221db290cf06b22ea53f50a50 Mon Sep 17 00:00:00 2001 From: jh-block Date: Thu, 16 Apr 2026 18:16:19 +0200 Subject: [PATCH 140/333] fix: treat resource metadata JSON parse failure as soft error (#810) In fetch_resource_metadata_from_url, a JSON parse failure on the response body caused a fatal AuthError::MetadataError, preventing discover_metadata() from falling through to direct .well-known/oauth-authorization-server discovery (Strategy B). MCP servers that return HTTP 200 with non-JSON content (e.g. HTML) at their base URL caused the OAuth flow to abort entirely, even when the server had a valid .well-known/oauth-authorization-server endpoint. Return Ok(None) on parse failure, consistent with how HTTP errors are already handled in the same function. --- crates/rmcp/src/transport/auth.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 34674df76..3aa3e9131 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1609,12 +1609,13 @@ impl AuthorizationManager { return Ok(None); } - let metadata = response - .json::() - .await - .map_err(|e| { - AuthError::MetadataError(format!("Failed to parse resource metadata: {}", e)) - })?; + let metadata = match response.json::().await { + Ok(metadata) => metadata, + Err(e) => { + debug!("failed to parse resource metadata as JSON: {}", e); + return Ok(None); + } + }; Ok(Some(metadata)) } From 020a38b6ad3d0f26487c464250a484fad2a06b0e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:47:24 -0400 Subject: [PATCH 141/333] chore: release v1.5.0 (#804) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 19e041e30..9e32d6c15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.4.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.4.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.5.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.5.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.4.0" +version = "1.5.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 01a820830..ec7063b92 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.4.0...rmcp-macros-v1.5.0) - 2026-04-16 + +### Fixed + +- *(macros)* respect `local` feature in `#[prompt]` macro — omit `+ Send` bound ([#803](https://github.com/modelcontextprotocol/rust-sdk/pull/803)) + ## [1.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.3.0...rmcp-macros-v1.4.0) - 2026-04-09 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 4051c9f3b..1ce71e588 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.4.0...rmcp-v1.5.0) - 2026-04-16 + +### Added + +- *(transport)* add constructors for non_exhaustive error types ([#806](https://github.com/modelcontextprotocol/rust-sdk/pull/806)) +- add 2025-11-25 protocol version support ([#802](https://github.com/modelcontextprotocol/rust-sdk/pull/802)) + +### Fixed + +- treat resource metadata JSON parse failure as soft error ([#810](https://github.com/modelcontextprotocol/rust-sdk/pull/810)) +- include http_request_id in request-wise priming event IDs ([#799](https://github.com/modelcontextprotocol/rust-sdk/pull/799)) +- *(http)* drain SSE stream for connection reuse ([#790](https://github.com/modelcontextprotocol/rust-sdk/pull/790)) + +### Other + +- *(deps)* update which requirement from 7 to 8 ([#807](https://github.com/modelcontextprotocol/rust-sdk/pull/807)) + ## [1.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.3.0...rmcp-v1.4.0) - 2026-04-09 ### Added From 7eb252aee71f1b845216e807165dad904a74d91d Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Tue, 21 Apr 2026 01:30:52 +0200 Subject: [PATCH 142/333] fix(docs): use correct Parameters syntax in tool examples (#814) The README examples used `#[tool(param)]` on function parameters, which is not a supported syntax and fails to compile. Replace with the `Parameters` wrapper pattern that the macros actually expect. Closes #812 --- README.md | 20 ++++++++++++++++---- docs/readme/README.zh-cn.md | 20 ++++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 670007af6..b6f2467db 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,13 @@ Tools let servers expose callable functions to clients. Each tool has a name, de The `#[tool]`, `#[tool_router]`, and `#[tool_handler]` macros handle all the wiring. For a tools-only server you can use `#[tool_router(server_handler)]` to skip the separate `ServerHandler` impl: ```rust,ignore -use rmcp::{tool, tool_router, ServiceExt, transport::stdio}; +use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, ServiceExt, transport::stdio}; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddParams { + a: i32, + b: i32, +} #[derive(Clone)] struct Calculator; @@ -149,7 +155,7 @@ struct Calculator; #[tool_router(server_handler)] impl Calculator { #[tool(description = "Add two numbers")] - fn add(&self, #[tool(param)] a: i32, #[tool(param)] b: i32) -> String { + fn add(&self, Parameters(AddParams { a, b }): Parameters) -> String { (a + b).to_string() } } @@ -165,7 +171,13 @@ async fn main() -> anyhow::Result<()> { When you need custom server metadata or multiple capabilities (tools + prompts), use explicit `#[tool_handler]`: ```rust,ignore -use rmcp::{tool, tool_router, tool_handler, ServerHandler, ServiceExt}; +use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, tool_handler, ServerHandler, ServiceExt}; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddParams { + a: i32, + b: i32, +} #[derive(Clone)] struct Calculator; @@ -173,7 +185,7 @@ struct Calculator; #[tool_router] impl Calculator { #[tool(description = "Add two numbers")] - fn add(&self, #[tool(param)] a: i32, #[tool(param)] b: i32) -> String { + fn add(&self, Parameters(AddParams { a, b }): Parameters) -> String { (a + b).to_string() } } diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md index 56261633b..70f0e5278 100644 --- a/docs/readme/README.zh-cn.md +++ b/docs/readme/README.zh-cn.md @@ -141,7 +141,13 @@ let quit_reason = server.cancel().await?; `#[tool]`、`#[tool_router]` 和 `#[tool_handler]` 宏负责所有连接工作。对于纯工具服务端,可以使用 `#[tool_router(server_handler)]` 来省略单独的 `ServerHandler` 实现: ```rust,ignore -use rmcp::{tool, tool_router, ServiceExt, transport::stdio}; +use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, ServiceExt, transport::stdio}; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddParams { + a: i32, + b: i32, +} #[derive(Clone)] struct Calculator; @@ -149,7 +155,7 @@ struct Calculator; #[tool_router(server_handler)] impl Calculator { #[tool(description = "Add two numbers")] - fn add(&self, #[tool(param)] a: i32, #[tool(param)] b: i32) -> String { + fn add(&self, Parameters(AddParams { a, b }): Parameters) -> String { (a + b).to_string() } } @@ -165,7 +171,13 @@ async fn main() -> anyhow::Result<()> { 当需要自定义服务端元数据或多种能力(工具 + 提示词)时,使用显式的 `#[tool_handler]`: ```rust,ignore -use rmcp::{tool, tool_router, tool_handler, ServerHandler, ServiceExt}; +use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, tool_handler, ServerHandler, ServiceExt}; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddParams { + a: i32, + b: i32, +} #[derive(Clone)] struct Calculator; @@ -173,7 +185,7 @@ struct Calculator; #[tool_router] impl Calculator { #[tool(description = "Add two numbers")] - fn add(&self, #[tool(param)] a: i32, #[tool(param)] b: i32) -> String { + fn add(&self, Parameters(AddParams { a, b }): Parameters) -> String { (a + b).to_string() } } From f6893a7d91b3cb54b97c4e70ae3719ae71da1a0a Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:00:15 -0400 Subject: [PATCH 143/333] ci: add semver check job for pull requests (#819) --- .github/workflows/ci.yml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c3b3dc0e..adf086873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,34 @@ jobs: - name: Run clippy run: cargo clippy --all-targets --all-features -- -D warnings - + + semver: + name: SemVer Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install cargo-semver-checks + uses: taiki-e/install-action@v2 + with: + tool: cargo-semver-checks + + - name: Check rmcp (default features) + run: | + cargo semver-checks \ + --package rmcp \ + --baseline-rev ${{ github.event.pull_request.base.sha }} \ + --only-explicit-features \ + --features default + spelling: name: spell check with typos runs-on: ubuntu-latest From 8f696e6788e9cd8160bacb80e90be806d259dbcb Mon Sep 17 00:00:00 2001 From: Guy Lichtman <1395797+glicht@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:06:18 +0300 Subject: [PATCH 144/333] feat: optional session store (resumabillity support) (#775) * feat: optional session store * fix: docs * fix: pr review comments * fix: add non_exhaustive * fix: support for non_exhaustive StreamableHttpServerConfig * fix: add SessionState::new --- crates/rmcp/Cargo.toml | 11 +- .../src/transport/streamable_http_server.rs | 2 +- .../streamable_http_server/session.rs | 53 +++ .../streamable_http_server/session/local.rs | 16 +- .../streamable_http_server/session/store.rs | 69 +++ .../transport/streamable_http_server/tower.rs | 407 ++++++++++++++++-- .../test_streamable_http_session_store.rs | 398 +++++++++++++++++ 7 files changed, 912 insertions(+), 44 deletions(-) create mode 100644 crates/rmcp/src/transport/streamable_http_server/session/store.rs create mode 100644 crates/rmcp/tests/test_streamable_http_session_store.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 5006681d4..9065c75b1 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -339,6 +339,16 @@ required-features = [ ] path = "tests/test_streamable_http_stale_session.rs" +[[test]] +name = "test_streamable_http_session_store" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] +path = "tests/test_streamable_http_session_store.rs" + [[test]] name = "test_streamable_http_connection_reuse" required-features = [ @@ -351,4 +361,3 @@ required-features = [ "transport-streamable-http-client-reqwest", ] path = "tests/test_streamable_http_connection_reuse.rs" - diff --git a/crates/rmcp/src/transport/streamable_http_server.rs b/crates/rmcp/src/transport/streamable_http_server.rs index 9cbb63cc0..df1945ab2 100644 --- a/crates/rmcp/src/transport/streamable_http_server.rs +++ b/crates/rmcp/src/transport/streamable_http_server.rs @@ -1,6 +1,6 @@ pub mod session; #[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] pub mod tower; -pub use session::{SessionId, SessionManager}; +pub use session::{RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker}; #[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] pub use tower::{StreamableHttpServerConfig, StreamableHttpService}; diff --git a/crates/rmcp/src/transport/streamable_http_server/session.rs b/crates/rmcp/src/transport/streamable_http_server/session.rs index dcdb25c86..4be265130 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session.rs @@ -30,6 +30,41 @@ use crate::{ pub mod local; pub mod never; +pub mod store; + +pub use store::{SessionState, SessionStore, SessionStoreError}; + +/// Extension marker inserted into the `initialize` request extensions during a +/// session restore replay. Handlers can check for its presence to distinguish a +/// cross-instance restore from a genuine client-initiated `initialize` request. +/// +/// ```rust,ignore +/// if req.extensions().get::().is_some() { +/// // this is a restore replay, not a fresh client connection +/// } +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct SessionRestoreMarker { + pub id: SessionId, +} + +/// The outcome of a [`SessionManager::restore_session`] call. +#[non_exhaustive] +#[derive(Debug)] +pub enum RestoreOutcome { + /// The session was just re-created from external state; the caller must + /// spawn an MCP handler against the returned transport and replay the + /// `initialize` handshake. + Restored(T), + /// The session was already present in memory (e.g. a concurrent request + /// already restored it). The caller should proceed as if `has_session` + /// had returned `true` — no further action is required. + AlreadyPresent, + /// This session manager does not support external-store restore. + /// The caller should fall through to the normal 404 response. + NotSupported, +} /// Controls how MCP sessions are created, validated, and closed. /// @@ -98,4 +133,22 @@ pub trait SessionManager: Send + Sync + 'static { ) -> impl Future< Output = Result + Send + Sync + 'static, Self::Error>, > + Send; + + /// Attempt to restore a previously-known session from external state, + /// creating a fresh in-memory session worker with the given `id`. + /// + /// See [`RestoreOutcome`] for the three possible results: + /// - [`RestoreOutcome::Restored`] — session re-created; caller must spawn + /// an MCP handler and replay the `initialize` handshake. + /// - [`RestoreOutcome::AlreadyPresent`] — session is already in memory + /// (e.g. a concurrent request restored it first); caller proceeds + /// normally. + /// - [`RestoreOutcome::NotSupported`] (default) — this session manager + /// does not support external-store restore; caller returns 404. + fn restore_session( + &self, + _id: SessionId, + ) -> impl Future, Self::Error>> + Send { + futures::future::ready(Ok(RestoreOutcome::NotSupported)) + } } diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 501fbb246..747a15e0e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -136,6 +136,20 @@ impl SessionManager for LocalSessionManager { handle.push_message(message, None).await?; Ok(()) } + + async fn restore_session( + &self, + id: SessionId, + ) -> Result, Self::Error> { + let mut sessions = self.sessions.write().await; + if sessions.contains_key(&id) { + // A concurrent request already restored this session. + return Ok(RestoreOutcome::AlreadyPresent); + } + let (handle, worker) = create_local_session(id.clone(), self.session_config.clone()); + sessions.insert(id, handle); + Ok(RestoreOutcome::Restored(WorkerTransport::spawn(worker))) + } } /// `/request_id>` @@ -188,7 +202,7 @@ impl std::str::FromStr for EventId { } } -use super::{ServerSseMessage, SessionManager}; +use super::{RestoreOutcome, ServerSseMessage, SessionManager}; struct CachedTx { tx: Sender, diff --git a/crates/rmcp/src/transport/streamable_http_server/session/store.rs b/crates/rmcp/src/transport/streamable_http_server/session/store.rs new file mode 100644 index 000000000..e9a6de2d8 --- /dev/null +++ b/crates/rmcp/src/transport/streamable_http_server/session/store.rs @@ -0,0 +1,69 @@ +use crate::model::InitializeRequestParams; + +/// State persisted to an external store for cross-instance session recovery. +/// +/// When a client reconnects to a different server instance, the new instance +/// loads this state to transparently replay the `initialize` handshake without +/// the client needing to re-initialize. +#[non_exhaustive] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SessionState { + /// Parameters from the client's original `initialize` request. + pub initialize_params: InitializeRequestParams, +} + +impl SessionState { + pub fn new(initialize_params: InitializeRequestParams) -> Self { + Self { initialize_params } + } +} + +/// Type alias for boxed session store errors. +pub type SessionStoreError = Box; + +/// Pluggable external session store for cross-instance recovery. +/// +/// Implement this trait to back sessions with Redis, a database, or any +/// key-value store. The simplest usage is to set +/// `StreamableHttpServerConfig::session_store` to an `Arc`. +/// +/// # Example (in-memory, for testing) +/// +/// ```rust,ignore +/// use std::{collections::HashMap, sync::Arc}; +/// use tokio::sync::RwLock; +/// use rmcp::transport::streamable_http_server::session::store::{ +/// SessionState, SessionStore, SessionStoreError, +/// }; +/// +/// #[derive(Default)] +/// struct InMemoryStore(Arc>>); +/// +/// #[async_trait::async_trait] +/// impl SessionStore for InMemoryStore { +/// async fn load(&self, id: &str) -> Result, SessionStoreError> { +/// Ok(self.0.read().await.get(id).cloned()) +/// } +/// async fn store(&self, id: &str, state: &SessionState) -> Result<(), SessionStoreError> { +/// self.0.write().await.insert(id.to_owned(), state.clone()); +/// Ok(()) +/// } +/// async fn delete(&self, id: &str) -> Result<(), SessionStoreError> { +/// self.0.write().await.remove(id); +/// Ok(()) +/// } +/// } +/// ``` +#[async_trait::async_trait] +pub trait SessionStore: Send + Sync + 'static { + /// Load session state for the given `session_id`. + /// + /// Returns `Ok(None)` when no entry exists (i.e. session is unknown to the store). + async fn load(&self, session_id: &str) -> Result, SessionStoreError>; + + /// Persist session state for the given `session_id`. + async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError>; + + /// Remove session state for the given `session_id`. + async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError>; +} diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index f2035bd7d..f7e1c3bb0 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, fmt::Display, sync::Arc, time::Duration}; +use std::{collections::HashMap, convert::Infallible, fmt::Display, sync::Arc, time::Duration}; use bytes::Bytes; use futures::{StreamExt, future::BoxFuture}; @@ -8,10 +8,15 @@ use http_body_util::{BodyExt, Full, combinators::BoxBody}; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; -use super::session::SessionManager; +use super::session::{ + RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker, SessionState, SessionStore, +}; use crate::{ RoleServer, - model::{ClientJsonRpcMessage, ClientRequest, GetExtensions, ProtocolVersion}, + model::{ + ClientJsonRpcMessage, ClientNotification, ClientRequest, GetExtensions, InitializeRequest, + InitializedNotification, ProtocolVersion, + }, serve_server, service::serve_directly, transport::{ @@ -59,6 +64,34 @@ pub struct StreamableHttpServerConfig { /// or with ports: /// allowed_hosts = ["example.com", "example.com:8080"] pub allowed_hosts: Vec, + /// Optional external session store for cross-instance recovery. + /// + /// When set, [`SessionState`] (the client's `initialize` parameters) is + /// persisted after a successful handshake and deleted when the session + /// closes. On any subsequent request that arrives at an instance with no + /// in-memory session, the store is consulted: if an entry is found the + /// session is transparently restored so the client does not need to + /// re-initialize. + /// + /// # Example + /// ```rust,ignore + /// use std::sync::Arc; + /// use rmcp::transport::streamable_http_server::{ + /// StreamableHttpServerConfig, session::SessionStore, + /// }; + /// + /// let config = StreamableHttpServerConfig { + /// session_store: Some(Arc::new(MyRedisStore::new())), + /// ..Default::default() + /// }; + /// ``` + pub session_store: Option>, +} + +impl std::fmt::Debug for dyn SessionStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } } impl Default for StreamableHttpServerConfig { @@ -70,6 +103,7 @@ impl Default for StreamableHttpServerConfig { json_response: false, cancellation_token: CancellationToken::new(), allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], + session_store: None, } } } @@ -331,6 +365,13 @@ pub struct StreamableHttpService { pub config: StreamableHttpServerConfig, session_manager: Arc, service_factory: Arc Result + Send + Sync>, + /// Tracks in-progress session restores so that concurrent requests for the + /// same unknown session ID wait for the first restore to complete rather + /// than racing to replay the initialize handshake. `None` when no external + /// session store is configured (avoids allocating the map). + pending_restores: Option< + Arc>>>>, + >, } impl Clone for StreamableHttpService { @@ -339,6 +380,7 @@ impl Clone for StreamableHttpService { config: self.config.clone(), session_manager: self.session_manager.clone(), service_factory: self.service_factory.clone(), + pending_restores: self.pending_restores.clone(), } } } @@ -369,6 +411,35 @@ where } } +/// Guard used inside [`StreamableHttpService::try_restore_from_store`]. +/// +/// Ensures the `pending_restores` map entry is always cleaned up — even when +/// the future is cancelled mid-await. +/// +/// `result` defaults to `false` (failure / cancellation). Only the success path +/// needs to set it to `true` before returning. +struct PendingRestoreGuard { + pending_restores: + Arc>>>>, + session_id: SessionId, + watch_tx: tokio::sync::watch::Sender>, + /// The value that will be broadcast to waiting tasks on drop. + result: bool, +} + +impl Drop for PendingRestoreGuard { + fn drop(&mut self) { + // `send` is synchronous — unblocks waiters immediately, no lock needed. + let _ = self.watch_tx.send(Some(self.result)); + // Remove the map entry asynchronously (requires the async write lock). + let pending_restores = self.pending_restores.clone(); + let session_id = self.session_id.clone(); + tokio::spawn(async move { + pending_restores.write().await.remove(&session_id); + }); + } +} + impl StreamableHttpService where S: crate::Service + Send + 'static, @@ -379,15 +450,233 @@ where session_manager: Arc, config: StreamableHttpServerConfig, ) -> Self { + let pending_restores = config.session_store.is_some().then(|| { + Arc::new(tokio::sync::RwLock::new(HashMap::< + SessionId, + tokio::sync::watch::Sender>, + >::new())) + }); Self { config, session_manager, service_factory: Arc::new(service_factory), + pending_restores, } } fn get_service(&self) -> Result { (self.service_factory)() } + + /// Spawn a task that runs `serve_server` for the given session, waits for + /// it to finish, and then calls `close_session`. + /// + /// `init_done_tx`: when `Some`, the sender is fired after `serve_server` + /// returns successfully, signalling to the caller that the MCP handshake + /// is complete. Used by `try_restore_from_store` to synchronise with the + /// restore `initialize` replay; `handle_post` passes `None`. + fn spawn_session_worker( + session_manager: Arc, + session_id: SessionId, + service: S, + transport: M::Transport, + init_done_tx: Option>, + ) where + S: crate::Service + Send + 'static, + M: SessionManager, + { + tokio::spawn(async move { + let svc = + serve_server::(service, transport) + .await; + match svc { + Ok(svc) => { + if let Some(tx) = init_done_tx { + let _ = tx.send(()); + } + let _ = svc.waiting().await; + } + Err(e) => { + tracing::error!("Failed to serve session: {e}"); + // Dropping init_done_tx (if Some) signals failure to the caller. + } + } + let _ = session_manager + .close_session(&session_id) + .await + .inspect_err(|e| { + tracing::error!("Failed to close session {session_id}: {e}"); + }); + }); + } + + /// Attempt to restore a session from the external store. + /// + /// Returns `true` when the session is available and ready to serve the + /// current request (either just restored or already in memory). Returns + /// `false` when no store is configured or the session ID is unknown. + /// + /// Concurrent requests for the same unknown session ID are serialized: the + /// first caller performs the full restore and handshake replay while others + /// subscribe to a `watch` channel and wait, avoiding duplicate handshakes. + async fn try_restore_from_store( + &self, + session_id: &SessionId, + parts: &http::request::Parts, + ) -> Result + where + S: crate::Service + Send + 'static, + M: SessionManager, + { + // Both fields are Some iff a session store is configured. + let (Some(pending_restores), Some(store)) = + (&self.pending_restores, &self.config.session_store) + else { + return Ok(false); + }; + + // Serialize concurrent restores for the same session ID. + // Write-lock once: if another task is already restoring, subscribe and wait; + // otherwise, register ourselves as the restoring task. + // Channel value: None = in progress, Some(true) = restored, Some(false) = not found/failed. + let (watch_tx, _watch_rx) = tokio::sync::watch::channel(None::); + { + let mut pending = pending_restores.write().await; + if let Some(tx) = pending.get(session_id) { + let mut rx = tx.subscribe(); + drop(pending); + // Wait for the restore to finish, then propagate the outcome. + let result = rx + .wait_for(|r| r.is_some()) + .await + .map(|r| r.unwrap_or(false)) + .unwrap_or(false); + return Ok(result); + } + pending.insert(session_id.clone(), watch_tx.clone()); + } + + // Guard: signals waiters and cleans up the map entry on drop + let mut guard = PendingRestoreGuard { + pending_restores: pending_restores.clone(), + session_id: session_id.clone(), + watch_tx: watch_tx.clone(), + result: false, + }; + + // --- Step 3: load from external store --- + let state = match store.load(session_id.as_ref()).await { + Ok(Some(s)) => s, + Ok(None) => { + return Ok(false); + } + Err(e) => { + tracing::error!( + session_id = session_id.as_ref(), + error = %e, + "session store load failed during restore" + ); + return Err(std::io::Error::other(e)); + } + }; + + // --- Step 4: ask the session manager to allocate an in-memory worker --- + let transport = match self + .session_manager + .restore_session(session_id.clone()) + .await + .map_err(|e| std::io::Error::other(e.to_string())) + { + Ok(RestoreOutcome::Restored(t)) => t, + Ok(RestoreOutcome::AlreadyPresent) => { + // Invariant violation: pending_restores ensures only one task can call + // restore_session per session ID, so AlreadyPresent is impossible here. + return Err(std::io::Error::other( + "restore_session returned AlreadyPresent unexpectedly; session manager might have modified the session store outside of the restore_session API", + )); + } + Ok(RestoreOutcome::NotSupported) => { + return Ok(false); + } + Err(e) => { + return Err(e); + } + }; + + // --- Step 5: replay the MCP initialize handshake --- + let service = match self.get_service() { + Ok(s) => s, + Err(e) => { + return Err(e); + } + }; + + // `serve_server` requires both the `initialize` request and the + // `notifications/initialized` notification before transitioning to + // the running state — we must send both before returning. + let mut restore_init = ClientJsonRpcMessage::request( + ClientRequest::InitializeRequest(InitializeRequest { + params: state.initialize_params, + ..Default::default() + }), + crate::model::NumberOrString::Number(0), + ); + restore_init.insert_extension(parts.clone()); + restore_init.insert_extension(SessionRestoreMarker { + id: session_id.clone(), + }); + let mut restore_initialized = ClientJsonRpcMessage::notification( + ClientNotification::InitializedNotification(InitializedNotification { + ..Default::default() + }), + ); + restore_initialized.insert_extension(parts.clone()); + restore_initialized.insert_extension(SessionRestoreMarker { + id: session_id.clone(), + }); + // Signal from the spawned task once serve_server finishes initialising. + let (init_done_tx, init_done_rx) = tokio::sync::oneshot::channel::<()>(); + + Self::spawn_session_worker( + self.session_manager.clone(), + session_id.clone(), + service, + transport, + Some(init_done_tx), + ); + + if let Err(e) = self + .session_manager + .initialize_session(session_id, restore_init) + .await + .map_err(|e| std::io::Error::other(e.to_string())) + { + return Err(e); + } + + if let Err(e) = self + .session_manager + .accept_message(session_id, restore_initialized) + .await + .map_err(|e| std::io::Error::other(e.to_string())) + { + return Err(e); + } + + if init_done_rx.await.is_err() { + return Err(std::io::Error::other( + "serve_server initialization failed during restore", + )); + } + + // Restore complete — wake any waiting concurrent requests. + guard.result = true; + + tracing::debug!( + session_id = session_id.as_ref(), + "session restored from external store" + ); + Ok(true) + } pub async fn handle(&self, request: Request) -> Response> where B: Body + Send + 'static, @@ -462,18 +751,26 @@ where .has_session(&session_id) .await .map_err(internal_error_response("check session"))?; + let (parts, _) = request.into_parts(); if !has_session { - // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions - return Ok(Response::builder() - .status(http::StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) - .expect("valid response")); + // Attempt transparent cross-instance restore from external store. + let restored = self + .try_restore_from_store(&session_id, &parts) + .await + .map_err(internal_error_response("restore session"))?; + if !restored { + // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions + return Ok(Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) + .expect("valid response")); + } } // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(request.headers())?; + validate_protocol_version_header(&parts.headers)?; // check if last event id is provided - let last_event_id = request - .headers() + let last_event_id = parts + .headers .get(HEADER_LAST_EVENT_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned()); @@ -585,11 +882,18 @@ where .await .map_err(internal_error_response("check session"))?; if !has_session { - // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions - return Ok(Response::builder() - .status(http::StatusCode::NOT_FOUND) - .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) - .expect("valid response")); + // Attempt transparent cross-instance restore from external store. + let restored = self + .try_restore_from_store(&session_id, &part) + .await + .map_err(internal_error_response("restore session"))?; + if !restored { + // MCP spec: server MUST respond with 404 Not Found for terminated/unknown sessions + return Ok(Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found: Session not found")).boxed()) + .expect("valid response")); + } } // Validate MCP-Protocol-Version header (per 2025-06-18 spec) @@ -641,6 +945,21 @@ where .create_session() .await .map_err(internal_error_response("create session"))?; + // Capture init params for external store persistence before + // extensions are injected (which would require Clone). + let stored_init_params = if self.config.session_store.is_some() { + if let ClientJsonRpcMessage::Request(req) = &message { + if let ClientRequest::InitializeRequest(init_req) = &req.request { + Some(init_req.params.clone()) + } else { + None + } + } else { + None + } + } else { + None + }; if let ClientJsonRpcMessage::Request(req) = &mut message { if !matches!(req.request, ClientRequest::InitializeRequest(_)) { return Err(unexpected_message_response("initialize request")); @@ -654,37 +973,36 @@ where .get_service() .map_err(internal_error_response("get service"))?; // spawn a task to serve the session - tokio::spawn({ - let session_manager = self.session_manager.clone(); - let session_id = session_id.clone(); - async move { - let service = serve_server::( - service, transport, - ) - .await; - match service { - Ok(service) => { - // on service created - let _ = service.waiting().await; - } - Err(e) => { - tracing::error!("Failed to create service: {e}"); - } - } - let _ = session_manager - .close_session(&session_id) - .await - .inspect_err(|e| { - tracing::error!("Failed to close session {session_id}: {e}"); - }); - } - }); + Self::spawn_session_worker( + self.session_manager.clone(), + session_id.clone(), + service, + transport, + None, + ); // get initialize response let response = self .session_manager .initialize_session(&session_id, message) .await .map_err(internal_error_response("create stream"))?; + // Persist session state to external store after a successful handshake. + if let (Some(store), Some(params)) = + (&self.config.session_store, stored_init_params) + { + let state = SessionState { + initialize_params: params, + }; + let _ = store + .store(session_id.as_ref(), &state) + .await + .inspect_err(|e| { + tracing::warn!( + "Failed to persist session {} to store: {e}", + session_id + ); + }); + } let stream = futures::stream::once(async move { ServerSseMessage::from_message(response) }); // Prepend priming event if sse_retry configured @@ -807,6 +1125,13 @@ where .close_session(&session_id) .await .map_err(internal_error_response("close session"))?; + // Remove from external store: a DELETE means the client intentionally + // ends the session, so the store entry is no longer needed. + if let Some(store) = &self.config.session_store { + let _ = store.delete(session_id.as_ref()).await.inspect_err(|e| { + tracing::warn!("Failed to delete session {} from store: {e}", session_id); + }); + } Ok(accepted_response()) } } diff --git a/crates/rmcp/tests/test_streamable_http_session_store.rs b/crates/rmcp/tests/test_streamable_http_session_store.rs new file mode 100644 index 000000000..91e77029e --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_session_store.rs @@ -0,0 +1,398 @@ +#![cfg(all( + feature = "client", + feature = "server", + feature = "transport-streamable-http-client-reqwest", + feature = "transport-streamable-http-server", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + ServiceExt, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::{SessionState, SessionStore, SessionStoreError, local::LocalSessionManager}, + }, + }, +}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +// --------------------------------------------------------------------------- +// Shared in-memory store used across tests +// --------------------------------------------------------------------------- + +#[derive(Default, Clone)] +struct InMemorySessionStore(Arc>>); + +impl InMemorySessionStore { + fn new() -> Self { + Self::default() + } + + async fn len(&self) -> usize { + self.0.read().await.len() + } +} + +#[async_trait::async_trait] +impl SessionStore for InMemorySessionStore { + async fn load(&self, session_id: &str) -> Result, SessionStoreError> { + Ok(self.0.read().await.get(session_id).cloned()) + } + + async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> { + self.0 + .write() + .await + .insert(session_id.to_owned(), state.clone()); + Ok(()) + } + + async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> { + self.0.write().await.remove(session_id); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Helper: spin up a StreamableHttpService backed by the given store and +// return the bound address together with the cancellation token. +// --------------------------------------------------------------------------- + +fn make_service( + session_store: Arc, + ct: &CancellationToken, +) -> StreamableHttpService { + StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), { + let mut cfg = StreamableHttpServerConfig::default(); + cfg.stateful_mode = true; + cfg.sse_keep_alive = None; + cfg.cancellation_token = ct.child_token(); + cfg.session_store = Some(session_store); + cfg + }) +} + +// --------------------------------------------------------------------------- +// Test 1 — state is persisted to the store after a successful handshake +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_session_state_persisted_to_store() -> anyhow::Result<()> { + let store = Arc::new(InMemorySessionStore::new()); + let ct = CancellationToken::new(); + let service = make_service(store.clone(), &ct); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + // Connect a full client — this performs the initialize + initialized handshake. + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), + ); + let client = ().serve(transport).await?; + + // Make a real request so the session is fully active. + let _resources = client.list_all_resources().await?; + + // The store should now contain exactly one session entry. + assert_eq!( + store.len().await, + 1, + "session state should be persisted to the store after initialization" + ); + + // Verify the stored state contains the expected client info. + let entries = store.0.read().await; + let state = entries.values().next().expect("store entry should exist"); + assert_eq!( + state.initialize_params.client_info.name, "rmcp", + "stored client_info.name should match the rmcp client" + ); + + let _ = client.cancel().await; + ct.cancel(); + handle.await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Test 2 — store entry is removed when the client sends HTTP DELETE +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_session_state_deleted_from_store_on_delete() -> anyhow::Result<()> { + let store = Arc::new(InMemorySessionStore::new()); + let session_manager = Arc::new(LocalSessionManager::default()); + let ct = CancellationToken::new(); + + let service = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager.clone(), { + let mut cfg = StreamableHttpServerConfig::default(); + cfg.stateful_mode = true; + cfg.sse_keep_alive = None; + cfg.cancellation_token = ct.child_token(); + cfg.session_store = Some(store.clone()); + cfg + }); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), + ); + let client = ().serve(transport).await?; + let _resources = client.list_all_resources().await?; + + assert_eq!(store.len().await, 1, "store should have one entry"); + + // Get the session ID from the server's in-memory map. + let session_id = { + let sessions = session_manager.sessions.read().await; + sessions + .keys() + .next() + .cloned() + .expect("session should exist") + }; + + // Send an explicit HTTP DELETE — this is the signal to remove from store. + let http_client = reqwest::Client::new(); + let response = http_client + .delete(format!("http://{addr}/mcp")) + .header("mcp-session-id", session_id.as_ref()) + .send() + .await?; + assert_eq!(response.status(), 202); + + assert_eq!( + store.len().await, + 0, + "store entry should be removed after explicit DELETE" + ); + + let _ = client.cancel().await; + ct.cancel(); + handle.await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helper: spin up a server on an ephemeral port and return its address and +// the join handle. The server shuts down when `ct` is cancelled. +// --------------------------------------------------------------------------- + +fn spawn_server( + session_store: Option>, + session_manager: Arc, + ct: &CancellationToken, +) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let svc = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, { + let mut cfg = StreamableHttpServerConfig::default(); + cfg.stateful_mode = true; + cfg.sse_keep_alive = None; + cfg.cancellation_token = ct.child_token(); + cfg.session_store = session_store; + cfg + }); + // Use std::net::TcpListener so the port is bound synchronously before + // we return — avoids a race between returning the addr and the server + // actually starting to accept connections. + let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + std_listener.set_nonblocking(true).unwrap(); + let addr = std_listener.local_addr().unwrap(); + let listener = tokio::net::TcpListener::from_std(std_listener).unwrap(); + let router = axum::Router::new().nest_service("/mcp", svc); + let handle = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + (addr, handle) +} + +// --------------------------------------------------------------------------- +// Test 3 — cross-instance session restore +// +// Both halves follow the same structure: +// +// Instance A initializes the session (session state may be saved to store) +// Instance A is fully shut down +// Instance B (fresh, no in-memory state) receives a request for the old ID +// +// Without a store → 404. With a shared store → transparent restore. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_cross_instance_session_restore() -> anyhow::Result<()> { + let http = reqwest::Client::new(); + + // ----------------------------------------------------------------------- + // Negative check: no session store → instance B returns 404. + // ----------------------------------------------------------------------- + { + // --- Instance A (no store): initialize --- + let ct_a = CancellationToken::new(); + let (addr_a, srv_a) = spawn_server(None, Arc::new(LocalSessionManager::default()), &ct_a); + + let init_resp = http + .post(format!("http://{addr_a}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#) + .send() + .await?; + assert_eq!( + init_resp.status(), + 200, + "instance A: initialize should succeed" + ); + let session_id = init_resp + .headers() + .get("mcp-session-id") + .expect("session ID header must be present") + .to_str()? + .to_owned(); + + // Shut down instance A completely. + ct_a.cancel(); + srv_a.await?; + + // --- Instance B (no store, fresh state): send request --- + let ct_b = CancellationToken::new(); + let (addr_b, srv_b) = spawn_server(None, Arc::new(LocalSessionManager::default()), &ct_b); + + let resp = http + .post(format!("http://{addr_b}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("mcp-session-id", &session_id) + .body(r#"{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}"#) + .send() + .await?; + assert_eq!( + resp.status(), + reqwest::StatusCode::NOT_FOUND, + "without a session store, instance B must return 404 for an unknown session ID" + ); + + ct_b.cancel(); + srv_b.await?; + } + + // ----------------------------------------------------------------------- + // Positive check: shared session store → instance B restores transparently. + // ----------------------------------------------------------------------- + { + let store: Arc = Arc::new(InMemorySessionStore::new()); + + // --- Instance A (with store): initialize --- + let ct_a = CancellationToken::new(); + let sm_a = Arc::new(LocalSessionManager::default()); + let (addr_a, srv_a) = spawn_server(Some(store.clone()), sm_a.clone(), &ct_a); + + let init_resp = http + .post(format!("http://{addr_a}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#) + .send() + .await?; + assert_eq!( + init_resp.status(), + 200, + "instance A: initialize should succeed" + ); + let original_session_id = init_resp + .headers() + .get("mcp-session-id") + .expect("session ID header must be present") + .to_str()? + .to_owned(); + + // Confirm the session was persisted. + let store_ref = store + .load(&original_session_id) + .await + .expect("store load should not error"); + assert!( + store_ref.is_some(), + "store should hold the session after initialization" + ); + + // Shut down instance A completely — session lives only in the store now. + ct_a.cancel(); + srv_a.await?; + + // --- Instance B (same store, fresh in-memory state): send request --- + let ct_b = CancellationToken::new(); + let sm_b = Arc::new(LocalSessionManager::default()); + let (addr_b, srv_b) = spawn_server(Some(store.clone()), sm_b.clone(), &ct_b); + + let resp = http + .post(format!("http://{addr_b}/mcp")) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("mcp-session-id", &original_session_id) + .body(r#"{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}"#) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "instance B: request must succeed after transparent restore" + ); + + // The session must be in instance B's memory under the ORIGINAL ID. + { + let sessions = sm_b.sessions.read().await; + let restored_id = sessions + .keys() + .next() + .expect("session should exist in instance B after restore"); + assert_eq!( + restored_id.as_ref(), + original_session_id.as_str(), + "restored session must keep the original session ID" + ); + } + + ct_b.cancel(); + srv_b.await?; + } + + Ok(()) +} From 63583b164f54ec6ed2f17c98450487c747d34aeb Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 22 Apr 2026 14:10:29 +0200 Subject: [PATCH 145/333] feat(router): support runtime disabling of tools (#809) * feat(router): support runtime disabling of tools Add methods to disable/enable tools at runtime. Disabled tools are hidden from listing, lookup, and execution, including in composed routers. Closes #477 * fix(router): simplify disable tool api * feat(router): auto-send tools/list_changed on disable/enable * refactor(router): simplify disable_route and notifier call --- crates/rmcp/src/handler/server/router.rs | 97 +++++- crates/rmcp/src/handler/server/router/tool.rs | 208 ++++++++++++- .../tests/test_tool_disable_notification.rs | 172 +++++++++++ crates/rmcp/tests/test_tool_routers.rs | 288 +++++++++++++++++- 4 files changed, 750 insertions(+), 15 deletions(-) create mode 100644 crates/rmcp/tests/test_tool_disable_notification.rs diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index 08beb61d2..45ff9a586 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -6,7 +6,7 @@ use tool::{IntoToolRoute, ToolRoute}; use super::ServerHandler; use crate::{ RoleServer, Service, - model::{ClientRequest, ListPromptsResult, ListToolsResult, ServerResult}, + model::{ClientNotification, ClientRequest, ListPromptsResult, ListToolsResult, ServerResult}, service::NotificationContext, }; @@ -18,6 +18,7 @@ pub struct Router { pub tool_router: tool::ToolRouter, pub prompt_router: prompt::PromptRouter, pub service: Arc, + peer_slot: Arc>>, } impl Router @@ -25,10 +26,14 @@ where S: ServerHandler, { pub fn new(service: S) -> Self { + let (notifier, peer_slot) = tool::ToolRouter::::deferred_peer_notifier(); + let mut tool_router = tool::ToolRouter::new(); + tool_router.set_notifier(notifier); Self { - tool_router: tool::ToolRouter::new(), + tool_router, prompt_router: prompt::PromptRouter::new(), service: Arc::new(service), + peer_slot, } } @@ -72,6 +77,12 @@ where notification: ::PeerNot, context: NotificationContext, ) -> Result<(), crate::ErrorData> { + if matches!( + ¬ification, + ClientNotification::InitializedNotification(_) + ) { + let _ = self.peer_slot.set(context.peer.clone()); + } self.service .handle_notification(notification, context) .await @@ -83,7 +94,10 @@ where ) -> Result<::Resp, crate::ErrorData> { match request { ClientRequest::CallToolRequest(request) => { - if self.tool_router.has_route(request.params.name.as_ref()) + if self + .tool_router + .map + .contains_key(request.params.name.as_ref()) || !self.tool_router.transparent_when_not_found { let tool_call_context = crate::handler::server::tool::ToolCallContext::new( @@ -134,6 +148,81 @@ where } fn get_info(&self) -> ::Info { - ServerHandler::get_info(&self.service) + let mut info = ServerHandler::get_info(&self.service); + info.capabilities + .tools + .get_or_insert_with(Default::default) + .list_changed = Some(true); + info + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::{ + model::{CallToolResult, ClientNotification, ServerNotification, Tool}, + service::{AtomicU32RequestIdProvider, Peer, PeerSinkMessage, RequestIdProvider}, + }; + + struct DummyHandler; + impl ServerHandler for DummyHandler {} + + async fn recv_notification( + rx: &mut tokio::sync::mpsc::Receiver>, + ) -> ServerNotification { + let msg = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("timed out") + .expect("channel closed"); + match msg { + PeerSinkMessage::Notification { + notification, + responder, + } => { + let _ = responder.send(Ok(())); + notification + } + other => panic!("expected notification, got {other:?}"), + } + } + + #[tokio::test] + async fn test_router_deferred_notifier_e2e() { + let mut router = Router::new(DummyHandler).with_tool(tool::ToolRoute::new_dyn( + Tool::new("my_tool", "test", Arc::new(Default::default())), + |_ctx| Box::pin(async { Ok(CallToolResult::default()) }), + )); + + let id_provider: Arc = + Arc::new(AtomicU32RequestIdProvider::default()); + let (peer, mut rx) = Peer::::new(id_provider, None); + + let context = crate::service::NotificationContext { + peer: peer.clone(), + meta: Default::default(), + extensions: Default::default(), + }; + router + .handle_notification( + ClientNotification::InitializedNotification(Default::default()), + context, + ) + .await + .unwrap(); + + router.tool_router.disable_route("my_tool"); + assert!(matches!( + recv_notification(&mut rx).await, + ServerNotification::ToolListChangedNotification(_) + )); + + router.tool_router.enable_route("my_tool"); + assert!(matches!( + recv_notification(&mut rx).await, + ServerNotification::ToolListChangedNotification(_) + )); } } diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 79a228ffe..07cdfaf03 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -298,13 +298,30 @@ where self } } -#[derive(Debug)] #[non_exhaustive] pub struct ToolRouter { #[allow(clippy::type_complexity)] pub map: std::collections::HashMap, ToolRoute>, pub transparent_when_not_found: bool, + + disabled: std::collections::HashSet>, + + notifier: Option>, +} + +impl std::fmt::Debug for ToolRouter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToolRouter") + .field("map", &self.map) + .field( + "transparent_when_not_found", + &self.transparent_when_not_found, + ) + .field("disabled", &self.disabled) + .field("notifier", &self.notifier.as_ref().map(|_| "...")) + .finish() + } } impl Default for ToolRouter { @@ -312,14 +329,19 @@ impl Default for ToolRouter { Self { map: std::collections::HashMap::new(), transparent_when_not_found: false, + disabled: std::collections::HashSet::new(), + notifier: None, } } } + impl Clone for ToolRouter { fn clone(&self) -> Self { Self { map: self.map.clone(), transparent_when_not_found: self.transparent_when_not_found, + disabled: self.disabled.clone(), + notifier: self.notifier.clone(), } } } @@ -329,7 +351,11 @@ impl IntoIterator for ToolRouter { type IntoIter = std::collections::hash_map::IntoValues, ToolRoute>; fn into_iter(self) -> Self::IntoIter { - self.map.into_values() + let mut map = self.map; + for name in &self.disabled { + map.remove(name); + } + map.into_values() } } @@ -338,10 +364,7 @@ where S: MaybeSend + 'static, { pub fn new() -> Self { - Self { - map: std::collections::HashMap::new(), - transparent_when_not_found: false, - } + Self::default() } pub fn with_route(mut self, route: R) -> Self where @@ -394,24 +417,134 @@ where } pub fn merge(&mut self, other: ToolRouter) { + self.disabled.extend(other.disabled); for item in other.map.into_values() { self.add_route(item); } } + /// Remove a tool route from the router. + /// + /// The disabled state is **preserved**: if the name was in the disabled + /// set, it stays there so that a future [`add_route`](Self::add_route) + /// or [`merge`](Self::merge) with the same name will inherit the + /// disabled state. To also clear the disabled marker, call + /// [`enable_route`](Self::enable_route) afterwards. pub fn remove_route(&mut self, name: &str) { self.map.remove(name); } + + /// Returns `true` if the tool is registered **and** not currently + /// disabled. pub fn has_route(&self, name: &str) -> bool { - self.map.contains_key(name) + self.map.contains_key(name) && !self.disabled.contains(name) + } + + /// Disable a tool by name. Hidden from `list_all`, `get`, rejected by + /// `call`. Re-enable with [`enable_route`](Self::enable_route). + /// + /// Returns `true` if the name was newly added to the disabled set. + /// The name is recorded even if no matching route exists yet, so routes + /// added later will inherit the disabled state. + pub fn disable_route(&mut self, name: impl Into>) -> bool { + let name = name.into(); + let was_visible = self.map.contains_key(&name) && !self.disabled.contains(&name); + if was_visible { + self.notify_if_visible(&name); + } + self.disabled.insert(name) + } + + /// Re-enable a previously disabled tool. Returns `true` if the name + /// was in the disabled set. + pub fn enable_route(&mut self, name: &str) -> bool { + let removed = self.disabled.remove(name); + if removed { + self.notify_if_visible(name); + } + removed + } + + /// Returns `true` if the tool exists in the router **and** is currently + /// disabled. Returns `false` if the tool does not exist or if the name + /// was pre-disabled without a matching route. + pub fn is_disabled(&self, name: &str) -> bool { + self.map.contains_key(name) && self.disabled.contains(name) + } + + /// Builder-style variant of [`disable_route`](Self::disable_route). + /// + /// The name is recorded even if no matching route has been added yet, + /// so it can be called before [`with_route`](Self::with_route) in a + /// builder chain. + pub fn with_disabled(mut self, name: impl Into>) -> Self { + self.disabled.insert(name.into()); + self + } + + /// Install a callback invoked when the visible tool list changes. + pub fn set_notifier(&mut self, f: impl Fn() + Send + Sync + 'static) { + self.notifier = Some(Arc::new(f)); + } + + pub fn clear_notifier(&mut self) { + self.notifier = None; } + + /// Install a notifier that sends `notifications/tools/list_changed` + /// via the given peer. + pub fn bind_peer_notifier(&mut self, peer: &crate::service::Peer) { + let peer = peer.clone(); + self.set_notifier(move || { + let peer = peer.clone(); + tokio::spawn(async move { + if let Err(e) = peer.notify_tool_list_changed().await { + tracing::warn!("failed to send tools/list_changed notification: {e}"); + } + }); + }); + } + + /// Deferred notifier: no-op until the peer slot is filled. + pub(crate) fn deferred_peer_notifier() -> ( + impl Fn() + Send + Sync + 'static, + Arc>>, + ) { + let peer_slot = + Arc::new(std::sync::OnceLock::>::new()); + let slot_clone = peer_slot.clone(); + let notifier = move || { + if let Some(peer) = slot_clone.get() { + let peer = peer.clone(); + tokio::spawn(async move { + if let Err(e) = peer.notify_tool_list_changed().await { + tracing::warn!("failed to send tools/list_changed notification: {e}"); + } + }); + } + }; + (notifier, peer_slot) + } + + fn notify_if_visible(&self, name: &str) { + if self.map.contains_key(name) { + if let Some(notifier) = &self.notifier { + notifier(); + } + } + } + pub async fn call( &self, context: ToolCallContext<'_, S>, ) -> Result { + let name = context.name(); + if self.disabled.contains(name) { + return Err(crate::ErrorData::invalid_params("tool not found", None)); + } let item = self .map - .get(context.name()) + .get(name) .ok_or_else(|| crate::ErrorData::invalid_params("tool not found", None))?; let result = (item.call)(context).await?; @@ -420,15 +553,24 @@ where } pub fn list_all(&self) -> Vec { - let mut tools: Vec<_> = self.map.values().map(|item| item.attr.clone()).collect(); + let mut tools: Vec<_> = self + .map + .values() + .filter(|item| !self.disabled.contains(&item.attr.name)) + .map(|item| item.attr.clone()) + .collect(); tools.sort_by(|a, b| a.name.cmp(&b.name)); tools } /// Get a tool definition by name. /// - /// Returns the tool if found, or `None` if no tool with the given name exists. + /// Returns the tool if found and enabled, or `None` if the tool does not + /// exist or is disabled. pub fn get(&self, name: &str) -> Option<&crate::model::Tool> { + if self.disabled.contains(name) { + return None; + } self.map.get(name).map(|r| &r.attr) } } @@ -453,3 +595,49 @@ where self.merge(other); } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::{ + RoleServer, + model::{CallToolRequestParams, ErrorCode, NumberOrString}, + service::{AtomicU32RequestIdProvider, Peer, RequestContext}, + }; + + struct DummyService; + impl crate::handler::server::ServerHandler for DummyService {} + + #[tokio::test] + async fn test_call_disabled_tool_returns_error() { + let service = DummyService; + let mut router = ToolRouter::new().with_route(ToolRoute::new_dyn( + crate::model::Tool::new("test_tool", "a test tool", Arc::new(Default::default())), + |_ctx| Box::pin(async { Ok(CallToolResult::default()) }), + )); + router.disable_route("test_tool"); + + let id_provider: Arc = + Arc::new(AtomicU32RequestIdProvider::default()); + let (peer, _rx) = Peer::::new(id_provider, None); + let ctx = crate::handler::server::tool::ToolCallContext::new( + &service, + CallToolRequestParams { + meta: None, + name: Cow::Borrowed("test_tool"), + arguments: None, + task: None, + }, + RequestContext::new(NumberOrString::Number(1), peer), + ); + + let err = router + .call(ctx) + .await + .expect_err("disabled tool should reject"); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + assert_eq!(err.message, "tool not found"); + } +} diff --git a/crates/rmcp/tests/test_tool_disable_notification.rs b/crates/rmcp/tests/test_tool_disable_notification.rs new file mode 100644 index 000000000..84037b59a --- /dev/null +++ b/crates/rmcp/tests/test_tool_disable_notification.rs @@ -0,0 +1,172 @@ +//! Integration tests for tool list change notifications. +#![cfg(all(feature = "client", not(feature = "local")))] + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use rmcp::{ + ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRoute, tool::ToolCallContext}, + model::{CallToolResult, ServerCapabilities, ServerInfo, Tool}, + service::{MaybeSendFuture, NotificationContext}, +}; +use tokio::sync::{Notify, RwLock}; + +#[derive(Clone)] +struct TestToolServer { + router: Arc>>, + trigger_disable: Arc, + trigger_enable: Arc, +} + +impl TestToolServer { + fn new() -> Self { + let mut tool_router = rmcp::handler::server::router::tool::ToolRouter::::new(); + tool_router.add_route(ToolRoute::new_dyn( + Tool::new("tool_a", "Tool A", Arc::new(Default::default())), + |_ctx| Box::pin(async { Ok(CallToolResult::default()) }), + )); + tool_router.add_route(ToolRoute::new_dyn( + Tool::new("tool_b", "Tool B", Arc::new(Default::default())), + |_ctx| Box::pin(async { Ok(CallToolResult::default()) }), + )); + Self { + router: Arc::new(RwLock::new(tool_router)), + trigger_disable: Arc::new(Notify::new()), + trigger_enable: Arc::new(Notify::new()), + } + } +} + +impl ServerHandler for TestToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + fn call_tool( + &self, + request: rmcp::model::CallToolRequestParams, + context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + MaybeSendFuture + '_ + { + async move { + let router = self.router.read().await; + let tcc = ToolCallContext::new(self, request, context); + router.call(tcc).await + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + + MaybeSendFuture + + '_ { + async move { + let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + }) + } + } + + fn on_initialized( + &self, + context: NotificationContext, + ) -> impl std::future::Future + MaybeSendFuture + '_ { + let router = self.router.clone(); + let trigger_disable = self.trigger_disable.clone(); + let trigger_enable = self.trigger_enable.clone(); + let peer = context.peer.clone(); + + async move { + router.write().await.bind_peer_notifier(&peer); + + let router = router.clone(); + tokio::spawn(async move { + trigger_disable.notified().await; + { + let mut r = router.write().await; + r.disable_route("tool_a"); + } + + trigger_enable.notified().await; + { + let mut r = router.write().await; + r.enable_route("tool_a"); + } + }); + } + } +} + +#[derive(Clone)] +struct TestToolClient { + notification_count: Arc, + notify: Arc, +} + +impl TestToolClient { + fn new() -> Self { + Self { + notification_count: Arc::new(AtomicUsize::new(0)), + notify: Arc::new(Notify::new()), + } + } +} + +impl ClientHandler for TestToolClient { + fn on_tool_list_changed( + &self, + _context: NotificationContext, + ) -> impl std::future::Future + MaybeSendFuture + '_ { + self.notification_count.fetch_add(1, Ordering::SeqCst); + self.notify.notify_one(); + std::future::ready(()) + } +} + +#[tokio::test] +async fn test_disable_enable_sends_tool_list_changed() { + let server = TestToolServer::new(); + let trigger_disable = server.trigger_disable.clone(); + let trigger_enable = server.trigger_enable.clone(); + + let client = TestToolClient::new(); + let notification_count = client.notification_count.clone(); + let client_notify = client.notify.clone(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { server.serve(server_transport).await }); + let client_service = client.serve(client_transport).await.unwrap(); + + let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + trigger_disable.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), client_notify.notified()) + .await + .expect("timed out waiting for tool_list_changed"); + assert_eq!(notification_count.load(Ordering::SeqCst), 1); + + let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 1); + assert_eq!(tools.tools[0].name, "tool_b"); + + trigger_enable.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), client_notify.notified()) + .await + .expect("timed out waiting for tool_list_changed"); + assert_eq!(notification_count.load(Ordering::SeqCst), 2); + + let tools = client_service.peer().list_tools(None).await.unwrap(); + assert_eq!(tools.tools.len(), 2); + + client_service.cancel().await.unwrap(); + server_handle.abort(); +} diff --git a/crates/rmcp/tests/test_tool_routers.rs b/crates/rmcp/tests/test_tool_routers.rs index c10665064..f2e28b0f3 100644 --- a/crates/rmcp/tests/test_tool_routers.rs +++ b/crates/rmcp/tests/test_tool_routers.rs @@ -1,5 +1,8 @@ #![cfg(not(feature = "local"))] -use std::collections::HashMap; +use std::{ + collections::HashMap, + sync::atomic::{AtomicUsize, Ordering}, +}; use futures::future::BoxFuture; use rmcp::{ @@ -84,3 +87,286 @@ fn test_tool_router_list_all_is_sorted() { "list_all() should return tools sorted alphabetically by name" ); } + +fn build_router() -> ToolRouter> { + ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)) + .with_route((async_function2_tool_attr(), async_function2)) + + TestHandler::<()>::test_router_1() + + TestHandler::<()>::test_router_2() +} + +#[test] +fn test_disable_route() { + let mut router = build_router(); + assert_eq!(router.list_all().len(), 4); + assert!(router.has_route("async_function")); + assert!(router.get("async_function").is_some()); + + assert!(router.disable_route("async_function")); + + assert_eq!(router.list_all().len(), 3); + assert!(!router.has_route("async_function")); + assert!(router.get("async_function").is_none()); + assert!(router.is_disabled("async_function")); + + // other tools unaffected + assert!(router.has_route("async_function2")); + assert!(router.get("async_function2").is_some()); + assert!(!router.is_disabled("async_function2")); +} + +#[test] +fn test_enable_route() { + let mut router = build_router(); + assert!(router.disable_route("async_function")); + assert!(!router.has_route("async_function")); + + assert!(router.enable_route("async_function")); + assert!(router.has_route("async_function")); + assert!(router.get("async_function").is_some()); + assert!(!router.is_disabled("async_function")); + assert_eq!(router.list_all().len(), 4); +} + +#[test] +fn test_with_disabled_builder() { + let router = build_router() + .with_disabled("async_function") + .with_disabled("sync_method"); + + assert_eq!(router.list_all().len(), 2); + assert!(!router.has_route("async_function")); + assert!(!router.has_route("sync_method")); + assert!(router.has_route("async_function2")); + assert!(router.has_route("async_method")); +} + +#[test] +fn test_disabled_tools_survive_merge() { + let mut router_a = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)); + assert!(router_a.disable_route("async_function")); + + let router_b = ToolRouter::>::new() + .with_route((async_function2_tool_attr(), async_function2)); + + router_a.merge(router_b); + + assert_eq!(router_a.list_all().len(), 1); + assert!(router_a.is_disabled("async_function")); + assert!(router_a.has_route("async_function2")); +} + +#[test] +fn test_disable_nonexistent_tool() { + let mut router = build_router(); + // should not panic; returns true because the name is newly added to disabled set + assert!(router.disable_route("does_not_exist")); + assert_eq!(router.list_all().len(), 4); + // is_disabled returns false for tools not in the map + assert!(!router.is_disabled("does_not_exist")); +} + +#[test] +fn test_remove_route_preserves_disabled_state() { + let mut router = build_router(); + assert!(router.disable_route("async_function")); + assert!(router.is_disabled("async_function")); + + router.remove_route("async_function"); + assert!(!router.has_route("async_function")); + // Disabled marker is preserved — is_disabled returns false (no route in map) + // but re-adding will inherit the disabled state (tested separately) + assert!(!router.is_disabled("async_function")); +} + +#[test] +fn test_remove_route_then_readd_stays_disabled() { + let mut router = build_router(); + assert!(router.disable_route("async_function")); + + router.remove_route("async_function"); + assert!(!router.has_route("async_function")); + + // Re-add the route — it should inherit the disabled state + let other = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)); + router.merge(other); + + assert!(!router.has_route("async_function")); + assert!(router.is_disabled("async_function")); + assert!(router.get("async_function").is_none()); +} + +#[test] +fn test_into_iter_skips_disabled() { + let router = build_router().with_disabled("async_function"); + let names: Vec<_> = router + .into_iter() + .map(|r| r.attr.name.to_string()) + .collect(); + assert_eq!(names.len(), 3); + assert!(!names.contains(&"async_function".to_string())); +} + +#[test] +fn test_pre_disable_before_add_route() { + // Disabling a name before adding a route with that name should + // result in the route being disabled once added. + let router = ToolRouter::>::new() + .with_disabled("async_function") + .with_route((async_function_tool_attr(), async_function)); + + assert_eq!(router.list_all().len(), 0); + assert!(router.is_disabled("async_function")); + assert!(!router.has_route("async_function")); +} + +#[test] +fn test_disabled_tool_invisible_across_all_queries() { + let router = build_router().with_disabled("async_function"); + + // Not listed + let names: Vec<_> = router.list_all().iter().map(|t| t.name.clone()).collect(); + assert!(!names.contains(&"async_function".into())); + // Not retrievable + assert!(router.get("async_function").is_none()); + // Not routable + assert!(!router.has_route("async_function")); + // But known as disabled + assert!(router.is_disabled("async_function")); +} + +#[test] +fn test_disable_route_then_add_route_blocks_tool() { + // Full pre-disable lifecycle via runtime mutation (not builder) + let mut router = ToolRouter::>::new(); + router.disable_route("async_function"); + + // Add route after disabling — tool should be blocked + let other = ToolRouter::>::new() + .with_route((async_function_tool_attr(), async_function)); + router.merge(other); + + assert!(router.is_disabled("async_function")); + assert!(!router.has_route("async_function")); + assert!(router.get("async_function").is_none()); + assert_eq!(router.list_all().len(), 0); +} + +#[test] +fn test_disable_enable_return_false_cases() { + let mut router = build_router(); + + // Repeated disable returns false + assert!(router.disable_route("async_function")); + assert!(!router.disable_route("async_function")); + + // Enable returns true, then false on repeat + assert!(router.enable_route("async_function")); + assert!(!router.enable_route("async_function")); + + // Enable on name never disabled returns false + assert!(!router.enable_route("async_function2")); + + // Enable on unknown name returns false + assert!(!router.enable_route("unknown")); +} + +// ── Notifier tests ────────────────────────────────────────────────────── + +fn counter_notifier() -> ( + impl Fn() + Send + Sync + 'static, + std::sync::Arc, +) { + let counter = std::sync::Arc::new(AtomicUsize::new(0)); + let c = counter.clone(); + let notifier = move || { + c.fetch_add(1, Ordering::SeqCst); + }; + (notifier, counter) +} + +#[test] +fn test_notifier_fires_on_disable_and_enable() { + let (notifier, counter) = counter_notifier(); + let mut router = build_router(); + router.set_notifier(notifier); + + assert!(router.disable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + assert!(!router.disable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + assert!(router.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 2); + + assert!(!router.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 2); +} + +#[test] +fn test_notifier_skips_nonexistent_tools() { + let (notifier, counter) = counter_notifier(); + let mut router = build_router(); + router.set_notifier(notifier); + + assert!(router.disable_route("does_not_exist")); + assert_eq!(counter.load(Ordering::SeqCst), 0); + + assert!(router.enable_route("does_not_exist")); + assert_eq!(counter.load(Ordering::SeqCst), 0); + + assert!(router.disable_route("future_tool")); + assert_eq!(counter.load(Ordering::SeqCst), 0); + assert!(router.enable_route("future_tool")); + assert_eq!(counter.load(Ordering::SeqCst), 0); +} + +#[test] +fn test_no_notifier_no_panic() { + let mut router = build_router(); + assert!(router.disable_route("async_function")); + assert!(router.enable_route("async_function")); + assert!(router.disable_route("async_function")); + assert!(!router.disable_route("async_function")); +} + +#[test] +fn test_clone_shares_notifier() { + let (notifier, counter) = counter_notifier(); + let mut router = build_router(); + router.set_notifier(notifier); + let mut cloned = router.clone(); + + assert!(cloned.disable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + assert!(router.disable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 2); + + cloned.clear_notifier(); + assert!(cloned.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 2); + + assert!(router.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 3); +} + +#[test] +fn test_pre_init_disable_silent_but_correct() { + let mut router = build_router(); + + assert!(router.disable_route("async_function")); + assert_eq!(router.list_all().len(), 3); + assert!(!router.has_route("async_function")); + + let (notifier, counter) = counter_notifier(); + router.set_notifier(notifier); + assert_eq!(counter.load(Ordering::SeqCst), 0); + + assert!(router.enable_route("async_function")); + assert_eq!(counter.load(Ordering::SeqCst), 1); +} From 9753d615108c7d2265944a191cddce6a5421d4e4 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:41:06 -0400 Subject: [PATCH 146/333] feat(http): add Origin header validation (#823) --- .../transport/streamable_http_server/tower.rs | 98 ++++++++++++++++ crates/rmcp/tests/test_custom_headers.rs | 108 ++++++++++++++++++ 2 files changed, 206 insertions(+) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index f7e1c3bb0..ff19206ca 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -64,6 +64,15 @@ pub struct StreamableHttpServerConfig { /// or with ports: /// allowed_hosts = ["example.com", "example.com:8080"] pub allowed_hosts: Vec, + /// Allowed browser origins for inbound `Origin` validation. + /// + /// Defaults to an empty list, which disables Origin validation. When + /// non-empty, requests carrying an `Origin` header must match per RFC 6454 + /// `(scheme, host, port)`; missing-`Origin` requests still pass. Entries + /// must include a scheme; `"null"` matches the browser's `Origin: null`. + /// examples: + /// allowed_origins = ["https://app.example.com", "http://localhost:8080"] + pub allowed_origins: Vec, /// Optional external session store for cross-instance recovery. /// /// When set, [`SessionState`] (the client's `initialize` parameters) is @@ -103,6 +112,7 @@ impl Default for StreamableHttpServerConfig { json_response: false, cancellation_token: CancellationToken::new(), allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], + allowed_origins: vec![], session_store: None, } } @@ -121,6 +131,18 @@ impl StreamableHttpServerConfig { self.allowed_hosts.clear(); self } + pub fn with_allowed_origins( + mut self, + allowed_origins: impl IntoIterator>, + ) -> Self { + self.allowed_origins = allowed_origins.into_iter().map(Into::into).collect(); + self + } + /// Disable Origin validation, reverting to the default ignore-Origin behavior. + pub fn disable_allowed_origins(mut self) -> Self { + self.allowed_origins.clear(); + self + } pub fn with_sse_keep_alive(mut self, duration: Option) -> Self { self.sse_keep_alive = duration; self @@ -243,6 +265,59 @@ fn host_is_allowed(host: &NormalizedAuthority, allowed_hosts: &[String]) -> bool }) } +#[derive(Debug, Clone, PartialEq, Eq)] +enum NormalizedOrigin { + Null, + Tuple { + scheme: String, + host: String, + port: Option, + }, +} + +fn parse_origin_value(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + if value.eq_ignore_ascii_case("null") { + return Some(NormalizedOrigin::Null); + } + let uri = http::Uri::try_from(value).ok()?; + let scheme = uri.scheme_str()?.to_ascii_lowercase(); + let authority = uri.authority()?; + Some(NormalizedOrigin::Tuple { + scheme, + host: normalize_host(authority.host()), + port: authority.port_u16(), + }) +} + +fn origin_is_allowed(origin: &NormalizedOrigin, allowed_origins: &[String]) -> bool { + if allowed_origins.is_empty() { + return true; + } + allowed_origins + .iter() + .filter_map(|raw| parse_origin_value(raw)) + .any(|allowed| match (&allowed, origin) { + (NormalizedOrigin::Null, NormalizedOrigin::Null) => true, + ( + NormalizedOrigin::Tuple { + scheme: a_scheme, + host: a_host, + port: a_port, + }, + NormalizedOrigin::Tuple { + scheme: o_scheme, + host: o_host, + port: o_port, + }, + ) => a_scheme == o_scheme && a_host == o_host && (a_port.is_none() || a_port == o_port), + _ => false, + }) +} + fn bad_request_response(message: &str) -> BoxResponse { let body = Full::from(message.to_string()).boxed(); @@ -274,7 +349,30 @@ fn validate_dns_rebinding_headers( if !host_is_allowed(&host, &config.allowed_hosts) { return Err(forbidden_response("Forbidden: Host header is not allowed")); } + validate_origin_header(headers, &config.allowed_origins)?; + Ok(()) +} +fn validate_origin_header( + headers: &HeaderMap, + allowed_origins: &[String], +) -> Result<(), BoxResponse> { + if allowed_origins.is_empty() { + return Ok(()); + } + let Some(origin_header) = headers.get(http::header::ORIGIN) else { + return Ok(()); + }; + let origin_str = origin_header + .to_str() + .map_err(|_| bad_request_response("Bad Request: Invalid Origin header encoding"))?; + let origin = parse_origin_value(origin_str) + .ok_or_else(|| bad_request_response("Bad Request: Invalid Origin header"))?; + if !origin_is_allowed(&origin, allowed_origins) { + return Err(forbidden_response( + "Forbidden: Origin header is not allowed", + )); + } Ok(()) } diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 0cdd1bc42..d2d536822 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -1030,3 +1030,111 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { let response = service.handle(wrong_port_request).await; assert_eq!(response.status(), http::StatusCode::FORBIDDEN); } + +#[cfg(all(feature = "transport-streamable-http-server", feature = "server"))] +mod origin_validation { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + fn service_with_allowed_origins( + origins: &[&str], + ) -> StreamableHttpService { + StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default().with_allowed_origins(origins.iter().copied()), + ) + } + + fn init_request(origin: Option<&str>) -> Request> { + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"} + } + }); + let mut builder = Request::builder() + .method(Method::POST) + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .header("Host", "localhost:8080"); + if let Some(origin) = origin { + builder = builder.header("Origin", origin); + } + builder + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap() + } + + #[tokio::test] + async fn allowlisted_origin_is_allowed() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service + .handle(init_request(Some("http://localhost:8080"))) + .await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn non_allowlisted_origin_is_forbidden() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service + .handle(init_request(Some("http://attacker.example"))) + .await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn missing_origin_passes_through() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service.handle(init_request(None)).await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn scheme_mismatch_is_forbidden() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service + .handle(init_request(Some("https://localhost:8080"))) + .await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn null_origin_is_allowed_when_allowlisted() { + let service = service_with_allowed_origins(&["null"]); + let response = service.handle(init_request(Some("null"))).await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn null_origin_is_forbidden_when_not_allowlisted() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service.handle(init_request(Some("null"))).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } +} From fffe138ec424e2e9e21781c7b3878405994a6082 Mon Sep 17 00:00:00 2001 From: Edward Burton Date: Thu, 23 Apr 2026 23:22:50 +0200 Subject: [PATCH 147/333] docs: add systemprompt-template to Built with rmcp (#820) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b6f2467db..f5f1bd3a1 100644 --- a/README.md +++ b/README.md @@ -990,6 +990,7 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. - [hyper-mcp](https://github.com/hyper-mcp-rs/hyper-mcp) - A fast, secure MCP server that extends its capabilities through WebAssembly (WASM) plugins - [rudof-mcp](https://github.com/rudof-project/rudof/tree/master/rudof_mcp) - RDF validation and data processing MCP server with ShEx/SHACL validation, SPARQL queries, and format conversion. Supports stdio and streamable HTTP transports with full MCP capabilities (tools, prompts, resources, logging, completions, tasks) - [McpMux](https://github.com/mcpmux/mcp-mux) - Desktop app to configure MCP servers once at McpMux, connect every AI client (Cursor, Claude Desktop, VS Code, Windsurf) through a single encrypted local gateway with Spaces for project organization, FeatureSets to switch toolsets per client, and a built-in server registry +- [systemprompt-template](https://github.com/systempromptio/systemprompt-template) - Single-binary Rust runtime providing MCP governance — authentication, authorisation, rate-limiting, audit trails, and cost tracking for AI agents. Self-hosted, air-gap capable, 3,300+ req/s with sub-5ms governance overhead ## Development From 4cf78736e7956fd9e37a9d9103bd8569816e9c6c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 1 May 2026 08:42:47 -0400 Subject: [PATCH 148/333] feat(http): log Host/Origin rejections (#826) --- .../transport/streamable_http_server/tower.rs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index ff19206ca..46b9550cf 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -330,13 +330,23 @@ fn bad_request_response(message: &str) -> BoxResponse { fn parse_host_header(headers: &HeaderMap) -> Result { let Some(host) = headers.get(http::header::HOST) else { + tracing::warn!("rejected request with missing Host header"); return Err(bad_request_response("Bad Request: missing Host header")); }; - let host = host + let host_str = host .to_str() + .inspect_err(|_| { + tracing::warn!(host = ?host, "rejected request with non-UTF-8 Host header"); + }) .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?; - let authority = http::uri::Authority::try_from(host) + let authority = http::uri::Authority::try_from(host_str) + .inspect_err(|_| { + tracing::warn!( + host = host_str, + "rejected request with malformed Host header" + ); + }) .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?; Ok(normalize_authority(authority.host(), authority.port_u16())) } @@ -347,6 +357,10 @@ fn validate_dns_rebinding_headers( ) -> Result<(), BoxResponse> { let host = parse_host_header(headers)?; if !host_is_allowed(&host, &config.allowed_hosts) { + tracing::warn!( + host = ?host, + "rejected request with disallowed Host header (possible DNS rebinding attempt)", + ); return Err(forbidden_response("Forbidden: Host header is not allowed")); } validate_origin_header(headers, &config.allowed_origins)?; @@ -365,10 +379,22 @@ fn validate_origin_header( }; let origin_str = origin_header .to_str() + .inspect_err(|_| { + tracing::warn!(origin = ?origin_header, "rejected request with non-UTF-8 Origin header"); + }) .map_err(|_| bad_request_response("Bad Request: Invalid Origin header encoding"))?; - let origin = parse_origin_value(origin_str) - .ok_or_else(|| bad_request_response("Bad Request: Invalid Origin header"))?; + let origin = parse_origin_value(origin_str).ok_or_else(|| { + tracing::warn!( + origin = origin_str, + "rejected request with malformed Origin header", + ); + bad_request_response("Bad Request: Invalid Origin header") + })?; if !origin_is_allowed(&origin, allowed_origins) { + tracing::warn!( + origin = ?origin, + "rejected request with disallowed Origin header (possible cross-origin attack)", + ); return Err(forbidden_response( "Forbidden: Origin header is not allowed", )); From ef7414711330e471ec2bfa575eba1dd93305faaf Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 1 May 2026 09:17:06 -0400 Subject: [PATCH 149/333] fix(http): fall back to :authority for HTTP/2 (#827) --- .../transport/streamable_http_server/tower.rs | 54 ++++++----- crates/rmcp/tests/test_custom_headers.rs | 89 +++++++++++++++++++ 2 files changed, 121 insertions(+), 22 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 46b9550cf..5993c75b1 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -328,34 +328,42 @@ fn bad_request_response(message: &str) -> BoxResponse { .expect("failed to build bad request response") } -fn parse_host_header(headers: &HeaderMap) -> Result { - let Some(host) = headers.get(http::header::HOST) else { - tracing::warn!("rejected request with missing Host header"); - return Err(bad_request_response("Bad Request: missing Host header")); - }; - - let host_str = host - .to_str() - .inspect_err(|_| { - tracing::warn!(host = ?host, "rejected request with non-UTF-8 Host header"); - }) - .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?; - let authority = http::uri::Authority::try_from(host_str) - .inspect_err(|_| { - tracing::warn!( - host = host_str, - "rejected request with malformed Host header" - ); - }) - .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?; +fn parse_host_header( + uri: &http::Uri, + headers: &HeaderMap, +) -> Result { + if let Some(host) = headers.get(http::header::HOST) { + let host_str = host + .to_str() + .inspect_err(|_| { + tracing::warn!(host = ?host, "rejected request with non-UTF-8 Host header"); + }) + .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?; + let authority = http::uri::Authority::try_from(host_str) + .inspect_err(|_| { + tracing::warn!( + host = host_str, + "rejected request with malformed Host header" + ); + }) + .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?; + return Ok(normalize_authority(authority.host(), authority.port_u16())); + } + // HTTP/2 carries the host in `:authority`; middleware such as + // `axum::Router::nest` can drop the `Host` header hyper synthesizes from it. + let authority = uri.authority().ok_or_else(|| { + tracing::warn!("rejected request with missing Host header and no :authority"); + bad_request_response("Bad Request: missing Host header") + })?; Ok(normalize_authority(authority.host(), authority.port_u16())) } fn validate_dns_rebinding_headers( + uri: &http::Uri, headers: &HeaderMap, config: &StreamableHttpServerConfig, ) -> Result<(), BoxResponse> { - let host = parse_host_header(headers)?; + let host = parse_host_header(uri, headers)?; if !host_is_allowed(&host, &config.allowed_hosts) { tracing::warn!( host = ?host, @@ -806,7 +814,9 @@ where B: Body + Send + 'static, B::Error: Display, { - if let Err(response) = validate_dns_rebinding_headers(request.headers(), &self.config) { + if let Err(response) = + validate_dns_rebinding_headers(request.uri(), request.headers(), &self.config) + { return response; } let method = request.method().clone(); diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index d2d536822..9b9dfc058 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -1031,6 +1031,95 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { assert_eq!(response.status(), http::StatusCode::FORBIDDEN); } +/// Integration test: Verify the validator falls back to the URI authority when +/// the Host header is absent (HTTP/2 :authority pseudo-header scenario). +#[tokio::test] +#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] +async fn test_server_falls_back_to_uri_authority_when_host_header_missing() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Method, Request, header::CONTENT_TYPE}; + use http_body_util::Full; + use rmcp::{ + handler::server::ServerHandler, + model::{ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + use serde_json::json; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + } + } + + let service = StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let init_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + // Allowed authority via URI only — no Host header. + let allowed_request = Request::builder() + .method(Method::POST) + .uri("http://localhost:8080/") + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + assert!(allowed_request.headers().get("Host").is_none()); + + let response = service.handle(allowed_request).await; + assert_eq!(response.status(), http::StatusCode::OK); + + // Disallowed authority via URI only — no Host header. + let bad_request = Request::builder() + .method(Method::POST) + .uri("http://attacker.example/") + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + assert!(bad_request.headers().get("Host").is_none()); + + let response = service.handle(bad_request).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + + // Neither Host header nor URI authority — still a 400. + let missing_request = Request::builder() + .method(Method::POST) + .uri("/") + .header("Accept", "application/json, text/event-stream") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(init_body.to_string()))) + .unwrap(); + assert!(missing_request.headers().get("Host").is_none()); + assert!(missing_request.uri().authority().is_none()); + + let response = service.handle(missing_request).await; + assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); +} + #[cfg(all(feature = "transport-streamable-http-server", feature = "server"))] mod origin_validation { use std::sync::Arc; From c1e0eadd5d2010ec775e37ef0a9f47cb6995be78 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 1 May 2026 09:20:50 -0400 Subject: [PATCH 150/333] fix: add init_timeout for streamable-http sessions (#811) --- .../streamable_http_server/session/local.rs | 33 +++++++--- .../test_streamable_http_init_timeout.rs | 63 +++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_init_timeout.rs diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 747a15e0e..15b283967 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -930,6 +930,8 @@ pub enum LocalSessionWorkerError { FailToHandleMessage(SessionError), #[error("keep alive timeout after {}ms", _0.as_millis())] KeepAliveTimeout(Duration), + #[error("init timeout after {}ms", _0.as_millis())] + InitTimeout(Duration), #[error("Transport closed")] TransportClosed, #[error("Tokio join error {0}")] @@ -959,13 +961,24 @@ impl Worker for LocalSessionWorker { FromHttpService(SessionEvent), FromHandler(WorkerSendRequest), } - // waiting for initialize request - let evt = self.event_rx.recv().await.ok_or_else(|| { - WorkerQuitReason::fatal( - LocalSessionWorkerError::TransportTerminated, - "get initialize request", - ) - })?; + let init_timeout = self.session_config.init_timeout.unwrap_or(Duration::MAX); + let evt = tokio::select! { + evt = self.event_rx.recv() => evt.ok_or_else(|| { + WorkerQuitReason::fatal( + LocalSessionWorkerError::TransportTerminated, + "get initialize request", + ) + })?, + _ = context.cancellation_token.cancelled() => { + return Err(WorkerQuitReason::Cancelled); + } + _ = tokio::time::sleep(init_timeout) => { + return Err(WorkerQuitReason::fatal( + LocalSessionWorkerError::InitTimeout(init_timeout), + "waiting for initialize request", + )); + } + }; let SessionEvent::InitializeRequest { request, responder } = evt else { return Err(WorkerQuitReason::fatal( LocalSessionWorkerError::UnexpectedEvent(evt), @@ -1122,6 +1135,10 @@ pub struct SessionConfig { /// resume requests. After this duration, completed entries are evicted /// and resume will return an error. Default is 60 seconds. pub completed_cache_ttl: Duration, + /// Maximum duration to wait for the `initialize` request after session + /// creation. If not received within this window, the session is + /// terminated. Default is 60 seconds. Set to `None` to disable. + pub init_timeout: Option, } impl SessionConfig { @@ -1129,6 +1146,7 @@ impl SessionConfig { pub const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(300); pub const DEFAULT_SSE_RETRY: Duration = Duration::from_secs(3); pub const DEFAULT_COMPLETED_CACHE_TTL: Duration = Duration::from_secs(60); + pub const DEFAULT_INIT_TIMEOUT: Duration = Duration::from_secs(60); } impl Default for SessionConfig { @@ -1138,6 +1156,7 @@ impl Default for SessionConfig { keep_alive: Some(Self::DEFAULT_KEEP_ALIVE), sse_retry: Some(Self::DEFAULT_SSE_RETRY), completed_cache_ttl: Self::DEFAULT_COMPLETED_CACHE_TTL, + init_timeout: Some(Self::DEFAULT_INIT_TIMEOUT), } } } diff --git a/crates/rmcp/tests/test_streamable_http_init_timeout.rs b/crates/rmcp/tests/test_streamable_http_init_timeout.rs new file mode 100644 index 000000000..c9a2ee89b --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_init_timeout.rs @@ -0,0 +1,63 @@ +#![cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))] + +use std::time::Duration; + +use rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + transport::streamable_http_server::session::{SessionManager, local::LocalSessionManager}, +}; + +#[tokio::test] +async fn test_init_timeout_terminates_pre_init_session() -> anyhow::Result<()> { + let mut manager = LocalSessionManager::default(); + manager.session_config.init_timeout = Some(Duration::from_millis(200)); + + // Bind the transport so its drop-guard doesn't cancel the worker — we + // want termination via init_timeout, not via cancellation. + let (session_id, _transport) = manager.create_session().await?; + + tokio::time::sleep(Duration::from_millis(500)).await; + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + let result = manager.initialize_session(&session_id, message).await; + + assert!( + result.is_err(), + "expected worker to be dead; got: {result:?}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_init_timeout_none_keeps_worker_alive() -> anyhow::Result<()> { + let mut manager = LocalSessionManager::default(); + manager.session_config.init_timeout = None; + + let (session_id, _transport) = manager.create_session().await?; + + tokio::time::sleep(Duration::from_millis(500)).await; + + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + // Liveness probe: a live worker accepts the send then stalls waiting for + // a handler response (none is wired up), tripping the outer timeout. A + // dead worker would fail the send and return immediately. + let probe = tokio::time::timeout( + Duration::from_millis(200), + manager.initialize_session(&session_id, message), + ) + .await; + + assert!( + probe.is_err(), + "expected worker to be alive; got: {probe:?}" + ); + + Ok(()) +} From 014fb2e6cd9faddbe86ae30b5cc9adf84a62edb9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 09:38:25 -0400 Subject: [PATCH 151/333] chore: release v1.6.0 (#818) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 10 ++++++++++ crates/rmcp/CHANGELOG.md | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9e32d6c15..4ae0713fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.5.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.5.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.6.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.6.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.5.0" +version = "1.6.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index ec7063b92..7145dda15 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.5.0...rmcp-macros-v1.6.0) - 2026-05-01 + +### Fixed + +- *(docs)* use correct Parameters syntax in tool examples ([#814](https://github.com/modelcontextprotocol/rust-sdk/pull/814)) + +### Other + +- add systemprompt-template to Built with rmcp ([#820](https://github.com/modelcontextprotocol/rust-sdk/pull/820)) + ## [1.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.4.0...rmcp-macros-v1.5.0) - 2026-04-16 ### Fixed diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 1ce71e588..c98f78b04 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.5.0...rmcp-v1.6.0) - 2026-05-01 + +### Added + +- *(http)* log Host/Origin rejections ([#826](https://github.com/modelcontextprotocol/rust-sdk/pull/826)) +- *(http)* add Origin header validation ([#823](https://github.com/modelcontextprotocol/rust-sdk/pull/823)) +- *(router)* support runtime disabling of tools ([#809](https://github.com/modelcontextprotocol/rust-sdk/pull/809)) +- optional session store (resumabillity support) ([#775](https://github.com/modelcontextprotocol/rust-sdk/pull/775)) + +### Fixed + +- add init_timeout for streamable-http sessions ([#811](https://github.com/modelcontextprotocol/rust-sdk/pull/811)) +- *(http)* fall back to :authority for HTTP/2 ([#827](https://github.com/modelcontextprotocol/rust-sdk/pull/827)) +- *(docs)* use correct Parameters syntax in tool examples ([#814](https://github.com/modelcontextprotocol/rust-sdk/pull/814)) + +### Other + +- add systemprompt-template to Built with rmcp ([#820](https://github.com/modelcontextprotocol/rust-sdk/pull/820)) + ## [1.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.4.0...rmcp-v1.5.0) - 2026-04-16 ### Added From 2f8d3b73551dd9fbd2ffc50393f349778a2f848c Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Tue, 5 May 2026 02:14:06 +0200 Subject: [PATCH 152/333] Fix/issue 817 idle timeout log level (#824) * fix(transport): downgrade idle timeout log from error to debug Idle keep-alive timeout is normal zombie-session cleanup, not a transport failure. Route it through a dedicated WorkerQuitReason::IdleTimeout variant. Log it at debug level instead of treating it as a fatal error. Remove the unused LocalSessionWorkerError::KeepAliveTimeout variant. Closes #817 * fix(session): tolerate dead worker in close_session Swallow SessionServiceTerminated in close_session when the worker has already exited. This prevents a spurious ERROR log during the post-exit cleanup path in spawn_session_worker. * fix(transport): address PR review feedback - deprecate KeepAliveTimeout - harden tests --- .../streamable_http_server/session/local.rs | 16 +- crates/rmcp/src/transport/worker.rs | 7 +- .../test_streamable_http_idle_timeout_log.rs | 252 ++++++++++++++++++ 3 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_idle_timeout_log.rs diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 15b283967..ed3604759 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -66,9 +66,16 @@ impl SessionManager for LocalSessionManager { Ok(response) } async fn close_session(&self, id: &SessionId) -> Result<(), Self::Error> { - let mut sessions = self.sessions.write().await; - if let Some(handle) = sessions.remove(id) { - handle.close().await?; + let handle = { + let mut sessions = self.sessions.write().await; + sessions.remove(id) + }; + if let Some(handle) = handle { + match handle.close().await { + // Worker already exited — nothing left to clean up. + Ok(()) | Err(SessionError::SessionServiceTerminated) => {} + Err(e) => return Err(e.into()), + } } Ok(()) } @@ -928,6 +935,7 @@ pub enum LocalSessionWorkerError { FailToSendInitializeRequest(SessionError), #[error("fail to handle message: {0}")] FailToHandleMessage(SessionError), + #[deprecated(note = "idle timeout now surfaces as WorkerQuitReason::IdleTimeout")] #[error("keep alive timeout after {}ms", _0.as_millis())] KeepAliveTimeout(Duration), #[error("init timeout after {}ms", _0.as_millis())] @@ -1021,7 +1029,7 @@ impl Worker for LocalSessionWorker { return Err(WorkerQuitReason::Cancelled) } _ = keep_alive_timeout => { - return Err(WorkerQuitReason::fatal(LocalSessionWorkerError::KeepAliveTimeout(keep_alive), "poll next session event")) + return Err(WorkerQuitReason::IdleTimeout(keep_alive)) } }; match event { diff --git a/crates/rmcp/src/transport/worker.rs b/crates/rmcp/src/transport/worker.rs index a5d722d44..5294640e5 100644 --- a/crates/rmcp/src/transport/worker.rs +++ b/crates/rmcp/src/transport/worker.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::{borrow::Cow, time::Duration}; use tokio_util::sync::CancellationToken; use tracing::{Instrument, Level}; @@ -22,6 +22,8 @@ pub enum WorkerQuitReason { TransportClosed, #[error("Handler terminated")] HandlerTerminated, + #[error("Worker idle timeout after {}ms", _0.as_millis())] + IdleTimeout(Duration), } impl WorkerQuitReason { @@ -122,7 +124,8 @@ impl WorkerTransport { .inspect_err(|e| match e { WorkerQuitReason::Cancelled | WorkerQuitReason::TransportClosed - | WorkerQuitReason::HandlerTerminated => { + | WorkerQuitReason::HandlerTerminated + | WorkerQuitReason::IdleTimeout(_) => { tracing::debug!("worker quit with reason: {:?}", e); } WorkerQuitReason::Join(e) => { diff --git a/crates/rmcp/tests/test_streamable_http_idle_timeout_log.rs b/crates/rmcp/tests/test_streamable_http_idle_timeout_log.rs new file mode 100644 index 000000000..e0559b5b8 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_idle_timeout_log.rs @@ -0,0 +1,252 @@ +#![cfg(all( + feature = "transport-streamable-http-server", + feature = "transport-streamable-http-client-reqwest", + not(feature = "local") +))] + +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::{SessionManager, local::LocalSessionManager}, +}; +use tokio_util::sync::CancellationToken; +use tracing_subscriber::layer::SubscriberExt; + +mod common; +use common::calculator::Calculator; + +struct CapturedEvent { + level: tracing::Level, + target: String, + message: String, +} + +struct CapturingLayer { + events: Arc>>, +} + +impl tracing_subscriber::Layer for CapturingLayer { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + self.events.lock().unwrap().push(CapturedEvent { + level: *event.metadata().level(), + target: event.metadata().target().to_string(), + message: visitor.0, + }); + } +} + +struct MessageVisitor(String); + +impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{:?}", value); + } + } +} + +#[tokio::test(flavor = "current_thread")] +async fn test_keep_alive_timeout_does_not_emit_error_log() { + let events = Arc::new(Mutex::new(Vec::::new())); + + let subscriber = tracing_subscriber::registry().with(CapturingLayer { + events: events.clone(), + }); + + let _guard = tracing::subscriber::set_default(subscriber); + + let ct = CancellationToken::new(); + let mut session_manager = LocalSessionManager::default(); + session_manager.session_config.keep_alive = Some(Duration::from_millis(200)); + let session_manager = Arc::new(session_manager); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let session_id = response.headers()["mcp-session-id"] + .to_str() + .unwrap() + .to_string(); + + client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(400)).await; + + // Wait until close_session() has completed so all logs are captured. + let session_id_parsed: Arc = Arc::from(session_id.as_str()); + for _ in 0..20 { + if !session_manager + .has_session(&session_id_parsed) + .await + .unwrap() + { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!( + !session_manager + .has_session(&session_id_parsed) + .await + .unwrap(), + "session should have been removed after idle reap" + ); + + let captured = events.lock().unwrap(); + + let error_events: Vec<_> = captured + .iter() + .filter(|e| e.level == tracing::Level::ERROR && e.target.starts_with("rmcp")) + .collect(); + assert!( + error_events.is_empty(), + "idle reap should not produce any ERROR logs, found {}: {:?}", + error_events.len(), + error_events.iter().map(|e| &e.message).collect::>() + ); + + let debug_events: Vec<_> = captured + .iter() + .filter(|e| { + e.level == tracing::Level::DEBUG + && e.target.starts_with("rmcp") + && e.message.contains("IdleTimeout") + }) + .collect(); + assert!( + !debug_events.is_empty(), + "expected a DEBUG log with IdleTimeout, but found none" + ); + + ct.cancel(); +} + +#[tokio::test(flavor = "current_thread")] +async fn test_explicit_close_on_live_session_succeeds() { + let ct = CancellationToken::new(); + let mut session_manager = LocalSessionManager::default(); + session_manager.session_config.keep_alive = Some(Duration::from_secs(60)); + let session_manager = Arc::new(session_manager); + + let service = StreamableHttpService::new( + || Ok(Calculator::new()), + session_manager.clone(), + StreamableHttpServerConfig::default() + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + + let response = client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let session_id = response.headers()["mcp-session-id"] + .to_str() + .unwrap() + .to_string(); + + client + .post(format!("http://{addr}/mcp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await + .unwrap(); + + let session_id_parsed: Arc = Arc::from(session_id.as_str()); + + assert!( + session_manager + .has_session(&session_id_parsed) + .await + .unwrap(), + "session should exist before explicit close" + ); + + let result = session_manager.close_session(&session_id_parsed).await; + assert!( + result.is_ok(), + "close_session on a live worker should succeed: {result:?}" + ); + + assert!( + !session_manager + .has_session(&session_id_parsed) + .await + .unwrap(), + "session should not exist after explicit close" + ); + + ct.cancel(); +} From 88df9af9f212cf1abde4c752429240d5e63ed4b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 20:37:16 -0400 Subject: [PATCH 153/333] chore(deps): update askama requirement from 0.15 to 0.16 (#830) Updates the requirements on [askama](https://github.com/askama-rs/askama) to permit the latest version. - [Release notes](https://github.com/askama-rs/askama/releases) - [Commits](https://github.com/askama-rs/askama/compare/v0.15.0...v0.16.0) --- updated-dependencies: - dependency-name: askama dependency-version: 0.16.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/servers/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/servers/Cargo.toml b/examples/servers/Cargo.toml index 12e3aae6a..cea7ba6f1 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -39,7 +39,7 @@ reqwest = { version = "0.13.2", features = ["json"] } chrono = "0.4" uuid = { version = "1.6", features = ["v4", "serde"] } serde_urlencoded = "0.7" -askama = { version = "0.15" } +askama = { version = "0.16" } tower-http = { version = "0.6", features = ["cors"] } hyper = { version = "1" } hyper-util = { version = "0", features = ["server"] } From 3bf5298972d34e88bc3666ad601c8752718fc605 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 4 May 2026 20:44:16 -0400 Subject: [PATCH 154/333] ci: extend semver check to all features except local (#832) --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf086873..25c1c3137 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,18 @@ jobs: --only-explicit-features \ --features default + - name: Check rmcp (all features except local) + run: | + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] + | select(startswith("__") | not) + | select(. != "local")] | join(",")') + cargo semver-checks \ + --package rmcp \ + --baseline-rev ${{ github.event.pull_request.base.sha }} \ + --only-explicit-features \ + --features "$FEATURES" + spelling: name: spell check with typos runs-on: ubuntu-latest From 0f776ab1d66f5e0d41c117b949b1381a913e8272 Mon Sep 17 00:00:00 2001 From: Xuntao Chi Date: Thu, 7 May 2026 02:53:22 +0800 Subject: [PATCH 155/333] chore(rmcp): remove dependency on chrono default features (#829) --- crates/rmcp/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 9065c75b1..9e4d82c58 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -102,7 +102,7 @@ hyper-util = { version = "0.1", features = ["tokio"], optional = true } # macro rmcp-macros = { workspace = true, optional = true } [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] -chrono = { version = "0.4.38", features = ["serde"] } +chrono = { version = "0.4.38", default-features = false, features = ["serde", "now"] } [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] chrono = { version = "0.4.38", default-features = false, features = [ From 321ab14f67da734a8e0cfa0bfcdee1690663d9dc Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 7 May 2026 12:27:15 -0400 Subject: [PATCH 156/333] fix: reply -32700 on stdio parse errors instead of closing (#833) * fix: reply -32700 on stdio parse errors instead of closing * fix: make JsonRpcError id optional per MCP spec --- crates/rmcp/src/model.rs | 16 +- crates/rmcp/src/service.rs | 10 +- crates/rmcp/src/service/server.rs | 2 +- crates/rmcp/src/transport/async_rw.rs | 157 +++++++++++++++--- .../streamable_http_server/session/local.rs | 17 +- .../rmcp/tests/test_client_initialization.rs | 2 +- .../client_json_rpc_message_schema.json | 10 +- ...lient_json_rpc_message_schema_current.json | 10 +- .../server_json_rpc_message_schema.json | 10 +- ...erver_json_rpc_message_schema_current.json | 10 +- 10 files changed, 191 insertions(+), 53 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index b473e9ac5..4aabab1d0 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -461,13 +461,17 @@ pub struct JsonRpcResponse { #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct JsonRpcError { pub jsonrpc: JsonRpcVersion2_0, - pub id: RequestId, + // MCP 2025-11-25 §Error Responses: `id` is optional and omitted when the + // server cannot read the request id (e.g. parse error / invalid request). + // https://modelcontextprotocol.io/specification/2025-11-25/basic#error-responses + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, pub error: ErrorData, } impl JsonRpcError { /// Create a new JsonRpcError. - pub fn new(id: RequestId, error: ErrorData) -> Self { + pub fn new(id: Option, error: ErrorData) -> Self { Self { jsonrpc: JsonRpcVersion2_0, id, @@ -601,7 +605,7 @@ impl JsonRpcMessage { }) } #[inline] - pub const fn error(error: ErrorData, id: RequestId) -> Self { + pub const fn error(error: ErrorData, id: Option) -> Self { JsonRpcMessage::Error(JsonRpcError { jsonrpc: JsonRpcVersion2_0, id, @@ -633,15 +637,15 @@ impl JsonRpcMessage { _ => None, } } - pub fn into_error(self) -> Option<(ErrorData, RequestId)> { + pub fn into_error(self) -> Option<(ErrorData, Option)> { match self { JsonRpcMessage::Error(e) => Some((e.error, e.id)), _ => None, } } - pub fn into_result(self) -> Option<(Result, RequestId)> { + pub fn into_result(self) -> Option<(Result, Option)> { match self { - JsonRpcMessage::Response(r) => Some((Ok(r.result), r.id)), + JsonRpcMessage::Response(r) => Some((Ok(r.result), Some(r.id))), JsonRpcMessage::Error(e) => Some((Err(e.error), e.id)), _ => None, diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 65b5ee719..d938cd660 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -881,7 +881,7 @@ where Event::ToSink(m) => { if let Some(id) = match &m { JsonRpcMessage::Response(response) => Some(&response.id), - JsonRpcMessage::Error(error) => Some(&error.id), + JsonRpcMessage::Error(error) => error.id.as_ref(), _ => None, } { if let Some(ct) = local_ct_pool.remove(id) { @@ -971,7 +971,7 @@ where } Err(error) => { tracing::warn!(%id, ?error, "response error"); - JsonRpcMessage::error(error, id) + JsonRpcMessage::error(error, Some(id)) } }; let _send_result = sink.send(response).await; @@ -1028,6 +1028,12 @@ where } } Event::PeerMessage(JsonRpcMessage::Error(JsonRpcError { error, id, .. })) => { + let Some(id) = id else { + // MCP error responses without an id (e.g. Parse error / Invalid Request) + // can't be routed back to a pending request — log and drop. + tracing::debug!(?error, "received id-less peer error"); + continue; + }; if let Some(responder) = local_responder_pool.remove(&id) { let _response_result = responder.send(Err(ServiceError::McpError(error))); if let Err(_error) = _response_result { diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 82db47b8c..530e508e1 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -219,7 +219,7 @@ where } Err(e) => { transport - .send(ServerJsonRpcMessage::error(e.clone(), id)) + .send(ServerJsonRpcMessage::error(e.clone(), Some(id))) .await .map_err(|error| { ServerInitializeError::transport::(error, "sending error response") diff --git a/crates/rmcp/src/transport/async_rw.rs b/crates/rmcp/src/transport/async_rw.rs index b14d94c33..2ef0aae25 100644 --- a/crates/rmcp/src/transport/async_rw.rs +++ b/crates/rmcp/src/transport/async_rw.rs @@ -1,20 +1,22 @@ use std::{marker::PhantomData, sync::Arc}; -// use crate::schema::*; -use futures::{SinkExt, StreamExt}; +use futures::SinkExt; use serde::{Serialize, de::DeserializeOwned}; use thiserror::Error; use tokio::{ - io::{AsyncRead, AsyncWrite}, + io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}, sync::Mutex, }; use tokio_util::{ bytes::{Buf, BufMut, BytesMut}, - codec::{Decoder, Encoder, FramedRead, FramedWrite}, + codec::{Decoder, Encoder, FramedWrite}, }; use super::{IntoTransport, Transport}; -use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}; +use crate::{ + model::ErrorData, + service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage}, +}; #[non_exhaustive] pub enum TransportAdapterAsyncRW {} @@ -47,8 +49,10 @@ where pub type TransportWriter = FramedWrite>>; pub struct AsyncRwTransport { - read: FramedRead>>, + read: BufReader, + line_buf: Vec, write: Arc>>>, + _role: PhantomData Role>, } impl AsyncRwTransport @@ -57,15 +61,17 @@ where W: Send + AsyncWrite + Unpin + 'static, { pub fn new(read: R, write: W) -> Self { - let read = FramedRead::new( - read, - JsonRpcMessageCodec::>::default(), - ); + let read = BufReader::new(read); let write = Arc::new(Mutex::new(Some(FramedWrite::new( write, JsonRpcMessageCodec::>::default(), )))); - Self { read, write } + Self { + read, + line_buf: Vec::new(), + write, + _role: PhantomData, + } } } @@ -116,15 +122,43 @@ where } } - fn receive(&mut self) -> impl Future>> { - let next = self.read.next(); - async { - next.await.and_then(|e| { - e.inspect_err(|e| { + async fn receive(&mut self) -> Option> { + loop { + self.line_buf.clear(); + match self.read.read_until(b'\n', &mut self.line_buf).await { + Ok(0) => return None, + Ok(_) => {} + Err(e) => { tracing::error!("Error reading from stream: {}", e); - }) - .ok() - }) + return None; + } + } + let line = without_carriage_return( + self.line_buf.strip_suffix(b"\n").unwrap_or(&self.line_buf), + ); + if line.is_empty() { + continue; + } + match try_parse_with_compatibility::>(line, "receive") { + Ok(Some(msg)) => return Some(msg), + Ok(None) => continue, + Err(JsonRpcMessageCodecError::Serde(e)) => { + tracing::debug!("Parse error on incoming message: {e}"); + let mut write = self.write.lock().await; + let framed = write.as_mut()?; + let response = TxJsonRpcMessage::::error( + ErrorData::parse_error("Parse error", None), + None, + ); + if framed.send(response).await.is_err() { + return None; + } + } + Err(e) => { + tracing::error!("Error reading from stream: {}", e); + return None; + } + } } } @@ -172,13 +206,12 @@ impl JsonRpcMessageCodec { } fn without_carriage_return(s: &[u8]) -> &[u8] { - if let Some(&b'\r') = s.last() { - &s[..s.len() - 1] - } else { - s - } + s.strip_suffix(b"\r").unwrap_or(s) } +/// UTF-8 byte order mark. RFC 8259 §8.1 allows JSON parsers to ignore a leading BOM. +const UTF8_BOM: &[u8; 3] = b"\xEF\xBB\xBF"; + /// Check if a method is a standard MCP method (request, response, or notification). /// This includes both requests and notifications defined in the MCP specification. /// @@ -247,6 +280,7 @@ fn try_parse_with_compatibility( line: &[u8], context: &str, ) -> Result, JsonRpcMessageCodecError> { + let line = line.strip_prefix(UTF8_BOM.as_slice()).unwrap_or(line); if let Ok(line_str) = std::str::from_utf8(line) { match serde_json::from_slice(line) { Ok(item) => Ok(Some(item)), @@ -406,7 +440,8 @@ impl Encoder for JsonRpcMessageCodec { #[cfg(test)] mod test { - use futures::{Sink, Stream}; + use futures::{Sink, Stream, StreamExt}; + use tokio_util::codec::FramedRead; use super::*; fn from_async_read(reader: R) -> impl Stream { @@ -555,4 +590,76 @@ mod test { println!("Standard notifications are preserved, non-standard are handled gracefully"); } + + #[tokio::test] + async fn test_decode_strips_utf8_bom() { + use futures::StreamExt; + use tokio::io::BufReader; + + // Valid JSON-RPC message preceded by a UTF-8 BOM (EF BB BF). Some Windows + // tooling and editors prepend this; the codec should ignore it per RFC 8259 §8.1. + let mut data = Vec::new(); + data.extend_from_slice(UTF8_BOM); + data.extend_from_slice(br#"{"jsonrpc":"2.0","method":"ping","id":1}"#); + data.push(b'\n'); + + let mut cursor = BufReader::new(&data[..]); + let mut stream = from_async_read::(&mut cursor); + + let item = stream + .next() + .await + .expect("should decode BOM-prefixed line"); + assert_eq!( + item, + serde_json::json!({"jsonrpc": "2.0", "method": "ping", "id": 1}) + ); + } + + #[cfg(feature = "server")] + #[tokio::test] + async fn receive_recovers_from_parse_error() { + use tokio::io::AsyncWriteExt; + + use crate::{RoleServer, transport::Transport}; + + // Two paired streams: `server_io` is wrapped by the transport; the test + // drives `client_io` to act as the peer. + let (server_io, client_io) = tokio::io::duplex(4096); + let (server_r, server_w) = tokio::io::split(server_io); + let (mut client_r, mut client_w) = tokio::io::split(client_io); + + let mut transport = AsyncRwTransport::::new(server_r, server_w); + + client_w + .write_all( + b"not json\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + ) + .await + .unwrap(); + + let received = transport + .receive() + .await + .expect("transport should recover and yield the next valid message"); + + // Read one line back from the peer side and parse as JSON. + let mut reply_buf = Vec::new(); + let mut peer = tokio::io::BufReader::new(&mut client_r); + peer.read_until(b'\n', &mut reply_buf).await.unwrap(); + let reply: serde_json::Value = serde_json::from_slice(&reply_buf).unwrap(); + + // Per MCP 2025-11-25: id is omitted when the server can't read the request id. + assert_eq!( + reply, + serde_json::json!({ + "jsonrpc": "2.0", + "error": {"code": -32700, "message": "Parse error"}, + }) + ); + assert_eq!( + serde_json::to_value(&received).unwrap()["method"], + "notifications/initialized", + ); + } } diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index ed3604759..54c7b558e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -523,14 +523,12 @@ impl LocalSessionWorker { } } ServerJsonRpcMessage::Error(json_rpc_error) => { - if let Some(id) = self - .resource_router - .get(&ResourceKey::McpRequestId(json_rpc_error.id.clone())) - { - OutboundChannel::RequestWise { - id: *id, - close: true, - } + if let Some(id) = json_rpc_error.id.clone().and_then(|rid| { + self.resource_router + .get(&ResourceKey::McpRequestId(rid)) + .copied() + }) { + OutboundChannel::RequestWise { id, close: true } } else { OutboundChannel::Common } @@ -1041,8 +1039,7 @@ impl Worker for LocalSessionWorker { Some(ResourceKey::McpRequestId(request_id)) } crate::model::JsonRpcMessage::Error(json_rpc_error) => { - let request_id = json_rpc_error.id.clone(); - Some(ResourceKey::McpRequestId(request_id)) + json_rpc_error.id.clone().map(ResourceKey::McpRequestId) } _ => { None diff --git a/crates/rmcp/tests/test_client_initialization.rs b/crates/rmcp/tests/test_client_initialization.rs index 4a91f3ac3..f51b33ef7 100644 --- a/crates/rmcp/tests/test_client_initialization.rs +++ b/crates/rmcp/tests/test_client_initialization.rs @@ -30,7 +30,7 @@ async fn test_client_init_handles_jsonrpc_error() { let error_msg = ServerJsonRpcMessage::Error(JsonRpcError { jsonrpc: JsonRpcVersion2_0, - id: RequestId::Number(1), + id: Some(RequestId::Number(1)), error: ErrorData { code: ErrorCode(-32600), message: Cow::Borrowed("Invalid Request"), diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 8e082db94..f8f94c6c3 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -862,7 +862,14 @@ "$ref": "#/definitions/ErrorData" }, "id": { - "$ref": "#/definitions/NumberOrString" + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] }, "jsonrpc": { "$ref": "#/definitions/JsonRpcVersion2_0" @@ -870,7 +877,6 @@ }, "required": [ "jsonrpc", - "id", "error" ] }, diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 8e082db94..f8f94c6c3 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -862,7 +862,14 @@ "$ref": "#/definitions/ErrorData" }, "id": { - "$ref": "#/definitions/NumberOrString" + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] }, "jsonrpc": { "$ref": "#/definitions/JsonRpcVersion2_0" @@ -870,7 +877,6 @@ }, "required": [ "jsonrpc", - "id", "error" ] }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 405b3e022..c2af3fba5 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -1281,7 +1281,14 @@ "$ref": "#/definitions/ErrorData" }, "id": { - "$ref": "#/definitions/NumberOrString" + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] }, "jsonrpc": { "$ref": "#/definitions/JsonRpcVersion2_0" @@ -1289,7 +1296,6 @@ }, "required": [ "jsonrpc", - "id", "error" ] }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 405b3e022..c2af3fba5 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -1281,7 +1281,14 @@ "$ref": "#/definitions/ErrorData" }, "id": { - "$ref": "#/definitions/NumberOrString" + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] }, "jsonrpc": { "$ref": "#/definitions/JsonRpcVersion2_0" @@ -1289,7 +1296,6 @@ }, "required": [ "jsonrpc", - "id", "error" ] }, From d83b1566d0fd536dd6a1f676e206f7daa8068e7e Mon Sep 17 00:00:00 2001 From: Yutaka Nishimura Date: Wed, 13 May 2026 03:46:12 +0900 Subject: [PATCH 157/333] fix(rmcp): flatten Resource variant of PromptMessageContent (#843) The Resource variant of PromptMessageContent was missing #[serde(flatten)], causing the embedded resource content block to serialize as a double-nested shape `{ "type": "resource", "resource": { "resource": {...} } }` instead of the spec-compliant flat shape `{ "type": "resource", "resource": {uri, mimeType, text} }`. This caused Zod-based MCP clients (e.g. Claude Code) to reject prompts/get responses containing embedded resource messages with InvalidUnion errors. The Image and ResourceLink variants already use #[serde(flatten)] correctly; only Resource was missing it. Fix: add #[serde(flatten)] so EmbeddedResource (=Annotated) fields _meta / annotations / resource are flattened to the content-block level, matching the MCP spec for prompts embedded resources. Regression test: test_prompt_message_resource_serialization_is_flat verifies content.resource.uri is reachable and content.resource.resource is absent. Schema snapshots regenerated via UPDATE_SCHEMA=1. --- crates/rmcp/src/model/prompt.rs | 56 ++++++++++++++++++- .../server_json_rpc_message_schema.json | 55 ++++++++---------- ...erver_json_rpc_message_schema_current.json | 55 ++++++++---------- 3 files changed, 99 insertions(+), 67 deletions(-) diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index 72ea0e469..e3bf4061a 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -158,7 +158,10 @@ pub enum PromptMessageContent { image: ImageContent, }, /// Embedded server-side resource - Resource { resource: EmbeddedResource }, + Resource { + #[serde(flatten)] + resource: EmbeddedResource, + }, /// A link to a resource that can be fetched separately ResourceLink { #[serde(flatten)] @@ -321,6 +324,57 @@ mod tests { assert!(json.contains("\"name\":\"test.txt\"")); } + #[test] + fn test_prompt_message_resource_serialization_is_flat() { + // Regression test: PromptMessageContent::Resource must serialize to + // the spec-compliant flat shape `{ "type": "resource", "resource": { "uri", "mimeType", "text" } }` + // and NOT the double-nested shape `{ "type": "resource", "resource": { "resource": {...} } }`. + // See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts + let message = PromptMessage::new_resource( + PromptMessageRole::User, + "alc://packages/sc/narrative".to_string(), + Some("text/markdown".to_string()), + Some("# Hello".to_string()), + None, + None, + None, + ); + + let value: serde_json::Value = serde_json::to_value(&message).unwrap(); + + // Drill into content + let content = value.get("content").expect("content present"); + assert_eq!( + content.get("type").and_then(|v| v.as_str()), + Some("resource") + ); + + let resource = content + .get("resource") + .expect("resource field present at content level"); + + // Spec-compliant: resource.uri / resource.mimeType / resource.text MUST be flat + assert_eq!( + resource.get("uri").and_then(|v| v.as_str()), + Some("alc://packages/sc/narrative"), + "expected flat resource.uri, got: {resource:#?}" + ); + assert_eq!( + resource.get("mimeType").and_then(|v| v.as_str()), + Some("text/markdown") + ); + assert_eq!( + resource.get("text").and_then(|v| v.as_str()), + Some("# Hello") + ); + + // Regression guard: content.resource MUST NOT contain a nested `resource` key. + assert!( + resource.get("resource").is_none(), + "double-nested resource detected (regression): {resource:#?}" + ); + } + #[test] fn test_prompt_message_content_resource_link_deserialization() { let json = r#"{ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index c2af3fba5..24eb04f69 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -140,35 +140,6 @@ ] }, "Annotated2": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "$ref": "#/definitions/ResourceContents" - } - }, - "required": [ - "resource" - ] - }, - "Annotated3": { "description": "Represents a resource in the extension with metadata", "type": "object", "properties": { @@ -244,7 +215,7 @@ "name" ] }, - "Annotated4": { + "Annotated3": { "type": "object", "properties": { "annotations": { @@ -1481,7 +1452,7 @@ "resourceTemplates": { "type": "array", "items": { - "$ref": "#/definitions/Annotated4" + "$ref": "#/definitions/Annotated3" } } }, @@ -1508,7 +1479,7 @@ "resources": { "type": "array", "items": { - "$ref": "#/definitions/Annotated3" + "$ref": "#/definitions/Annotated2" } } }, @@ -2148,8 +2119,26 @@ "description": "Embedded server-side resource", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, "resource": { - "$ref": "#/definitions/Annotated2" + "$ref": "#/definitions/ResourceContents" }, "type": { "type": "string", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index c2af3fba5..24eb04f69 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -140,35 +140,6 @@ ] }, "Annotated2": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "$ref": "#/definitions/ResourceContents" - } - }, - "required": [ - "resource" - ] - }, - "Annotated3": { "description": "Represents a resource in the extension with metadata", "type": "object", "properties": { @@ -244,7 +215,7 @@ "name" ] }, - "Annotated4": { + "Annotated3": { "type": "object", "properties": { "annotations": { @@ -1481,7 +1452,7 @@ "resourceTemplates": { "type": "array", "items": { - "$ref": "#/definitions/Annotated4" + "$ref": "#/definitions/Annotated3" } } }, @@ -1508,7 +1479,7 @@ "resources": { "type": "array", "items": { - "$ref": "#/definitions/Annotated3" + "$ref": "#/definitions/Annotated2" } } }, @@ -2148,8 +2119,26 @@ "description": "Embedded server-side resource", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, "resource": { - "$ref": "#/definitions/Annotated2" + "$ref": "#/definitions/ResourceContents" }, "type": { "type": "string", From 5ccdfc07beb0d6f2d9a2ca1257c0caa10c13bf6e Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 13 May 2026 09:22:29 -0400 Subject: [PATCH 158/333] feat: add task-based stdio examples (#839) * feat: add task-based stdio examples * docs: add Tasks section to Chinese README --- README.md | 23 ++++ docs/readme/README.zh-cn.md | 21 ++++ examples/clients/Cargo.toml | 4 + examples/clients/README.md | 12 +++ examples/clients/src/task_stdio.rs | 127 +++++++++++++++++++++++ examples/servers/Cargo.toml | 4 + examples/servers/README.md | 10 ++ examples/servers/src/common/mod.rs | 1 + examples/servers/src/common/task_demo.rs | 93 +++++++++++++++++ examples/servers/src/task_stdio.rs | 27 +++++ 10 files changed, 322 insertions(+) create mode 100644 examples/clients/src/task_stdio.rs create mode 100644 examples/servers/src/common/task_demo.rs create mode 100644 examples/servers/src/task_stdio.rs diff --git a/README.md b/README.md index f5f1bd3a1..d3edc3383 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ For the full MCP specification, see [modelcontextprotocol.io](https://modelconte - [Completions](#completions) - [Notifications](#notifications) - [Subscriptions](#subscriptions) +- [Tasks](#tasks-long-running-tool-invocations) - [Examples](#examples) - [OAuth Support](#oauth-support) - [Related Resources](#related-resources) @@ -954,6 +955,28 @@ impl ClientHandler for MyClient { --- +## Tasks (long-running tool invocations) + +`rmcp` supports the [task-based tool invocation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) +flow defined in SEP-1319. Annotate a tool with `execution(task_support = "required" | "optional")` +and add `#[task_handler]` to your `ServerHandler` impl — `enqueue_task`, `tasks/list`, `tasks/get`, +`tasks/result`, and `tasks/cancel` are generated for you on top of an `OperationProcessor`. + +```rust, ignore +#[tool( + description = "Sum two numbers after a 2-second delay", + execution(task_support = "required") +)] +async fn slow_sum(/* ... */) -> Result { /* ... */ } + +#[tool_handler] +#[task_handler] +impl ServerHandler for TaskDemo {} +``` + +See [`servers_task_stdio`](examples/servers/src/task_stdio.rs) and the matching +[`clients_task_stdio`](examples/clients/src/task_stdio.rs) for a runnable end-to-end example. + ## Examples See [examples](examples/README.md). diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md index 70f0e5278..8c3d60671 100644 --- a/docs/readme/README.zh-cn.md +++ b/docs/readme/README.zh-cn.md @@ -31,6 +31,7 @@ - [补全](#补全) - [通知](#通知) - [订阅](#订阅) +- [任务](#任务长时间运行的工具调用) - [示例](#示例) - [OAuth 支持](#oauth-支持) - [相关资源](#相关资源) @@ -954,6 +955,26 @@ impl ClientHandler for MyClient { --- +## 任务(长时间运行的工具调用) + +`rmcp` 支持 SEP-1319 中定义的[基于任务的工具调用](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)流程。为工具添加 `execution(task_support = "required" | "optional")` 注解,并在 `ServerHandler` 实现上添加 `#[task_handler]` —— `enqueue_task`、`tasks/list`、`tasks/get`、`tasks/result` 和 `tasks/cancel` 将在 `OperationProcessor` 之上自动生成。 + +```rust, ignore +#[tool( + description = "Sum two numbers after a 2-second delay", + execution(task_support = "required") +)] +async fn slow_sum(/* ... */) -> Result { /* ... */ } + +#[tool_handler] +#[task_handler] +impl ServerHandler for TaskDemo {} +``` + +完整的端到端示例请参阅 [`servers_task_stdio`](../../examples/servers/src/task_stdio.rs) 及对应的 [`clients_task_stdio`](../../examples/clients/src/task_stdio.rs)。 + +--- + ## 示例 查看 [examples](../../examples/README.md)。 diff --git a/examples/clients/Cargo.toml b/examples/clients/Cargo.toml index f2ac8dd7d..416057ac8 100644 --- a/examples/clients/Cargo.toml +++ b/examples/clients/Cargo.toml @@ -61,3 +61,7 @@ path = "src/progress_client.rs" [[example]] name = "clients_client_credentials" path = "src/auth/client_credentials.rs" + +[[example]] +name = "clients_task_stdio" +path = "src/task_stdio.rs" diff --git a/examples/clients/README.md b/examples/clients/README.md index e066cd77d..4361d681d 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -59,6 +59,15 @@ A client demonstrating how to use the sampling tool. - Retrieves server information and list of available tools - Calls the `ask_llm` tool +### Task Standard I/O Client (`task_stdio.rs`) + +A client that exercises the task lifecycle against `servers_task_stdio` +(per [SEP-1319](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)). + +- Spawns `servers_task_stdio` as a child process over stdio +- Calls `quick_echo` synchronously +- Calls `slow_sum` as a task via `CallToolRequestParams::with_task(...)`, polls `tasks/get` until completion, then fetches the result via `tasks/result` + ### Progress Test Client (`progress_client.rs`) A client that communicates with an MCP server using progress notifications. @@ -91,6 +100,9 @@ cargo run -p mcp-client-examples --example clients_oauth_client # Run the sampling standard I/O client example cargo run -p mcp-client-examples --example clients_sampling_stdio + +# Run the task-based invocation client (drives servers_task_stdio) +cargo run -p mcp-client-examples --example clients_task_stdio ``` ## Dependencies diff --git a/examples/clients/src/task_stdio.rs b/examples/clients/src/task_stdio.rs new file mode 100644 index 000000000..472a9c370 --- /dev/null +++ b/examples/clients/src/task_stdio.rs @@ -0,0 +1,127 @@ +//! Client for the task-demo server in `examples/servers/src/task_stdio.rs`. +//! +//! Walks through the task lifecycle (SEP-1319): +//! 1. Call a regular tool (`quick_echo`) — synchronous response. +//! 2. Call a task-required tool (`slow_sum`) by attaching `task: {}` to +//! the `tools/call` request. The server returns a `Task` with a `task_id`. +//! 3. Poll `tasks/get` until status becomes `Completed`. +//! 4. Fetch the underlying `CallToolResult` via `tasks/result`. + +use anyhow::{Result, anyhow}; +use rmcp::{ + ServiceExt, + model::{ + CallToolRequestParams, CallToolResult, ClientRequest, GetTaskInfoParams, + GetTaskResultParams, JsonObject, Request, ServerResult, TaskStatus, + }, + object, + transport::{ConfigureCommandExt, TokioChildProcess}, +}; +use tokio::process::Command; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| format!("info,{}=debug", env!("CARGO_CRATE_NAME")).into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Spawn the task-demo server as a child process over stdio. + let client = () + .serve(TokioChildProcess::new(Command::new("cargo").configure( + |cmd| { + cmd.arg("run") + .arg("-q") + .arg("-p") + .arg("mcp-server-examples") + .arg("--example") + .arg("servers_task_stdio"); + }, + ))?) + .await?; + + // 1) Synchronous call. `quick_echo` has the default task_support = forbidden. + let echo = client + .call_tool( + CallToolRequestParams::new("quick_echo") + .with_arguments(object!({ "message": "hi from rmcp" })), + ) + .await?; + tracing::info!("quick_echo -> {echo:#?}"); + + // 2) Task call. `slow_sum` is task_support = required, so we MUST attach a + // `task` object. An empty object is fine — clients can stash arbitrary + // metadata here that the server-side `OperationDescriptor` will keep. + let create = client + .send_request(ClientRequest::CallToolRequest(Request::new( + CallToolRequestParams::new("slow_sum") + .with_arguments(object!({ "a": 40, "b": 2 })) + .with_task(JsonObject::new()), + ))) + .await?; + let ServerResult::CreateTaskResult(create) = create else { + return Err(anyhow!("expected CreateTaskResult, got {create:?}")); + }; + let task_id = create.task.task_id.clone(); + tracing::info!( + "slow_sum enqueued as task {task_id} (status = {:?})", + create.task.status + ); + + // 3) Poll `tasks/get` until the server reports a terminal status. + let final_status = loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + + let info = client + .send_request(ClientRequest::GetTaskInfoRequest(Request::new( + GetTaskInfoParams { + meta: None, + task_id: task_id.clone(), + }, + ))) + .await?; + let ServerResult::GetTaskResult(info) = info else { + return Err(anyhow!("expected GetTaskResult, got {info:?}")); + }; + tracing::info!("status = {:?}", info.task.status); + + match info.task.status { + TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => { + break info.task.status; + } + _ => {} + } + }; + + if final_status != TaskStatus::Completed { + return Err(anyhow!("task ended in {final_status:?}")); + } + + // 4) Fetch the payload. The server-side handler returns a serialized + // `CallToolResult`. On the wire the response is just a JSON value, and + // `ServerResult` is `#[serde(untagged)]`, so the client decodes it as + // whichever variant the JSON shape matches first — a `CallToolResult` + // here. (For a non-tool task the same value would surface as + // `ServerResult::CustomResult` and need manual `serde_json::from_value`.) + let payload = client + .send_request(ClientRequest::GetTaskResultRequest(Request::new( + GetTaskResultParams { + meta: None, + task_id: task_id.clone(), + }, + ))) + .await?; + let call_result: CallToolResult = match payload { + ServerResult::CallToolResult(r) => r, + ServerResult::CustomResult(c) => serde_json::from_value(c.0)?, + other => return Err(anyhow!("unexpected task result: {other:?}")), + }; + tracing::info!("slow_sum result -> {call_result:#?}"); + + client.cancel().await?; + Ok(()) +} diff --git a/examples/servers/Cargo.toml b/examples/servers/Cargo.toml index cea7ba6f1..7dbd2fb4d 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -109,3 +109,7 @@ path = "src/calculator_stdio.rs" [[example]] name = "elicitation_enum_select" path = "src/elicitation_enum_inference.rs" + +[[example]] +name = "servers_task_stdio" +path = "src/task_stdio.rs" diff --git a/examples/servers/README.md b/examples/servers/README.md index 946a2433f..69126519e 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -62,6 +62,16 @@ A server demonstrating the prompt framework capabilities. - Uses standard I/O transport - Good example of prompt implementation patterns +### Task Demo Server (`task_stdio.rs`) + +A minimal stdio server demonstrating task-based tool invocation per +[SEP-1319](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks). + +- `slow_sum` is declared with `execution(task_support = "required")`, so clients MUST invoke it as a task +- `quick_echo` is a regular synchronous tool for contrast +- Wires up `enqueue_task` / `tasks/get` / `tasks/result` / `tasks/cancel` via `#[task_handler]` +- Pair with `examples/clients/src/task_stdio.rs` to see the full lifecycle (create → poll → fetch result) + ### Progress Demo Server (`progress_demo.rs`) A server that demonstrates progress notifications during long-running operations. diff --git a/examples/servers/src/common/mod.rs b/examples/servers/src/common/mod.rs index 674a8b51a..32df83c78 100644 --- a/examples/servers/src/common/mod.rs +++ b/examples/servers/src/common/mod.rs @@ -2,3 +2,4 @@ pub mod calculator; pub mod counter; pub mod generic_service; pub mod progress_demo; +pub mod task_demo; diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs new file mode 100644 index 000000000..27bff2195 --- /dev/null +++ b/examples/servers/src/common/task_demo.rs @@ -0,0 +1,93 @@ +//! Minimal example of a tool that supports task-based invocation (SEP-1319). +//! +//! - `slow_sum` is marked `task_support = "required"`, so the client MUST invoke +//! it as a task. The server enqueues the call into an `OperationProcessor`, +//! returns a task id immediately, and the client polls `tasks/get` and +//! fetches the payload via `tasks/result`. +//! - `quick_echo` is a regular synchronous tool for contrast (the default, +//! `task_support = "forbidden"`). +//! +//! See `examples/clients/src/task_stdio.rs` for the matching client. + +#![allow(dead_code)] + +use std::sync::Arc; + +use rmcp::{ + ErrorData as McpError, ServerHandler, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolResult, Content}, + schemars, task_handler, + task_manager::OperationProcessor, + tool, tool_handler, tool_router, +}; +use tokio::sync::Mutex; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SumArgs { + pub a: i32, + pub b: i32, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct EchoArgs { + pub message: String, +} + +/// Server state. The `processor` field is required by `#[task_handler]`: +/// the macro generates `enqueue_task` / `tasks/*` handlers that submit and +/// poll operations through it. +#[derive(Clone)] +pub struct TaskDemo { + tool_router: ToolRouter, + processor: Arc>, +} + +impl Default for TaskDemo { + fn default() -> Self { + Self::new() + } +} + +#[tool_router] +impl TaskDemo { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + processor: Arc::new(Mutex::new(OperationProcessor::new())), + } + } + + /// Long-running tool. The `execution(task_support = "required")` attribute + /// tells clients they MUST call this tool as a task; the server returns + /// `-32601` if they don't. + #[tool( + description = "Sum two numbers after a 2-second delay", + execution(task_support = "required") + )] + async fn slow_sum( + &self, + Parameters(SumArgs { a, b }): Parameters, + ) -> Result { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + Ok(CallToolResult::success(vec![Content::text( + (a + b).to_string(), + )])) + } + + /// Synchronous tool with the default `task_support = "forbidden"`. + #[tool(description = "Echo a message back immediately")] + async fn quick_echo( + &self, + Parameters(EchoArgs { message }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![Content::text(message)])) + } +} + +/// `#[task_handler]` reads `self.processor` (configurable via the macro's +/// `processor = ...` argument) and synthesizes `enqueue_task`, `list_tasks`, +/// `get_task_info`, `get_task_result`, and `cancel_task` for us. +#[tool_handler] +#[task_handler] +impl ServerHandler for TaskDemo {} diff --git a/examples/servers/src/task_stdio.rs b/examples/servers/src/task_stdio.rs new file mode 100644 index 000000000..82e293ed5 --- /dev/null +++ b/examples/servers/src/task_stdio.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use common::task_demo::TaskDemo; +use rmcp::{ServiceExt, transport::stdio}; +use tracing_subscriber::{self, EnvFilter}; +mod common; + +/// Stdio server demonstrating task-based tool invocation. +/// +/// Run a matching client with: +/// cargo run -p mcp-client-examples --example clients_task_stdio +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + + tracing::info!("Starting task-demo MCP server"); + + let service = TaskDemo::new().serve(stdio()).await.inspect_err(|e| { + tracing::error!("serving error: {e:?}"); + })?; + + service.waiting().await?; + Ok(()) +} From d695046ffaf8e6dccf5f814ec1f0a0ba18b92a8c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 13 May 2026 09:22:58 -0400 Subject: [PATCH 159/333] fix: enable task support on counter long_task example (#838) * fix: enable task support on counter long_task example * ci: include example targets when testing example crates --- .github/workflows/ci.yml | 2 +- examples/servers/src/common/counter.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25c1c3137..5824bd393 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,7 +259,7 @@ jobs: if [ -f "$dir/Cargo.toml" ]; then if [[ "$dir" != *"wasi"* ]]; then echo "Testing $dir" - cargo test --manifest-path "$dir/Cargo.toml" --all-features + cargo test --manifest-path "$dir/Cargo.toml" --all-features --all-targets fi fi done diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index 91b4a7bc1..ccd17bcd8 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -103,7 +103,10 @@ impl Counter { )])) } - #[tool(description = "Long running task example")] + #[tool( + description = "Long running task example", + execution(task_support = "optional") + )] async fn long_task(&self) -> Result { tokio::time::sleep(std::time::Duration::from_secs(10)).await; Ok(CallToolResult::success(vec![Content::text( @@ -360,7 +363,7 @@ mod tests { "source".into(), serde_json::Value::String("integration-test".into()), ); - let params = CallToolRequestParams::new("long_task").with_task(Some(task_meta)); + let params = CallToolRequestParams::new("long_task").with_task(task_meta); let response = client_service .send_request(ClientRequest::CallToolRequest(Request::new(params.clone()))) .await?; From 3529c3675ff64db805bd947ca6ece6090809e43d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 09:36:22 -0400 Subject: [PATCH 160/333] chore: release v1.6.1 (#831) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4ae0713fb..06e831825 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.6.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.6.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.7.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.7.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.6.0" +version = "1.7.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 7145dda15..13d809d5c 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.6.0...rmcp-macros-v1.7.0) - 2026-05-13 + +### Added + +- add task-based stdio examples ([#839](https://github.com/modelcontextprotocol/rust-sdk/pull/839)) + ## [1.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.5.0...rmcp-macros-v1.6.0) - 2026-05-01 ### Fixed diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index c98f78b04..841ce6c66 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.6.0...rmcp-v1.7.0) - 2026-05-13 + +### Added + +- add task-based stdio examples ([#839](https://github.com/modelcontextprotocol/rust-sdk/pull/839)) + +### Fixed + +- *(rmcp)* flatten Resource variant of PromptMessageContent ([#843](https://github.com/modelcontextprotocol/rust-sdk/pull/843)) +- reply -32700 on stdio parse errors instead of closing ([#833](https://github.com/modelcontextprotocol/rust-sdk/pull/833)) + +### Other + +- *(rmcp)* remove dependency on chrono default features ([#829](https://github.com/modelcontextprotocol/rust-sdk/pull/829)) +- Fix/issue 817 idle timeout log level ([#824](https://github.com/modelcontextprotocol/rust-sdk/pull/824)) + ## [1.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.5.0...rmcp-v1.6.0) - 2026-05-01 ### Added From cc66e3091e1584f48ee1e0058a2a1201a1d35c81 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 14 May 2026 14:52:11 -0400 Subject: [PATCH 161/333] fix: accept 200 with empty body in response to notifications in addition to 202 (#849) --- .../common/reqwest/streamable_http_client.rs | 14 ++++++ .../rmcp/src/transport/common/unix_socket.rs | 17 +++++++ ..._streamable_http_empty_2xx_notification.rs | 48 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 crates/rmcp/tests/test_streamable_http_empty_2xx_notification.rs diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 32de491db..e6d4943db 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -178,11 +178,25 @@ impl StreamableHttpClient for reqwest::Client { .headers() .get(reqwest::header::CONTENT_TYPE) .map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string()); + let content_length = response.content_length(); let session_id = response .headers() .get(HEADER_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + // Spec requires 202 Accepted for these, but some servers return an empty 200. + // Treat empty success responses as equivalent to Accepted. + if status.is_success() + && content_length == Some(0) + && matches!( + message, + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) + ) + { + return Ok(StreamableHttpPostResponse::Accepted); + } // Non-success responses may carry valid JSON-RPC error payloads that // should be surfaced as McpError rather than lost in TransportSend. if !status.is_success() { diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs index 9170c1296..8ea30f57f 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -257,12 +257,29 @@ impl StreamableHttpClient for UnixSocketHttpClient { } let content_type = response.headers().get(http::header::CONTENT_TYPE).cloned(); + let content_length = response + .headers() + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); let session_id = response .headers() .get(HEADER_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + if status.is_success() + && content_length == Some(0) + && matches!( + message, + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) + ) + { + return Ok(StreamableHttpPostResponse::Accepted); + } + match content_type { Some(ref ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { let sse_stream = SseStream::new(response.into_body()).boxed(); diff --git a/crates/rmcp/tests/test_streamable_http_empty_2xx_notification.rs b/crates/rmcp/tests/test_streamable_http_empty_2xx_notification.rs new file mode 100644 index 000000000..c8a153345 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_empty_2xx_notification.rs @@ -0,0 +1,48 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + model::{ClientJsonRpcMessage, ClientNotification, InitializedNotification}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpPostResponse}, +}; + +async fn spawn_empty_ok_server() -> String { + use axum::{Router, http::StatusCode, routing::post}; + + let router = Router::new().route("/mcp", post(|| async { StatusCode::OK })); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + format!("http://{addr}/mcp") +} + +#[tokio::test] +async fn empty_success_response_to_notification_is_accepted() { + let url = spawn_empty_ok_server().await; + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ClientJsonRpcMessage::notification(ClientNotification::InitializedNotification( + InitializedNotification::default(), + )), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Ok(StreamableHttpPostResponse::Accepted) => {} + other => panic!("expected Accepted, got: {other:?}"), + } +} From d328751dc9cb2ddfe8ddb007c5a9f9d0a3ca919d Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 18 May 2026 16:54:08 -0400 Subject: [PATCH 162/333] fix: align protocol version negotiation (#855) * fix: align protocol version negotiation * ci: relax semver-checks to allow minor changes --- .github/workflows/ci.yml | 2 + crates/rmcp/src/service/server.rs | 35 +++++--- .../rmcp/tests/test_server_initialization.rs | 83 ++++++++++++++++++- 3 files changed, 108 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5824bd393..ba1eddb61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,7 @@ jobs: cargo semver-checks \ --package rmcp \ --baseline-rev ${{ github.event.pull_request.base.sha }} \ + --release-type minor \ --only-explicit-features \ --features default @@ -97,6 +98,7 @@ jobs: cargo semver-checks \ --package rmcp \ --baseline-rev ${{ github.event.pull_request.base.sha }} \ + --release-type minor \ --only-explicit-features \ --features "$FEATURES" diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 530e508e1..c185696e6 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -69,6 +69,10 @@ pub enum ServerInitializeError { #[error("initialize failed: {0}")] InitializeFailed(ErrorData), + #[deprecated( + since = "1.8.0", + note = "Negotiation now falls back to the server-configured version. This variant is never constructed and will be removed in a future major release." + )] #[error("unsupported protocol version: {0}")] UnsupportedProtocolVersion(ProtocolVersion), @@ -155,6 +159,23 @@ where } } +/// Echoes the client-requested version if known; otherwise returns `server_fallback`. +fn negotiate_protocol_version( + client_requested: &ProtocolVersion, + server_fallback: ProtocolVersion, +) -> ProtocolVersion { + if ProtocolVersion::KNOWN_VERSIONS.contains(client_requested) { + client_requested.clone() + } else { + tracing::warn!( + client_requested = %client_requested, + server_fallback = %server_fallback, + "client requested unsupported protocol version; falling back to server default" + ); + server_fallback + } +} + async fn serve_server_with_ct_inner( service: S, transport: T, @@ -227,16 +248,10 @@ where return Err(ServerInitializeError::InitializeFailed(e)); } }; - let peer_protocol_version = peer_info.params.protocol_version.clone(); - let protocol_version = match peer_protocol_version - .partial_cmp(&init_response.protocol_version) - .ok_or(ServerInitializeError::UnsupportedProtocolVersion( - peer_protocol_version, - ))? { - std::cmp::Ordering::Less => peer_info.params.protocol_version.clone(), - _ => init_response.protocol_version, - }; - init_response.protocol_version = protocol_version; + init_response.protocol_version = negotiate_protocol_version( + &peer_info.params.protocol_version, + init_response.protocol_version, + ); transport .send(ServerJsonRpcMessage::response( ServerResult::InitializeResult(init_response), diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index 8cf5c2c41..e2e048960 100644 --- a/crates/rmcp/tests/test_server_initialization.rs +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -4,8 +4,11 @@ mod common; use common::handlers::TestServer; use rmcp::{ - ServiceExt, - model::{ClientJsonRpcMessage, ServerJsonRpcMessage, ServerResult}, + ServerHandler, ServiceExt, + model::{ + ClientJsonRpcMessage, ProtocolVersion, ServerCapabilities, ServerInfo, + ServerJsonRpcMessage, ServerResult, + }, transport::{IntoTransport, Transport}, }; @@ -220,6 +223,82 @@ async fn server_init_buffers_request_before_initialized() { result.unwrap().cancel().await.unwrap(); } +fn init_request_with_version(v: &str) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {{ + "protocolVersion": "{v}", + "capabilities": {{}}, + "clientInfo": {{ "name": "test-client", "version": "0.0.1" }} + }} + }}"# + )) +} + +async fn negotiate_version(handler: H, client_version: &str) -> ProtocolVersion +where + H: ServerHandler + 'static, +{ + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { handler.serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + client + .send(init_request_with_version(client_version)) + .await + .unwrap(); + let response = client.receive().await.unwrap(); + let ServerJsonRpcMessage::Response(r) = response else { + panic!("expected initialize response, got {response:?}"); + }; + let ServerResult::InitializeResult(init) = r.result else { + panic!("expected InitializeResult"); + }; + init.protocol_version +} + +#[tokio::test] +async fn server_echoes_client_protocol_version_when_known_old() { + let negotiated = negotiate_version(TestServer::new(), "2024-11-05").await; + assert_eq!(negotiated, ProtocolVersion::V_2024_11_05); +} + +#[tokio::test] +async fn server_echoes_client_protocol_version_when_latest() { + let negotiated = negotiate_version(TestServer::new(), "2025-11-25").await; + assert_eq!(negotiated, ProtocolVersion::LATEST); +} + +#[tokio::test] +async fn server_falls_back_when_client_protocol_version_unknown() { + let negotiated = negotiate_version(TestServer::new(), "2099-99-99").await; + assert_eq!(negotiated, ProtocolVersion::LATEST); +} + +struct PinnedServer; + +impl ServerHandler for PinnedServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) + .with_protocol_version(ProtocolVersion::V_2025_06_18) + } +} + +#[tokio::test] +async fn server_pinned_version_does_not_override_known_client_request() { + let negotiated = negotiate_version(PinnedServer, "2025-11-25").await; + assert_eq!(negotiated, ProtocolVersion::LATEST); +} + +#[tokio::test] +async fn server_pinned_version_used_as_fallback_for_unknown_client_request() { + let negotiated = negotiate_version(PinnedServer, "2099-99-99").await; + assert_eq!(negotiated, ProtocolVersion::V_2025_06_18); +} + // Server buffers multiple requests before initialized and processes them in order. #[tokio::test] async fn server_init_buffers_multiple_requests_before_initialized() { From c330fede90e4729c234f8e87fdbc5ea27a1dd10c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 18 May 2026 20:21:22 -0400 Subject: [PATCH 163/333] fix: reject init header/body version mismatch (#853) --- crates/rmcp/Cargo.toml | 5 + .../transport/streamable_http_server/tower.rs | 91 +++++++++-- .../test_streamable_http_protocol_version.rs | 149 ++++++++++++++++++ 3 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_protocol_version.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 9e4d82c58..8a5bd63a3 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -272,6 +272,11 @@ name = "test_streamable_http_json_response" required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] path = "tests/test_streamable_http_json_response.rs" +[[test]] +name = "test_streamable_http_protocol_version" +required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] +path = "tests/test_streamable_http_protocol_version.rs" + [[test]] name = "test_streamable_http_4xx_error_body" required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"] diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 5993c75b1..cd2f5f1e5 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -1,4 +1,6 @@ -use std::{collections::HashMap, convert::Infallible, fmt::Display, sync::Arc, time::Duration}; +use std::{ + borrow::Cow, collections::HashMap, convert::Infallible, fmt::Display, sync::Arc, time::Duration, +}; use bytes::Bytes; use futures::{StreamExt, future::BoxFuture}; @@ -14,8 +16,8 @@ use super::session::{ use crate::{ RoleServer, model::{ - ClientJsonRpcMessage, ClientNotification, ClientRequest, GetExtensions, InitializeRequest, - InitializedNotification, ProtocolVersion, + ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, + InitializeRequest, InitializedNotification, JsonRpcError, ProtocolVersion, RequestId, }, serve_server, service::serve_directly, @@ -209,6 +211,54 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box Ok(()) } +fn invalid_request_jsonrpc_response( + id: Option, + message: impl Into>, +) -> BoxResponse { + let err = JsonRpcError::new(id, ErrorData::invalid_request(message, None)); + let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response") +} + +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +/// Absent header is allowed; the first initialize round-trip may legitimately omit it. +fn validate_header_matches_init_body( + headers: &http::HeaderMap, + body_version: &str, + request_id: Option, +) -> Result<(), BoxResponse> { + let Some(header_value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { + return Ok(()); + }; + let header_str = header_value.to_str().map_err(|_| { + invalid_request_jsonrpc_response( + request_id.clone(), + "Invalid Request: MCP-Protocol-Version header is not valid UTF-8", + ) + })?; + if header_str != body_version { + tracing::warn!( + header = header_str, + body = body_version, + "rejecting initialize: MCP-Protocol-Version header does not match params.protocolVersion" + ); + return Err(invalid_request_jsonrpc_response( + request_id, + format!( + "Invalid Request: MCP-Protocol-Version header ({header_str}) does not match initialize params.protocolVersion ({body_version})" + ), + )); + } + Ok(()) +} + fn forbidden_response(message: impl Into) -> BoxResponse { Response::builder() .status(http::StatusCode::FORBIDDEN) @@ -1095,9 +1145,15 @@ where None }; if let ClientJsonRpcMessage::Request(req) = &mut message { - if !matches!(req.request, ClientRequest::InitializeRequest(_)) { + let ClientRequest::InitializeRequest(init_req) = &req.request else { return Err(unexpected_message_response("initialize request")); - } + }; + // Reject mismatched MCP-Protocol-Version header before binding the session to anything. + validate_header_matches_init_body( + &part.headers, + init_req.params.protocol_version.as_str(), + Some(req.id.clone()), + )?; // inject request part to extensions req.request.extensions_mut().insert(part); } else { @@ -1163,13 +1219,24 @@ where Ok(response) } } else { - // Stateless mode: validate MCP-Protocol-Version on non-init requests - let is_init = matches!( - &message, - ClientJsonRpcMessage::Request(req) if matches!(req.request, ClientRequest::InitializeRequest(_)) - ); - if !is_init { - validate_protocol_version_header(&part.headers)?; + // Stateless mode: + // - on initialize: the header (if present) must match `params.protocolVersion` + // - on every other request: the header must name a known version. + match &message { + ClientJsonRpcMessage::Request(req) => { + if let ClientRequest::InitializeRequest(init_req) = &req.request { + validate_header_matches_init_body( + &part.headers, + init_req.params.protocol_version.as_str(), + Some(req.id.clone()), + )?; + } else { + validate_protocol_version_header(&part.headers)?; + } + } + _ => { + validate_protocol_version_header(&part.headers)?; + } } let service = self .get_service() diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs new file mode 100644 index 000000000..3500266b9 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -0,0 +1,149 @@ +#![cfg(not(feature = "local"))] +//! Regression tests for the `MCP-Protocol-Version` header / initialize body consistency check. +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, +}; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +fn init_body(body_version: &str) -> String { + format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"{body_version}","capabilities":{{}},"clientInfo":{{"name":"test","version":"1.0"}}}}}}"# + ) +} + +async fn spawn_server( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + let base_url = format!("http://{addr}/mcp"); + (client, base_url, ct) +} + +fn stateless_json_config() -> StreamableHttpServerConfig { + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()) +} + +fn stateful_config() -> StreamableHttpServerConfig { + StreamableHttpServerConfig::default() + .with_stateful_mode(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()) +} + +async fn post_init( + client: &reqwest::Client, + url: &str, + header: Option<&str>, + body_version: &str, +) -> reqwest::Response { + let mut req = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(init_body(body_version)); + if let Some(h) = header { + req = req.header("MCP-Protocol-Version", h); + } + req.send().await.expect("send initialize request") +} + +#[tokio::test] +async fn stateless_init_rejects_when_header_older_than_body() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await; + assert_eq!(response.status(), 400); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32600); + assert!( + body["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("MCP-Protocol-Version"), + "expected error message to mention the header, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_init_rejects_when_header_newer_than_body() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_init(&client, &url, Some("2025-11-25"), "2025-03-26").await; + assert_eq!(response.status(), 400); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32600); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_init_accepts_when_header_matches_body() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await; + assert_eq!(response.status(), 200); + + let body: serde_json::Value = response.json().await?; + assert!( + body["result"].is_object(), + "expected an InitializeResult, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateless_init_accepts_when_header_absent() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_init(&client, &url, None, "2025-11-25").await; + assert_eq!(response.status(), 200); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateful_config()).await; + + let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await; + assert_eq!(response.status(), 400); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32600); + + ct.cancel(); + Ok(()) +} From 53e4410d9962af92e86269621de6d682d61753e2 Mon Sep 17 00:00:00 2001 From: Federico Poli Date: Thu, 28 May 2026 17:28:57 +0200 Subject: [PATCH 164/333] fix: remove unnecessary fields from tools' inputSchema (#856) --- README.md | 2 + crates/rmcp-macros/src/tool.rs | 2 +- crates/rmcp/src/handler/server/common.rs | 30 ++++++++++++++ crates/rmcp/src/handler/server/router/tool.rs | 7 ++-- .../handler/server/router/tool/tool_traits.rs | 6 +-- crates/rmcp/src/handler/server/tool.rs | 2 +- crates/rmcp/src/model/tool.rs | 2 +- crates/rmcp/tests/test_complex_schema.rs | 1 - crates/rmcp/tests/test_list_tools_result.rs | 40 +++++++++++++++++++ .../list_tools_result.json | 32 +++++++++++++++ 10 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 crates/rmcp/tests/test_list_tools_result.rs create mode 100644 crates/rmcp/tests/test_list_tools_result/list_tools_result.json diff --git a/README.md b/README.md index d3edc3383..d2e8781b2 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ async fn main() -> anyhow::Result<()> { } ``` +The generated tool `inputSchema` is derived from the fields of `T`. The type name and documentation on `T` are ignored; only field names, field types, and field documentation are used. + When you need custom server metadata or multiple capabilities (tools + prompts), use explicit `#[tool_handler]`: ```rust,ignore diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index 6fe1765a8..b4b6c0b99 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -231,7 +231,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { if let Some(params_ty) = params_ty { // if found, use the Parameters schema syn::parse2::(quote! { - rmcp::handler::server::common::schema_for_type::<#params_ty>() + rmcp::handler::server::common::schema_for_input::<#params_ty>() })? } else { // if not found, use a default empty JSON schema object diff --git a/crates/rmcp/src/handler/server/common.rs b/crates/rmcp/src/handler/server/common.rs index 74c49d887..153e33c62 100644 --- a/crates/rmcp/src/handler/server/common.rs +++ b/crates/rmcp/src/handler/server/common.rs @@ -48,6 +48,36 @@ pub fn schema_for_type() -> Arc { }) } +/// Generate a JSON schema for inputSchema (does not need "title" or "description" fields for the top-level object) +pub fn schema_for_input() -> Arc { + thread_local! { + static CACHE_FOR_INPUT: std::sync::RwLock>> = Default::default(); + }; + CACHE_FOR_INPUT.with(|cache| { + if let Some(schema) = cache + .read() + .expect("input schema cache lock poisoned") + .get(&TypeId::of::()) + { + schema.clone() + } else { + let mut schema = schema_for_type::().as_ref().clone(); + + // Remove unnecessary top-level fields + schema.remove("title"); + schema.remove("description"); + + let schema = Arc::new(schema); + cache + .write() + .expect("input schema cache lock poisoned") + .insert(TypeId::of::(), schema.clone()); + + schema + } + }) +} + // TODO: should be updated according to the new specifications /// Schema used when input is empty. pub fn schema_for_empty_input() -> Arc { diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 07cdfaf03..8e2913275 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -133,7 +133,8 @@ pub use tool_traits::{AsyncTool, SyncTool, ToolBase}; use crate::{ handler::server::{ - tool::{CallToolHandler, DynCallToolHandler, ToolCallContext, schema_for_type}, + common::schema_for_input, + tool::{CallToolHandler, DynCallToolHandler, ToolCallContext}, tool_name_validation::validate_and_warn_tool_name, }, model::{CallToolResult, Tool, ToolAnnotations}, @@ -249,7 +250,7 @@ where attr: Tool::new( name.into(), "", - schema_for_type::(), + schema_for_input::(), ), call: self, _marker: std::marker::PhantomData, @@ -286,7 +287,7 @@ where self } pub fn parameters(mut self) -> Self { - self.attr.input_schema = schema_for_type::(); + self.attr.input_schema = schema_for_input::(); self } pub fn parameters_value(mut self, schema: serde_json::Value) -> Self { diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs index e4167a08b..57977db34 100644 --- a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize}; use crate::{ ErrorData, handler::server::{ - common::schema_for_empty_input, - tool::{schema_for_output, schema_for_type}, + common::{schema_for_empty_input, schema_for_input}, + tool::schema_for_output, wrapper::{Json, Parameters}, }, model::{Icon, JsonObject, Meta, ToolAnnotations, ToolExecution}, @@ -49,7 +49,7 @@ pub trait ToolBase { /// If the tool does not have any parameters, you should override this methods to return [`None`], /// and when invoked, the parameter will get default values. fn input_schema() -> Option> { - Some(schema_for_type::>()) + Some(schema_for_input::>()) } /// Json schema for tool output. diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index 2b9fe62af..d5b75a8f2 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -10,7 +10,7 @@ use serde::de::DeserializeOwned; use super::common::{AsRequestContext, FromContextPart}; pub use super::{ - common::{Extension, RequestId, schema_for_output, schema_for_type}, + common::{Extension, RequestId, schema_for_input, schema_for_output, schema_for_type}, router::tool::{ToolRoute, ToolRouter}, }; use crate::{ diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index 66d29bc10..11bba529e 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -330,7 +330,7 @@ impl Tool { /// Set the input schema using a type that implements JsonSchema #[cfg(feature = "server")] pub fn with_input_schema(mut self) -> Self { - self.input_schema = crate::handler::server::tool::schema_for_type::(); + self.input_schema = crate::handler::server::tool::schema_for_input::(); self } diff --git a/crates/rmcp/tests/test_complex_schema.rs b/crates/rmcp/tests/test_complex_schema.rs index 74b9be7c9..0e3dc4fed 100644 --- a/crates/rmcp/tests/test_complex_schema.rs +++ b/crates/rmcp/tests/test_complex_schema.rs @@ -91,7 +91,6 @@ fn expected_schema() -> serde_json::Value { "required": [ "messages" ], - "title": "ChatRequest", "type": "object" }) } diff --git a/crates/rmcp/tests/test_list_tools_result.rs b/crates/rmcp/tests/test_list_tools_result.rs new file mode 100644 index 000000000..1736b0ee8 --- /dev/null +++ b/crates/rmcp/tests/test_list_tools_result.rs @@ -0,0 +1,40 @@ +#![cfg(all(feature = "server", feature = "macros", not(feature = "local")))] + +use rmcp::{ + handler::server::wrapper::Parameters, + model::{ListToolsResult, NumberOrString, ServerJsonRpcMessage, ServerResult}, +}; + +/// Parameters for adding two numbers. +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddRequest { + /// The left-hand number. + a: f64, + /// The right-hand number. + b: f64, +} + +/// Add two numbers. +#[rmcp::tool] +fn add(Parameters(AddRequest { a, b }): Parameters) -> String { + (a + b).to_string() +} + +#[test] +fn list_tools_result_matches_expected_json() { + let expected_json = std::fs::read("tests/test_list_tools_result/list_tools_result.json") + .expect("missing expected list tools result JSON fixture"); + let expected: serde_json::Value = + serde_json::from_slice(&expected_json).expect("invalid expected JSON fixture"); + + assert_eq!(add(Parameters(AddRequest { a: 1.0, b: 2.0 })), "3"); + + let result = ListToolsResult::with_all_items(vec![add_tool_attr()]); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(result), + NumberOrString::Number(2), + ); + + let actual = serde_json::to_value(response).expect("failed to serialize list tools response"); + assert_eq!(actual, expected); +} diff --git a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json new file mode 100644 index 000000000..1ef882306 --- /dev/null +++ b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json @@ -0,0 +1,32 @@ +{ + "result": { + "tools": [ + { + "name": "add", + "description": "Add two numbers.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "a": { + "description": "The left-hand number.", + "format": "double", + "type": "number" + }, + "b": { + "description": "The right-hand number.", + "format": "double", + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] + }, + "jsonrpc": "2.0", + "id": 2 +} From 6a7f10af51c1a20484a214fcf3b961a00e4bb13f Mon Sep 17 00:00:00 2001 From: savanne-kham <101482831+savanne-kham@users.noreply.github.com> Date: Thu, 28 May 2026 17:32:35 +0200 Subject: [PATCH 165/333] fix(examples): accept stringified prompt argument values (#859) --- examples/servers/src/common/counter.rs | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index ccd17bcd8..d78acd59f 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -44,9 +44,29 @@ pub struct ExamplePromptArgs { pub message: String, } +/// MCP spec types prompt arguments as `Record`, so +/// spec-compliant clients stringify all values. Accept both wire forms. +fn deserialize_i32_from_string_or_int<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrInt { + Int(i32), + Str(String), + } + match StringOrInt::deserialize(deserializer)? { + StringOrInt::Int(n) => Ok(n), + StringOrInt::Str(s) => s.parse::().map_err(serde::de::Error::custom), + } +} + #[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct CounterAnalysisArgs { /// The target value you're trying to reach + #[serde(deserialize_with = "deserialize_i32_from_string_or_int")] pub goal: i32, /// Preferred strategy: 'fast' or 'careful' #[serde(skip_serializing_if = "Option::is_none")] @@ -334,6 +354,34 @@ mod tests { assert_eq!(args[1].required, Some(false)); } + #[test] + fn test_counter_analysis_args_accepts_integer_goal() { + let json = serde_json::json!({ "goal": 20 }); + let args: CounterAnalysisArgs = serde_json::from_value(json).unwrap(); + assert_eq!(args.goal, 20); + } + + #[test] + fn test_counter_analysis_args_accepts_string_goal() { + let json = serde_json::json!({ "goal": "20" }); + let args: CounterAnalysisArgs = serde_json::from_value(json).unwrap(); + assert_eq!(args.goal, 20); + } + + #[test] + fn test_counter_analysis_args_accepts_negative_string_goal() { + let json = serde_json::json!({ "goal": "-7" }); + let args: CounterAnalysisArgs = serde_json::from_value(json).unwrap(); + assert_eq!(args.goal, -7); + } + + #[test] + fn test_counter_analysis_args_rejects_non_numeric_string_goal() { + let json = serde_json::json!({ "goal": "not a number" }); + let result: Result = serde_json::from_value(json); + assert!(result.is_err()); + } + #[tokio::test] async fn test_prompt_router_has_routes() { let router = Counter::prompt_router(); From 8f558d83be9268c8922c91748033ff25891d86bf Mon Sep 17 00:00:00 2001 From: Datron Date: Fri, 29 May 2026 21:01:44 +0530 Subject: [PATCH 166/333] docs: added jilebi-mcp to the list of built with rmcp (#861) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d2e8781b2..3c75d4d87 100644 --- a/README.md +++ b/README.md @@ -1016,7 +1016,7 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. - [rudof-mcp](https://github.com/rudof-project/rudof/tree/master/rudof_mcp) - RDF validation and data processing MCP server with ShEx/SHACL validation, SPARQL queries, and format conversion. Supports stdio and streamable HTTP transports with full MCP capabilities (tools, prompts, resources, logging, completions, tasks) - [McpMux](https://github.com/mcpmux/mcp-mux) - Desktop app to configure MCP servers once at McpMux, connect every AI client (Cursor, Claude Desktop, VS Code, Windsurf) through a single encrypted local gateway with Spaces for project organization, FeatureSets to switch toolsets per client, and a built-in server registry - [systemprompt-template](https://github.com/systempromptio/systemprompt-template) - Single-binary Rust runtime providing MCP governance — authentication, authorisation, rate-limiting, audit trails, and cost tracking for AI agents. Self-hosted, air-gap capable, 3,300+ req/s with sub-5ms governance overhead - +- [jilebi-mcp](https://github.com/datron/jilebi) - an extensible MCP server through plugins in Javascript with a secure permissions model ## Development From 254f04a764c0f9c26e7514f1de31bde384c4251b Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:01:33 -0400 Subject: [PATCH 167/333] fix: strip and validate tool outputSchema and inputSchema (#860) * fix: remove unnecessary fields from tools' outputSchema * fix: validate input schema root type per MCP spec --- README.md | 2 +- crates/rmcp-macros/src/tool.rs | 7 + crates/rmcp/src/handler/server/common.rs | 142 +++++++++++------- crates/rmcp/src/handler/server/router/tool.rs | 11 +- .../handler/server/router/tool/tool_traits.rs | 9 +- crates/rmcp/src/model/tool.rs | 3 +- crates/rmcp/tests/test_list_tools_result.rs | 14 +- .../list_tools_result.json | 14 ++ crates/rmcp/tests/test_structured_output.rs | 21 ++- 9 files changed, 158 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 3c75d4d87..05ca08790 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ async fn main() -> anyhow::Result<()> { } ``` -The generated tool `inputSchema` is derived from the fields of `T`. The type name and documentation on `T` are ignored; only field names, field types, and field documentation are used. +The generated tool `inputSchema` and `outputSchema` are derived from the fields of `T`. The type name and documentation on `T` are ignored; only field names, field types, and field documentation are used. When you need custom server metadata or multiple capabilities (tools + prompts), use explicit `#[tool_handler]`: diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index b4b6c0b99..5e5044eb6 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -232,6 +232,13 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { // if found, use the Parameters schema syn::parse2::(quote! { rmcp::handler::server::common::schema_for_input::<#params_ty>() + .unwrap_or_else(|e| { + panic!( + "Invalid input schema for `{}`: {}", + std::any::type_name::<#params_ty>(), + e + ) + }) })? } else { // if not found, use a default empty JSON schema object diff --git a/crates/rmcp/src/handler/server/common.rs b/crates/rmcp/src/handler/server/common.rs index 153e33c62..aa996b5b6 100644 --- a/crates/rmcp/src/handler/server/common.rs +++ b/crates/rmcp/src/handler/server/common.rs @@ -1,6 +1,10 @@ //! Common utilities shared between tool and prompt handlers -use std::{any::TypeId, collections::HashMap, sync::Arc}; +use std::{ + any::TypeId, + collections::HashMap, + sync::{Arc, LazyLock}, +}; use schemars::JsonSchema; @@ -30,12 +34,10 @@ pub fn schema_for_type() -> Arc { let generator = settings.into_generator(); let schema = generator.into_root_schema_for::(); let object = serde_json::to_value(schema).expect("failed to serialize schema"); - let object = match object { - serde_json::Value::Object(object) => object, - _ => panic!( - "Schema serialization produced non-object value: expected JSON object but got {:?}", - object - ), + let serde_json::Value::Object(object) = object else { + panic!( + "Schema serialization produced non-object value: expected JSON object but got {object:?}" + ); }; let schema = Arc::new(object); cache @@ -48,51 +50,63 @@ pub fn schema_for_type() -> Arc { }) } -/// Generate a JSON schema for inputSchema (does not need "title" or "description" fields for the top-level object) -pub fn schema_for_input() -> Arc { +/// Validate that the schema root is `type: "object"` (per MCP spec) and strip top-level +/// `title`/`description` (the wrapper type name and doc, which are noise to the LLM). +fn validate_and_strip(raw: &Arc, purpose: &str) -> Result, String> { + match raw.get("type") { + Some(serde_json::Value::String(t)) if t == "object" => { + let mut object = raw.as_ref().clone(); + object.remove("title"); + object.remove("description"); + Ok(Arc::new(object)) + } + Some(serde_json::Value::String(t)) => Err(format!( + "MCP specification requires tool {purpose} to have root type 'object', but found '{t}'." + )), + None => Err(format!( + "Schema is missing 'type' field. MCP specification requires {purpose} to have root type 'object'." + )), + Some(other) => Err(format!( + "Schema 'type' field has unexpected format: {other:?}. Expected \"object\"." + )), + } +} + +/// Generate, validate, and strip a JSON schema for inputSchema (must have root type "object"; +/// top-level "title" and "description" are removed). +pub fn schema_for_input() -> Result, String> { thread_local! { - static CACHE_FOR_INPUT: std::sync::RwLock>> = Default::default(); + static CACHE_FOR_INPUT: std::sync::RwLock, String>>> = Default::default(); }; CACHE_FOR_INPUT.with(|cache| { - if let Some(schema) = cache + if let Some(result) = cache .read() .expect("input schema cache lock poisoned") .get(&TypeId::of::()) { - schema.clone() - } else { - let mut schema = schema_for_type::().as_ref().clone(); - - // Remove unnecessary top-level fields - schema.remove("title"); - schema.remove("description"); - - let schema = Arc::new(schema); - cache - .write() - .expect("input schema cache lock poisoned") - .insert(TypeId::of::(), schema.clone()); - - schema + return result.clone(); } + let result = validate_and_strip(&schema_for_type::(), "inputSchema"); + cache + .write() + .expect("input schema cache lock poisoned") + .insert(TypeId::of::(), result.clone()); + result }) } -// TODO: should be updated according to the new specifications /// Schema used when input is empty. pub fn schema_for_empty_input() -> Arc { - std::sync::Arc::new( - serde_json::json!({ - "type": "object", - "properties": {} - }) - .as_object() - .unwrap() - .clone(), - ) + static EMPTY: LazyLock> = LazyLock::new(|| { + let mut object = JsonObject::new(); + object.insert("type".into(), serde_json::json!("object")); + object.insert("properties".into(), serde_json::json!({})); + Arc::new(object) + }); + EMPTY.clone() } -/// Generate and validate a JSON schema for outputSchema (must have root type "object"). +/// Generate a JSON schema for outputSchema (must have root type "object"; top-level "title" and "description" are removed) pub fn schema_for_output() -> Result, String> { thread_local! { static CACHE_FOR_OUTPUT: std::sync::RwLock, String>>> = Default::default(); @@ -108,22 +122,8 @@ pub fn schema_for_output() -> Result(); - let result = match schema.get("type") { - Some(serde_json::Value::String(t)) if t == "object" => Ok(schema.clone()), - Some(serde_json::Value::String(t)) => Err(format!( - "MCP specification requires tool outputSchema to have root type 'object', but found '{}'.", - t - )), - None => Err( - "Schema is missing 'type' field. MCP specification requires outputSchema to have root type 'object'.".to_string() - ), - Some(other) => Err(format!( - "Schema 'type' field has unexpected format: {:?}. Expected \"object\".", - other - )), - }; + // Generate, validate, and strip unnecessary top-level fields + let result = validate_and_strip(&schema_for_type::(), "outputSchema"); // Cache the result (both success and error cases) cache @@ -316,4 +316,40 @@ mod tests { let result = schema_for_output::(); assert!(result.is_ok(),); } + + #[test] + fn test_schema_for_output_strips_top_level_title() { + let schema = schema_for_output::().unwrap(); + assert!(!schema.contains_key("title")); + } + + #[test] + fn test_schema_for_output_strips_top_level_description() { + let schema = schema_for_output::().unwrap(); + assert!(!schema.contains_key("description")); + } + + #[test] + fn test_schema_for_input_rejects_primitive() { + let result = schema_for_input::(); + assert!(result.is_err()); + } + + #[test] + fn test_schema_for_input_accepts_object() { + let result = schema_for_input::(); + assert!(result.is_ok()); + } + + #[test] + fn test_schema_for_input_strips_top_level_title() { + let schema = schema_for_input::().unwrap(); + assert!(!schema.contains_key("title")); + } + + #[test] + fn test_schema_for_input_strips_top_level_description() { + let schema = schema_for_input::().unwrap(); + assert!(!schema.contains_key("description")); + } } diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 8e2913275..c2bf299c8 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -250,7 +250,9 @@ where attr: Tool::new( name.into(), "", - schema_for_input::(), + schema_for_input::().unwrap_or_else(|e| { + panic!("Invalid input schema for JsonObject: {e}"); + }), ), call: self, _marker: std::marker::PhantomData, @@ -287,7 +289,12 @@ where self } pub fn parameters(mut self) -> Self { - self.attr.input_schema = schema_for_input::(); + self.attr.input_schema = schema_for_input::().unwrap_or_else(|e| { + panic!( + "Invalid input schema for `{}`: {e}", + std::any::type_name::() + ) + }); self } pub fn parameters_value(mut self, schema: serde_json::Value) -> Self { diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs index 57977db34..b0bf9e2dc 100644 --- a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -49,7 +49,14 @@ pub trait ToolBase { /// If the tool does not have any parameters, you should override this methods to return [`None`], /// and when invoked, the parameter will get default values. fn input_schema() -> Option> { - Some(schema_for_input::>()) + Some( + schema_for_input::>().unwrap_or_else(|e| { + panic!( + "Invalid input schema for ToolBase::Parameter type `{0}`: {e}", + std::any::type_name::(), + ); + }), + ) } /// Json schema for tool output. diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index 11bba529e..2ed89d6ad 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -330,7 +330,8 @@ impl Tool { /// Set the input schema using a type that implements JsonSchema #[cfg(feature = "server")] pub fn with_input_schema(mut self) -> Self { - self.input_schema = crate::handler::server::tool::schema_for_input::(); + self.input_schema = crate::handler::server::tool::schema_for_input::() + .unwrap_or_else(|e| panic!("Invalid input schema for tool '{}': {}", self.name, e)); self } diff --git a/crates/rmcp/tests/test_list_tools_result.rs b/crates/rmcp/tests/test_list_tools_result.rs index 1736b0ee8..4d8dc70aa 100644 --- a/crates/rmcp/tests/test_list_tools_result.rs +++ b/crates/rmcp/tests/test_list_tools_result.rs @@ -1,6 +1,7 @@ #![cfg(all(feature = "server", feature = "macros", not(feature = "local")))] use rmcp::{ + Json, handler::server::wrapper::Parameters, model::{ListToolsResult, NumberOrString, ServerJsonRpcMessage, ServerResult}, }; @@ -14,10 +15,17 @@ struct AddRequest { b: f64, } +/// Result of adding two numbers. +#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +struct AddResult { + /// The sum of the two numbers. + sum: f64, +} + /// Add two numbers. #[rmcp::tool] -fn add(Parameters(AddRequest { a, b }): Parameters) -> String { - (a + b).to_string() +fn add(Parameters(AddRequest { a, b }): Parameters) -> Json { + Json(AddResult { sum: a + b }) } #[test] @@ -27,7 +35,7 @@ fn list_tools_result_matches_expected_json() { let expected: serde_json::Value = serde_json::from_slice(&expected_json).expect("invalid expected JSON fixture"); - assert_eq!(add(Parameters(AddRequest { a: 1.0, b: 2.0 })), "3"); + assert_eq!(add(Parameters(AddRequest { a: 1.0, b: 2.0 })).0.sum, 3.0); let result = ListToolsResult::with_all_items(vec![add_tool_attr()]); let response = ServerJsonRpcMessage::response( diff --git a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json index 1ef882306..15325e8fa 100644 --- a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json +++ b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json @@ -23,6 +23,20 @@ "a", "b" ] + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sum": { + "description": "The sum of the two numbers.", + "format": "double", + "type": "number" + } + }, + "required": [ + "sum" + ] } } ] diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index 7bb62e650..adbdfec5e 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -28,6 +28,16 @@ pub struct UserInfo { pub age: u32, } +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct GreetingRequest { + pub name: String, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct GetUserRequest { + pub user_id: String, +} + #[tool_handler(router = self.tool_router)] impl ServerHandler for TestServer {} @@ -64,14 +74,17 @@ impl TestServer { /// Tool that returns regular string output #[tool(name = "get-greeting", description = "Get a greeting")] - pub async fn get_greeting(&self, name: Parameters) -> String { - format!("Hello, {}!", name.0) + pub async fn get_greeting(&self, params: Parameters) -> String { + format!("Hello, {}!", params.0.name) } /// Tool that returns structured user info #[tool(name = "get-user", description = "Get user info")] - pub async fn get_user(&self, user_id: Parameters) -> Result, String> { - if user_id.0 == "123" { + pub async fn get_user( + &self, + params: Parameters, + ) -> Result, String> { + if params.0.user_id == "123" { Ok(Json(UserInfo { name: "Alice".to_string(), age: 30, From 82b04a31f5d54d316d4b5d0d5f0b317152e3d378 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:43:39 +0100 Subject: [PATCH 168/333] feat: deprecate roots, sampling, and logging (SEP-2577) (#884) SEP-2577 deprecates the Roots, Sampling, and Logging features. The deprecation is advisory: the features stay fully functional and there is no wire-level change. Mark the corresponding Rust APIs as deprecated so downstream users get compiler warnings and migration guidance. - Forward attributes through the service `method!` macros and deprecate `Peer::create_message`, `Peer::list_roots`, `Peer::set_level`, and `Peer::notify_logging_message`. - Forward per-field attributes through the capability `builder!` macro and deprecate the generated `enable_roots`, `enable_sampling`, and `enable_logging` builders, plus the hand-written `enable_roots_list_changed`, `enable_sampling_tools`, and `enable_sampling_context`. - Document the deprecation on the capability types and fields, and in the README feature sections. - Allow `deprecated` at the crate's own call sites so the build stays warning-clean, and refresh the message schema snapshots. --- README.md | 6 ++ conformance/src/bin/server.rs | 1 + crates/rmcp/src/model/capabilities.rs | 65 +++++++++++++++---- crates/rmcp/src/service/client.rs | 26 ++++++-- crates/rmcp/src/service/server.rs | 41 +++++++++--- crates/rmcp/tests/common/handlers.rs | 2 + crates/rmcp/tests/test_logging.rs | 1 + .../client_json_rpc_message_schema.json | 9 ++- ...lient_json_rpc_message_schema_current.json | 9 ++- .../server_json_rpc_message_schema.json | 4 +- ...erver_json_rpc_message_schema_current.json | 4 +- crates/rmcp/tests/test_sampling.rs | 1 + examples/servers/src/sampling_stdio.rs | 1 + 13 files changed, 132 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 05ca08790..a94023c1f 100644 --- a/README.md +++ b/README.md @@ -471,6 +471,8 @@ context.peer.notify_prompt_list_changed().await?; ## Sampling +> **Deprecated (SEP-2577):** Sampling is deprecated and will be removed in a future release. It remains fully functional for now. See [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577). + Sampling flips the usual direction: the server asks the client to run an LLM completion. The server sends a `create_message` request, the client processes it through its LLM, and returns the result. **MCP Spec:** [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) @@ -544,6 +546,8 @@ impl ClientHandler for MyClient { ## Roots +> **Deprecated (SEP-2577):** Roots is deprecated and will be removed in a future release. It remains fully functional for now. See [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577). + Roots tell servers which directories or projects the client is working in. A root is a URI (typically `file://`) pointing to a workspace or repository. Servers can query roots to know where to look for files and how to scope their work. **MCP Spec:** [Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) @@ -612,6 +616,8 @@ client.notify_roots_list_changed().await?; ## Logging +> **Deprecated (SEP-2577):** Logging is deprecated and will be removed in a future release. It remains fully functional for now. See [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577). + Servers can send structured log messages to clients. The client sets a minimum severity level, and the server sends messages through the peer notification interface. **MCP Spec:** [Logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 5ca4b5922..28a9f1d91 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -1,3 +1,4 @@ +#![allow(deprecated)] use std::{collections::HashSet, sync::Arc}; use rmcp::{ diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 33aae6908..1d32f975a 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -60,6 +60,9 @@ pub struct ToolsCapability { pub list_changed: Option, } +/// Roots capability. Deprecated by SEP-2577; remains functional and will be +/// removed in a future release. +/// See . #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -97,6 +100,9 @@ pub struct TaskRequestsCapability { pub tools: Option, } +/// Sampling task capability. Deprecated by SEP-2577; remains functional and +/// will be removed in a future release. +/// See . #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -231,6 +237,10 @@ pub struct ElicitationCapability { } /// Sampling capability with optional sub-capabilities (SEP-1577). +/// +/// Deprecated by SEP-2577; remains functional and will be removed in a future +/// release. +/// See . #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -250,8 +260,6 @@ pub struct SamplingCapability { /// # use rmcp::model::ClientCapabilities; /// let cap = ClientCapabilities::builder() /// .enable_experimental() -/// .enable_roots() -/// .enable_roots_list_changed() /// .build(); /// ``` #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] @@ -266,9 +274,10 @@ pub struct ClientCapabilities { /// support with no settings. #[serde(skip_serializing_if = "Option::is_none")] pub extensions: Option, + /// Capability for filesystem roots (deprecated by SEP-2577). #[serde(skip_serializing_if = "Option::is_none")] pub roots: Option, - /// Capability for LLM sampling requests (SEP-1577) + /// Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577). #[serde(skip_serializing_if = "Option::is_none")] pub sampling: Option, /// Capability to handle elicitation requests from servers for interactive user input @@ -283,7 +292,6 @@ pub struct ClientCapabilities { /// ```rust /// # use rmcp::model::ServerCapabilities; /// let cap = ServerCapabilities::builder() -/// .enable_logging() /// .enable_experimental() /// .enable_prompts() /// .enable_resources() @@ -304,6 +312,7 @@ pub struct ServerCapabilities { /// support with no settings. #[serde(skip_serializing_if = "Option::is_none")] pub extensions: Option, + /// Capability for server log message notifications (deprecated by SEP-2577). #[serde(skip_serializing_if = "Option::is_none")] pub logging: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -320,7 +329,7 @@ pub struct ServerCapabilities { #[cfg(any(feature = "server", feature = "macros"))] macro_rules! builder { - ($Target: ident {$($f: ident: $T: ty),* $(,)?}) => { + ($Target: ident {$($(#[$fa:meta])* $f: ident: $T: ty),* $(,)?}) => { paste! { #[derive(Default, Clone, Copy, Debug)] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] @@ -352,20 +361,20 @@ macro_rules! builder { } } } - builder!($Target @toggle $($f: $T,) *); + builder!($Target @toggle $($(#[$fa])* $f: $T,)*); }; - ($Target: ident @toggle $f0: ident: $T0: ty, $($f: ident: $T: ty,)*) => { - builder!($Target @toggle [][$f0: $T0][$($f: $T,)*]); + ($Target: ident @toggle $(#[$fa0:meta])* $f0: ident: $T0: ty, $($(#[$fa:meta])* $f: ident: $T: ty,)*) => { + builder!($Target @toggle [][$(#[$fa0])* $f0: $T0][$($(#[$fa])* $f: $T,)*]); }; - ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$fn: ident: $TN: ty][$fn_1: ident: $Tn_1: ty, $($ft: ident: $Tt: ty,)*]) => { - builder!($Target @impl_toggle [$($ff: $Tf,)*][$fn: $TN][$fn_1: $Tn_1, $($ft:$Tt,)*]); - builder!($Target @toggle [$($ff: $Tf,)* $fn: $TN,][$fn_1: $Tn_1][$($ft:$Tt,)*]); + ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][$(#[$fn1a:meta])* $fn_1: ident: $Tn_1: ty, $($(#[$fta:meta])* $ft: ident: $Tt: ty,)*]) => { + builder!($Target @impl_toggle [$($ff: $Tf,)*][$(#[$fna])* $fn: $TN][$fn_1: $Tn_1, $($ft:$Tt,)*]); + builder!($Target @toggle [$($ff: $Tf,)* $fn: $TN,][$(#[$fn1a])* $fn_1: $Tn_1][$($(#[$fta])* $ft: $Tt,)*]); }; - ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$fn: ident: $TN: ty][]) => { - builder!($Target @impl_toggle [$($ff: $Tf,)*][$fn: $TN][]); + ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][]) => { + builder!($Target @impl_toggle [$($ff: $Tf,)*][$(#[$fna])* $fn: $TN][]); }; - ($Target: ident @impl_toggle [$($ff: ident: $Tf: ty,)*][$fn: ident: $TN: ty][$($ft: ident: $Tt: ty,)*]) => { + ($Target: ident @impl_toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][$($ft: ident: $Tt: ty,)*]) => { paste! { impl< $(const [<$ff:upper>]: bool,)* @@ -375,6 +384,7 @@ macro_rules! builder { false, $([<$ft:upper>],)* >> { + $(#[$fna])* pub fn [](self) -> [<$Target Builder>]<[<$Target BuilderState>]< $([<$ff:upper>],)* true, @@ -387,6 +397,7 @@ macro_rules! builder { state: PhantomData } } + $(#[$fna])* pub fn [](self, $fn: $TN) -> [<$Target Builder>]<[<$Target BuilderState>]< $([<$ff:upper>],)* true, @@ -431,6 +442,10 @@ builder! { ServerCapabilities { experimental: ExperimentalCapabilities, extensions: ExtensionCapabilities, + #[deprecated( + since = "1.8.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] logging: JsonObject, completions: JsonObject, prompts: PromptsCapability, @@ -509,7 +524,15 @@ builder! { ClientCapabilities{ experimental: ExperimentalCapabilities, extensions: ExtensionCapabilities, + #[deprecated( + since = "1.8.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] roots: RootsCapabilities, + #[deprecated( + since = "1.8.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] sampling: SamplingCapability, elicitation: ElicitationCapability, tasks: TasksCapability, @@ -520,6 +543,10 @@ builder! { impl ClientCapabilitiesBuilder> { + #[deprecated( + since = "1.8.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] pub fn enable_roots_list_changed(mut self) -> Self { if let Some(c) = self.roots.as_mut() { c.list_changed = Some(true); @@ -533,6 +560,10 @@ impl> { /// Enable tool calling in sampling requests + #[deprecated( + since = "1.8.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] pub fn enable_sampling_tools(mut self) -> Self { if let Some(c) = self.sampling.as_mut() { c.tools = Some(JsonObject::default()); @@ -541,6 +572,10 @@ impl Self { if let Some(c) = self.sampling.as_mut() { c.context = Some(JsonObject::default()); @@ -571,6 +606,7 @@ impl::default() .enable_logging() @@ -673,6 +709,7 @@ mod test { } #[test] + #[allow(deprecated)] fn test_client_extensions_capability() { // Test building ClientCapabilities with extensions (MCP Apps support) let mut extensions = ExtensionCapabilities::new(); diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 8031e66ae..7bb5d8238 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -270,7 +270,8 @@ where } macro_rules! method { - (peer_req $method:ident $Req:ident() => $Resp: ident ) => { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { + $(#[$meta])* pub async fn $method(&self) -> Result<$Resp, ServiceError> { let result = self .send_request(ClientRequest::$Req($Req { @@ -283,7 +284,8 @@ macro_rules! method { } } }; - (peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { + $(#[$meta])* pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> { let result = self .send_request(ClientRequest::$Req($Req { @@ -298,7 +300,8 @@ macro_rules! method { } } }; - (peer_req $method:ident $Req:ident($Param: ident)? => $Resp: ident ) => { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)? => $Resp: ident ) => { + $(#[$meta])* pub async fn $method(&self, params: Option<$Param>) -> Result<$Resp, ServiceError> { let result = self .send_request(ClientRequest::$Req($Req { @@ -313,7 +316,8 @@ macro_rules! method { } } }; - (peer_req $method:ident $Req:ident($Param: ident)) => { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => { + $(#[$meta])* pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { let result = self .send_request(ClientRequest::$Req($Req { @@ -329,7 +333,8 @@ macro_rules! method { } }; - (peer_not $method:ident $Not:ident($Param: ident)) => { + ($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => { + $(#[$meta])* pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { self.send_notification(ClientNotification::$Not($Not { method: Default::default(), @@ -340,7 +345,8 @@ macro_rules! method { Ok(()) } }; - (peer_not $method:ident $Not:ident) => { + ($(#[$meta:meta])* peer_not $method:ident $Not:ident) => { + $(#[$meta])* pub async fn $method(&self) -> Result<(), ServiceError> { self.send_notification(ClientNotification::$Not($Not { method: Default::default(), @@ -354,7 +360,13 @@ macro_rules! method { impl Peer { method!(peer_req complete CompleteRequest(CompleteRequestParams) => CompleteResult); - method!(peer_req set_level SetLevelRequest(SetLevelRequestParams)); + method!( + #[deprecated( + since = "1.8.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + peer_req set_level SetLevelRequest(SetLevelRequestParams) + ); method!(peer_req get_prompt GetPromptRequest(GetPromptRequestParams) => GetPromptResult); method!(peer_req list_prompts ListPromptsRequest(PaginatedRequestParams)? => ListPromptsResult); method!(peer_req list_resources ListResourcesRequest(PaginatedRequestParams)? => ListResourcesResult); diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index c185696e6..173fdb428 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -272,7 +272,8 @@ where } macro_rules! method { - (peer_req $method:ident $Req:ident() => $Resp: ident ) => { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { + $(#[$meta])* pub async fn $method(&self) -> Result<$Resp, ServiceError> { let result = self .send_request(ServerRequest::$Req($Req { @@ -286,7 +287,8 @@ macro_rules! method { } } }; - (peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { + $(#[$meta])* pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> { let result = self .send_request(ServerRequest::$Req($Req { @@ -301,7 +303,8 @@ macro_rules! method { } } }; - (peer_req $method:ident $Req:ident($Param: ident)) => { + ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => { + $(#[$meta])* pub fn $method( &self, params: $Param, @@ -321,7 +324,8 @@ macro_rules! method { } }; - (peer_not $method:ident $Not:ident($Param: ident)) => { + ($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => { + $(#[$meta])* pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { self.send_notification(ServerNotification::$Not($Not { method: Default::default(), @@ -332,7 +336,8 @@ macro_rules! method { Ok(()) } }; - (peer_not $method:ident $Not:ident) => { + ($(#[$meta:meta])* peer_not $method:ident $Not:ident) => { + $(#[$meta])* pub async fn $method(&self) -> Result<(), ServiceError> { self.send_notification(ServerNotification::$Not($Not { method: Default::default(), @@ -344,7 +349,8 @@ macro_rules! method { }; // Timeout-only variants (base method should be created separately with peer_req) - (peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => { + ($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => { + $(#[$meta])* pub async fn $method_with_timeout( &self, timeout: Option, @@ -369,7 +375,8 @@ macro_rules! method { } }; - (peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => { + ($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => { + $(#[$meta])* pub async fn $method_with_timeout( &self, params: $Param, @@ -412,6 +419,10 @@ impl Peer { } } + #[deprecated( + since = "1.8.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] pub async fn create_message( &self, params: CreateMessageRequestParams, @@ -441,7 +452,13 @@ impl Peer { _ => Err(ServiceError::UnexpectedResponse), } } - method!(peer_req list_roots ListRootsRequest() => ListRootsResult); + method!( + #[deprecated( + since = "1.8.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + peer_req list_roots ListRootsRequest() => ListRootsResult + ); #[cfg(feature = "elicitation")] method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult); #[cfg(feature = "elicitation")] @@ -451,7 +468,13 @@ impl Peer { method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); - method!(peer_not notify_logging_message LoggingMessageNotification(LoggingMessageNotificationParam)); + method!( + #[deprecated( + since = "1.8.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" + )] + peer_not notify_logging_message LoggingMessageNotification(LoggingMessageNotificationParam) + ); method!(peer_not notify_resource_updated ResourceUpdatedNotification(ResourceUpdatedNotificationParam)); method!(peer_not notify_resource_list_changed ResourceListChangedNotification); method!(peer_not notify_tool_list_changed ToolListChangedNotification); diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index 866cbdeff..dd2d16ebb 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -112,10 +112,12 @@ impl TestServer { } impl ServerHandler for TestServer { + #[allow(deprecated)] fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_logging().build()) } + #[allow(deprecated)] fn set_level( &self, request: SetLevelRequestParams, diff --git a/crates/rmcp/tests/test_logging.rs b/crates/rmcp/tests/test_logging.rs index c27cafbc5..467cf7134 100644 --- a/crates/rmcp/tests/test_logging.rs +++ b/crates/rmcp/tests/test_logging.rs @@ -1,5 +1,6 @@ // cargo test --features "server client" --package rmcp test_logging #![cfg(not(feature = "local"))] +#![allow(deprecated)] mod common; use std::sync::{Arc, Mutex}; diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index f8f94c6c3..952aff8e4 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -272,7 +272,7 @@ }, "ClientCapabilities": { "title": "Builder", - "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .enable_roots()\n .enable_roots_list_changed()\n .build();\n```", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", "type": "object", "properties": { "elicitation": { @@ -308,6 +308,7 @@ } }, "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", "anyOf": [ { "$ref": "#/definitions/RootsCapabilities" @@ -318,7 +319,7 @@ ] }, "sampling": { - "description": "Capability for LLM sampling requests (SEP-1577)", + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", "anyOf": [ { "$ref": "#/definitions/SamplingCapability" @@ -1811,6 +1812,7 @@ ] }, "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", "type": "object", "properties": { "listChanged": { @@ -1827,7 +1829,7 @@ "const": "notifications/roots/list_changed" }, "SamplingCapability": { - "description": "Sampling capability with optional sub-capabilities (SEP-1577).", + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", "type": "object", "properties": { "context": { @@ -1955,6 +1957,7 @@ ] }, "SamplingTaskCapability": { + "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", "type": "object", "properties": { "createMessage": { diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index f8f94c6c3..952aff8e4 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -272,7 +272,7 @@ }, "ClientCapabilities": { "title": "Builder", - "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .enable_roots()\n .enable_roots_list_changed()\n .build();\n```", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", "type": "object", "properties": { "elicitation": { @@ -308,6 +308,7 @@ } }, "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", "anyOf": [ { "$ref": "#/definitions/RootsCapabilities" @@ -318,7 +319,7 @@ ] }, "sampling": { - "description": "Capability for LLM sampling requests (SEP-1577)", + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", "anyOf": [ { "$ref": "#/definitions/SamplingCapability" @@ -1811,6 +1812,7 @@ ] }, "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", "type": "object", "properties": { "listChanged": { @@ -1827,7 +1829,7 @@ "const": "notifications/roots/list_changed" }, "SamplingCapability": { - "description": "Sampling capability with optional sub-capabilities (SEP-1577).", + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", "type": "object", "properties": { "context": { @@ -1955,6 +1957,7 @@ ] }, "SamplingTaskCapability": { + "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", "type": "object", "properties": { "createMessage": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 24eb04f69..c1c6d1b2c 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -2724,6 +2724,7 @@ ] }, "SamplingTaskCapability": { + "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", "type": "object", "properties": { "createMessage": { @@ -2737,7 +2738,7 @@ }, "ServerCapabilities": { "title": "Builder", - "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_logging()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", + "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", "type": "object", "properties": { "completions": { @@ -2769,6 +2770,7 @@ } }, "logging": { + "description": "Capability for server log message notifications (deprecated by SEP-2577).", "type": [ "object", "null" diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 24eb04f69..c1c6d1b2c 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -2724,6 +2724,7 @@ ] }, "SamplingTaskCapability": { + "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", "type": "object", "properties": { "createMessage": { @@ -2737,7 +2738,7 @@ }, "ServerCapabilities": { "title": "Builder", - "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_logging()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", + "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", "type": "object", "properties": { "completions": { @@ -2769,6 +2770,7 @@ } }, "logging": { + "description": "Capability for server log message notifications (deprecated by SEP-2577).", "type": [ "object", "null" diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 7bd6cd118..74e904ff5 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -1,4 +1,5 @@ #![cfg(not(feature = "local"))] +#![allow(deprecated)] mod common; use anyhow::Result; diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index bd244d871..9c1d21d6d 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -1,3 +1,4 @@ +#![allow(deprecated)] use std::sync::Arc; use anyhow::Result; From f1ef2ec86c22a18ce6352f16943769c14e7b1bf0 Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Thu, 4 Jun 2026 17:53:18 +0300 Subject: [PATCH 169/333] feat: specify OIDC application_type during dynamic client registration (SEP-837) (#883) * feat(auth): specify OIDC application_type during client registration SEP-837 [1] requires an MCP client to specify an application_type during OIDC Dynamic Client Registration. When it is omitted, OIDC servers default the client to "web", which conflicts with the loopback redirect URIs that CLI and desktop clients use, so the registration can be rejected. I make register_client always send an application_type. It defaults to "native" to match the loopback redirect this SDK uses, and I added OAuthClientConfig::with_application_type so web clients can opt in. Tests cover the serialized request body and the config default. Implements [2]. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/authorization.mdx#L395 [2]: https://github.com/modelcontextprotocol/rust-sdk/issues/880 Signed-off-by: Stefano Amorelli * chore(auth): declare application_type in client metadata document I set application_type to "native" in the hosted client metadata document so the URL-based client id flow and dynamic registration agree on the client type that SEP-837 [1] expects. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/authorization.mdx#L395 Signed-off-by: Stefano Amorelli --------- Signed-off-by: Stefano Amorelli --- client-metadata.json | 3 +- crates/rmcp/src/transport/auth.rs | 80 ++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/client-metadata.json b/client-metadata.json index 0c289e1e4..2b40a8bcf 100644 --- a/client-metadata.json +++ b/client-metadata.json @@ -3,5 +3,6 @@ "redirect_uris": ["http://127.0.0.1:8080/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], - "token_endpoint_auth_method": "none" + "token_endpoint_auth_method": "none", + "application_type": "native" } diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 3aa3e9131..3c0b55836 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -58,6 +58,9 @@ impl<'c> AsyncHttpClient<'c> for OAuthReqwestClient { const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; +/// Default OIDC Dynamic Client Registration `application_type` (SEP-837) +const DEFAULT_APPLICATION_TYPE: &str = "native"; + /// Stored credentials for OAuth2 authorization #[derive(Clone, Serialize, Deserialize)] #[non_exhaustive] @@ -423,6 +426,7 @@ pub struct OAuthClientConfig { pub client_secret: Option, pub scopes: Vec, pub redirect_uri: String, + pub application_type: Option, } impl OAuthClientConfig { @@ -432,6 +436,7 @@ impl OAuthClientConfig { client_secret: None, scopes: Vec::new(), redirect_uri: redirect_uri.into(), + application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), } } @@ -444,6 +449,12 @@ impl OAuthClientConfig { self.scopes = scopes; self } + + /// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), e.g. `"native"` or `"web"` + pub fn with_application_type(mut self, application_type: impl Into) -> Self { + self.application_type = Some(application_type.into()); + self + } } // add type aliases for oauth2 types @@ -613,6 +624,8 @@ pub struct AuthorizationManager { www_auth_scopes: RwLock>, /// scopes_supported from protected resource metadata (RFC 9728) resource_scopes: RwLock>, + /// OIDC Dynamic Client Registration `application_type` (SEP-837) + application_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -624,6 +637,8 @@ pub(crate) struct ClientRegistrationRequest { pub response_types: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub application_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -707,6 +722,7 @@ impl AuthorizationManager { scope_upgrade_config: ScopeUpgradeConfig::default(), www_auth_scopes: RwLock::new(Vec::new()), resource_scopes: RwLock::new(Vec::new()), + application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), }; Ok(manager) @@ -800,6 +816,11 @@ impl AuthorizationManager { return Err(AuthError::NoAuthorizationSupport); } + // SEP-837: only override application_type when the config sets one + if let Some(application_type) = &config.application_type { + self.application_type = Some(application_type.clone()); + } + let metadata = self.metadata.as_ref().unwrap(); let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone()) @@ -890,6 +911,7 @@ impl AuthorizationManager { }; self.validate_server_metadata("code")?; + let application_type = self.application_type.clone(); let registration_request = ClientRegistrationRequest { client_name: name.to_string(), redirect_uris: vec![redirect_uri.to_string()], @@ -904,6 +926,7 @@ impl AuthorizationManager { } else { Some(scopes.join(" ")) }, + application_type: application_type.clone(), }; let response = match self @@ -958,6 +981,7 @@ impl AuthorizationManager { client_secret: reg_response.client_secret.filter(|s| !s.is_empty()), redirect_uri: redirect_uri.to_string(), scopes: scopes.iter().map(|s| s.to_string()).collect(), + application_type, }; self.configure_client(config.clone())?; @@ -972,6 +996,8 @@ impl AuthorizationManager { client_secret: None, scopes: vec![], redirect_uri: self.base_url.to_string(), + // keep the manager's current application_type + application_type: None, }; self.configure_client(config) } @@ -2140,12 +2166,14 @@ impl AuthorizationSession { client_metadata_url ))); } - // SEP-991: URL-based Client IDs - use URL as client_id directly + // SEP-991: URL-based Client IDs - use URL as client_id directly. + // SEP-837: match the hosted client-metadata.json application_type ("native") OAuthClientConfig { client_id: client_metadata_url.to_string(), client_secret: None, scopes: scopes.iter().map(|s| s.to_string()).collect(), redirect_uri: redirect_uri.to_string(), + application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), } } else { // Fallback to dynamic registration @@ -3117,6 +3145,7 @@ mod tests { client_secret: Some("my-secret".to_string()), scopes: vec![], redirect_uri: "http://localhost/callback".to_string(), + application_type: None, } } @@ -3678,6 +3707,7 @@ mod tests { token_endpoint_auth_method: "none".to_string(), response_types: vec!["code".to_string()], scope: Some("read write".to_string()), + application_type: None, }; let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["scope"], "read write"); @@ -3692,11 +3722,59 @@ mod tests { token_endpoint_auth_method: "none".to_string(), response_types: vec!["code".to_string()], scope: None, + application_type: None, }; let json = serde_json::to_value(&req).unwrap(); assert!(!json.as_object().unwrap().contains_key("scope")); } + // -- ClientRegistrationRequest application_type (SEP-837) -- + + #[test] + fn client_registration_request_includes_application_type_when_present() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: None, + application_type: Some("native".to_string()), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["application_type"], "native"); + } + + #[test] + fn client_registration_request_omits_application_type_when_none() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: None, + application_type: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert!(!json.as_object().unwrap().contains_key("application_type")); + } + + // -- OAuthClientConfig application_type (SEP-837) -- + + #[test] + fn oauth_client_config_defaults_application_type_to_native() { + let config = super::OAuthClientConfig::new("client-id", "http://127.0.0.1:8080/callback"); + assert_eq!(config.application_type.as_deref(), Some("native")); + } + + #[test] + fn oauth_client_config_with_application_type_overrides_default() { + let config = super::OAuthClientConfig::new("client-id", "https://app.example.com/callback") + .with_application_type("web"); + assert_eq!(config.application_type.as_deref(), Some("web")); + } + // -- client credentials (SEP-1046) -- #[tokio::test] From 2d3d1879ad6b0a12325c118176d2914ce857523e Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 10 Jun 2026 13:08:31 +1000 Subject: [PATCH 170/333] feat: validate OAuth authorization response issuer (#896) * feat: validate OAuth authorization response issuer * fix: tighten issuer validation callbacks --- conformance/src/bin/client.rs | 70 ++-- crates/rmcp/src/transport/auth.rs | 387 +++++++++++++++++++++- examples/clients/src/auth/oauth_client.rs | 4 +- 3 files changed, 420 insertions(+), 41 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 49afb75fd..41a94f701 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -3,7 +3,8 @@ use rmcp::{ model::*, service::RequestContext, transport::{ - AuthClient, AuthorizationManager, StreamableHttpClientTransport, auth::OAuthState, + AuthClient, AuthorizationManager, StreamableHttpClientTransport, + auth::{AuthorizationCallback, OAuthState}, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -221,20 +222,16 @@ async fn perform_oauth_flow( .and_then(|v| v.to_str().ok()) .ok_or_else(|| anyhow::anyhow!("No Location header in auth redirect"))?; - let redirect_url = url::Url::parse(location)?; - let code = redirect_url - .query_pairs() - .find(|(k, _)| k == "code") - .map(|(_, v)| v.to_string()) - .ok_or_else(|| anyhow::anyhow!("No code in redirect URL"))?; - let state = redirect_url - .query_pairs() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.to_string()) - .ok_or_else(|| anyhow::anyhow!("No state in redirect URL"))?; + let callback = AuthorizationCallback::from_redirect_url(location)?; tracing::debug!("Got auth code, exchanging for token..."); - oauth.handle_callback(&code, &state).await?; + oauth + .handle_callback_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await?; let am = oauth .into_authorization_manager() @@ -334,8 +331,14 @@ async fn run_auth_scope_step_up_client( .await?; let auth_url = oauth.get_authorization_url().await?; - let (code, state) = headless_authorize(&auth_url).await?; - oauth.handle_callback(&code, &state).await?; + let callback = headless_authorize(&auth_url).await?; + oauth + .handle_callback_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await?; let am = oauth .into_authorization_manager() @@ -380,8 +383,14 @@ async fn run_auth_scope_step_up_client( ) .await?; let auth_url2 = oauth2.get_authorization_url().await?; - let (code2, state2) = headless_authorize(&auth_url2).await?; - oauth2.handle_callback(&code2, &state2).await?; + let callback2 = headless_authorize(&auth_url2).await?; + oauth2 + .handle_callback_with_issuer( + &callback2.code, + &callback2.csrf_token, + callback2.issuer.as_deref(), + ) + .await?; let am2 = oauth2.into_authorization_manager().unwrap(); let auth_client2 = AuthClient::new(reqwest::Client::default(), am2); @@ -422,8 +431,14 @@ async fn run_auth_scope_retry_limit_client( ) .await?; let auth_url = oauth.get_authorization_url().await?; - let (code, state) = headless_authorize(&auth_url).await?; - oauth.handle_callback(&code, &state).await?; + let callback = headless_authorize(&auth_url).await?; + oauth + .handle_callback_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await?; let am = oauth.into_authorization_manager().unwrap(); let auth_client = AuthClient::new(reqwest::Client::default(), am); @@ -696,8 +711,8 @@ async fn run_cross_app_access_client( // ─── Helpers ──────────────────────────────────────────────────────────────── -/// Fetch an authorization URL headlessly, returning (code, state). -async fn headless_authorize(auth_url: &str) -> anyhow::Result<(String, String)> { +/// Fetch an authorization URL headlessly, returning the callback parameters. +async fn headless_authorize(auth_url: &str) -> anyhow::Result { let http = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build()?; @@ -707,18 +722,7 @@ async fn headless_authorize(auth_url: &str) -> anyhow::Result<(String, String)> .get("location") .and_then(|v| v.to_str().ok()) .ok_or_else(|| anyhow::anyhow!("No Location header in auth redirect"))?; - let redirect_url = url::Url::parse(location)?; - let code = redirect_url - .query_pairs() - .find(|(k, _)| k == "code") - .map(|(_, v)| v.to_string()) - .ok_or_else(|| anyhow::anyhow!("No code in redirect URL"))?; - let state = redirect_url - .query_pairs() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.to_string()) - .ok_or_else(|| anyhow::anyhow!("No state in redirect URL"))?; - Ok((code, state)) + AuthorizationCallback::from_redirect_url(location).map_err(Into::into) } /// Build a `CallToolRequestParams` for a tool, optionally with arguments. diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 3c0b55836..be9abf149 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -159,6 +159,10 @@ impl CredentialStore for InMemoryCredentialStore { pub struct StoredAuthorizationState { pub pkce_verifier: String, pub csrf_token: String, + #[serde(default)] + pub expected_issuer: Option, + #[serde(default)] + pub require_issuer: bool, pub created_at: u64, } @@ -167,6 +171,8 @@ impl std::fmt::Debug for StoredAuthorizationState { f.debug_struct("StoredAuthorizationState") .field("pkce_verifier", &"[REDACTED]") .field("csrf_token", &"[REDACTED]") + .field("expected_issuer", &self.expected_issuer) + .field("require_issuer", &self.require_issuer) .field("created_at", &self.created_at) .finish() } @@ -201,9 +207,20 @@ impl ExtraTokenFields for VendorExtraTokenFields {} impl StoredAuthorizationState { pub fn new(pkce_verifier: &PkceCodeVerifier, csrf_token: &CsrfToken) -> Self { + Self::new_with_expected_issuer(pkce_verifier, csrf_token, None, false) + } + + pub fn new_with_expected_issuer( + pkce_verifier: &PkceCodeVerifier, + csrf_token: &CsrfToken, + expected_issuer: Option, + require_issuer: bool, + ) -> Self { Self { pkce_verifier: pkce_verifier.secret().to_string(), csrf_token: csrf_token.secret().to_string(), + expected_issuer, + require_issuer, created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) @@ -364,6 +381,17 @@ pub enum AuthError { upgrade_url: Option, }, + #[error( + "Authorization server issuer mismatch: expected {expected_issuer}, received {received_issuer}" + )] + AuthorizationServerMismatch { + expected_issuer: String, + received_issuer: String, + }, + + #[error("Authorization server response missing required issuer: expected {expected_issuer}")] + AuthorizationServerMissingIssuer { expected_issuer: String }, + #[error("Client credentials error: {0}")] ClientCredentialsError(String), @@ -1026,8 +1054,27 @@ impl AuthorizationManager { let (auth_url, csrf_token) = auth_request.url(); - // store pkce verifier for later use via state store - let stored_state = StoredAuthorizationState::new(&pkce_verifier, &csrf_token); + // store pkce verifier and expected issuer for later use via state store + let expected_issuer = self + .metadata + .as_ref() + .and_then(|metadata| metadata.issuer.clone()); + let require_issuer = self + .metadata + .as_ref() + .and_then(|metadata| { + metadata + .additional_fields + .get("authorization_response_iss_parameter_supported") + .and_then(|value| value.as_bool()) + }) + .unwrap_or(false); + let stored_state = StoredAuthorizationState::new_with_expected_issuer( + &pkce_verifier, + &csrf_token, + expected_issuer, + require_issuer, + ); self.state_store .save(csrf_token.secret(), stored_state) .await?; @@ -1166,11 +1213,58 @@ impl AuthorizationManager { *self.scope_upgrade_attempts.read().await } + fn validate_authorization_response_issuer( + stored_state: &StoredAuthorizationState, + received_issuer: Option<&str>, + ) -> Result<(), AuthError> { + let Some(expected_issuer) = stored_state.expected_issuer.as_deref() else { + if received_issuer.is_some() || stored_state.require_issuer { + return Err(AuthError::AuthorizationFailed( + "Authorization callback issuer cannot be validated because expected issuer was not recorded" + .to_string(), + )); + } + // Without issuer metadata from discovery and without an issuer-bearing + // callback, there is no stable value to bind to. This preserves + // compatibility with older authorization servers. + return Ok(()); + }; + let Some(received_issuer) = received_issuer else { + if stored_state.require_issuer { + return Err(AuthError::AuthorizationServerMissingIssuer { + expected_issuer: expected_issuer.to_string(), + }); + } + // SEP-2468 recommends RFC 9207 `iss`, but tolerate older authorization + // servers that do not advertise support for backwards compatibility. + return Ok(()); + }; + if received_issuer != expected_issuer { + return Err(AuthError::AuthorizationServerMismatch { + expected_issuer: expected_issuer.to_string(), + received_issuer: received_issuer.to_string(), + }); + } + Ok(()) + } + /// exchange authorization code for access token pub async fn exchange_code_for_token( &self, code: &str, csrf_token: &str, + ) -> Result { + self.exchange_code_for_token_with_issuer(code, csrf_token, None) + .await + } + + /// exchange authorization code for access token, validating the optional + /// RFC 9207 authorization response issuer (`iss`) when present. + pub async fn exchange_code_for_token_with_issuer( + &self, + code: &str, + csrf_token: &str, + received_issuer: Option<&str>, ) -> Result { debug!("start exchange code for token: {:?}", code); let oauth_client = self @@ -1187,6 +1281,8 @@ impl AuthorizationManager { // Delete state after retrieval (one-time use) self.state_store.delete(csrf_token).await?; + Self::validate_authorization_response_issuer(&stored_state, received_issuer)?; + // Reconstruct the PKCE verifier let pkce_verifier = stored_state.into_pkce_verifier(); @@ -2132,6 +2228,47 @@ impl AuthorizationManager { } } +/// Parameters returned by an OAuth authorization redirect. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct AuthorizationCallback { + pub code: String, + pub csrf_token: String, + pub issuer: Option, +} + +impl AuthorizationCallback { + /// Parse an OAuth redirect URL and extract `code`, `state`, and optional RFC 9207 `iss`. + pub fn from_redirect_url(url: &str) -> Result { + let url = Url::parse(url)?; + let mut code = None; + let mut csrf_token = None; + let mut issuer = None; + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => csrf_token = Some(value.into_owned()), + "iss" => issuer = Some(value.into_owned()), + _ => {} + } + } + + let code = code.ok_or_else(|| { + AuthError::AuthorizationFailed("Authorization callback missing code".to_string()) + })?; + let csrf_token = csrf_token.ok_or_else(|| { + AuthError::AuthorizationFailed("Authorization callback missing state".to_string()) + })?; + + Ok(Self { + code, + csrf_token, + issuer, + }) + } +} + /// oauth2 authorization session, for guiding user to complete the authorization process #[non_exhaustive] pub struct AuthorizationSession { @@ -2239,11 +2376,37 @@ impl AuthorizationSession { &self, code: &str, csrf_token: &str, + ) -> Result { + self.handle_callback_with_issuer(code, csrf_token, None) + .await + } + + /// handle authorization code callback, validating the optional RFC 9207 + /// authorization response issuer (`iss`) when present. + pub async fn handle_callback_with_issuer( + &self, + code: &str, + csrf_token: &str, + issuer: Option<&str>, ) -> Result { self.auth_manager - .exchange_code_for_token(code, csrf_token) + .exchange_code_for_token_with_issuer(code, csrf_token, issuer) .await } + + /// handle an OAuth redirect URL, including optional RFC 9207 `iss` validation. + pub async fn handle_callback_url( + &self, + callback_url: &str, + ) -> Result { + let callback = AuthorizationCallback::from_redirect_url(callback_url)?; + self.handle_callback_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await + } } /// http client extension, automatically add authorization header @@ -2496,9 +2659,23 @@ impl OAuthState { /// handle authorization callback pub async fn handle_callback(&mut self, code: &str, csrf_token: &str) -> Result<(), AuthError> { + self.handle_callback_with_issuer(code, csrf_token, None) + .await + } + + /// handle authorization callback, validating the optional RFC 9207 + /// authorization response issuer (`iss`) when present. + pub async fn handle_callback_with_issuer( + &mut self, + code: &str, + csrf_token: &str, + issuer: Option<&str>, + ) -> Result<(), AuthError> { match self { OAuthState::Session(session) => { - session.handle_callback(code, csrf_token).await?; + session + .handle_callback_with_issuer(code, csrf_token, issuer) + .await?; self.complete_authorization().await } OAuthState::Unauthorized(_) => { @@ -2513,6 +2690,17 @@ impl OAuthState { } } + /// handle an OAuth redirect URL, including optional RFC 9207 `iss` validation. + pub async fn handle_callback_url(&mut self, callback_url: &str) -> Result<(), AuthError> { + let callback = AuthorizationCallback::from_redirect_url(callback_url)?; + self.handle_callback_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await + } + /// get access token pub async fn get_access_token(&self) -> Result { match self { @@ -2599,8 +2787,9 @@ mod tests { use url::Url; use super::{ - AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore, - OAuthClientConfig, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, + AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, + InMemoryStateStore, OAuthClientConfig, ScopeUpgradeConfig, StateStore, + StoredAuthorizationState, is_https_url, }; use crate::transport::auth::VendorExtraTokenFields; @@ -2922,6 +3111,29 @@ mod tests { assert_eq!(deserialized.pkce_verifier, "my-verifier"); assert_eq!(deserialized.csrf_token, "my-csrf"); + assert_eq!(deserialized.expected_issuer, None); + assert!(!deserialized.require_issuer); + } + + #[test] + fn test_stored_authorization_state_records_expected_issuer() { + let pkce = PkceCodeVerifier::new("my-verifier".to_string()); + let csrf = CsrfToken::new("my-csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer( + &pkce, + &csrf, + Some("https://auth.example.com".to_string()), + false, + ); + + let json = serde_json::to_string(&state).unwrap(); + let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap(); + + assert_eq!( + deserialized.expected_issuer.as_deref(), + Some("https://auth.example.com") + ); + assert!(!deserialized.require_issuer); } #[test] @@ -2934,7 +3146,7 @@ mod tests { assert!(!debug_output.contains("super-secret-verifier")); assert!(!debug_output.contains("super-secret-csrf")); assert!(debug_output.contains("[REDACTED]")); - assert!(debug_output.contains("created_at")); + assert!(debug_output.contains("expected_issuer")); assert!(debug_output.contains("created_at")); } @@ -3369,6 +3581,167 @@ mod tests { assert!(scope.contains("write")); } + #[test] + fn authorization_callback_parses_optional_issuer() { + let callback = AuthorizationCallback::from_redirect_url( + "http://localhost/callback?code=abc&state=csrf&iss=https%3A%2F%2Fauth.example.com", + ) + .unwrap(); + + assert_eq!(callback.code, "abc"); + assert_eq!(callback.csrf_token, "csrf"); + assert_eq!(callback.issuer.as_deref(), Some("https://auth.example.com")); + } + + #[test] + fn authorization_callback_requires_code_and_state() { + let missing_code = AuthorizationCallback::from_redirect_url( + "http://localhost/callback?state=csrf&iss=https%3A%2F%2Fauth.example.com", + ); + assert!(matches!( + missing_code, + Err(AuthError::AuthorizationFailed(message)) if message.contains("missing code") + )); + + let missing_state = AuthorizationCallback::from_redirect_url( + "http://localhost/callback?code=abc&iss=https%3A%2F%2Fauth.example.com", + ); + assert!(matches!( + missing_state, + Err(AuthError::AuthorizationFailed(message)) if message.contains("missing state") + )); + } + + #[test] + fn validate_authorization_response_issuer_accepts_match_and_missing_issuer() { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer( + &pkce, + &csrf, + Some("https://auth.example.com".to_string()), + false, + ); + + assert!( + AuthorizationManager::validate_authorization_response_issuer( + &state, + Some("https://auth.example.com") + ) + .is_ok() + ); + assert!(AuthorizationManager::validate_authorization_response_issuer(&state, None).is_ok()); + } + + #[test] + fn validate_authorization_response_issuer_requires_issuer_when_advertised() { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer( + &pkce, + &csrf, + Some("https://auth.example.com".to_string()), + true, + ); + + let error = + AuthorizationManager::validate_authorization_response_issuer(&state, None).unwrap_err(); + + assert!(matches!( + error, + AuthError::AuthorizationServerMissingIssuer { expected_issuer } + if expected_issuer == "https://auth.example.com" + )); + } + + #[test] + fn validate_authorization_response_issuer_rejects_present_issuer_without_expected_issuer() { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer(&pkce, &csrf, None, false); + + let error = AuthorizationManager::validate_authorization_response_issuer( + &state, + Some("https://auth.example.com"), + ) + .unwrap_err(); + + assert!( + matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded")) + ); + } + + #[test] + fn validate_authorization_response_issuer_rejects_required_issuer_without_expected_issuer() { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer(&pkce, &csrf, None, true); + + let error = + AuthorizationManager::validate_authorization_response_issuer(&state, None).unwrap_err(); + + assert!( + matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded")) + ); + } + + #[test] + fn validate_authorization_response_issuer_rejects_mismatch() { + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer( + &pkce, + &csrf, + Some("https://auth.example.com".to_string()), + false, + ); + + let error = AuthorizationManager::validate_authorization_response_issuer( + &state, + Some("https://evil.example.com"), + ) + .unwrap_err(); + + assert!(matches!( + error, + AuthError::AuthorizationServerMismatch { + expected_issuer, + received_issuer + } if expected_issuer == "https://auth.example.com" + && received_issuer == "https://evil.example.com" + )); + } + + #[tokio::test] + async fn authorization_url_stores_expected_issuer_for_callback_validation() { + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + issuer: Some("https://auth.example.com".to_string()), + ..Default::default() + })) + .await; + manager.configure_client_id("test-client-id").unwrap(); + + let auth_url = manager.get_authorization_url(&[]).await.unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + let state = parsed + .query_pairs() + .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) + .expect("authorization URL should contain state"); + + let stored_state = manager + .state_store + .load(&state) + .await + .unwrap() + .expect("authorization state should be stored"); + assert_eq!( + stored_state.expected_issuer.as_deref(), + Some("https://auth.example.com") + ); + } + // -- scope management -- #[test] diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 456f32698..7a2bfab00 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -38,6 +38,7 @@ struct AppState { struct CallbackParams { code: String, state: String, + iss: Option, } async fn callback_handler( @@ -150,6 +151,7 @@ async fn main() -> Result<()> { let CallbackParams { code: auth_code, state: csrf_token, + iss, } = code_receiver .await .context("Failed to get authorization code")?; @@ -157,7 +159,7 @@ async fn main() -> Result<()> { // Exchange code for access token tracing::info!("Exchanging authorization code for access token..."); oauth_state - .handle_callback(&auth_code, &csrf_token) + .handle_callback_with_issuer(&auth_code, &csrf_token, iss.as_deref()) .await .context("Failed to handle callback")?; tracing::info!("Successfully obtained access token"); From 2536a05992fb02d37a7d7e1ddcb94e6f959c5304 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:42:09 -0400 Subject: [PATCH 171/333] fix: update peer info on duplicate initialize (#862) --- crates/rmcp/src/handler/server.rs | 4 +- crates/rmcp/src/service.rs | 16 +++-- .../rmcp/tests/test_server_initialization.rs | 61 +++++++++++++++++++ 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 8673a8bfd..78c3be2f2 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -184,9 +184,7 @@ macro_rules! server_handler_methods { request: InitializeRequestParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - if context.peer.peer_info().is_none() { - context.peer.set_peer_info(request); - } + context.peer.set_peer_info(request); std::future::ready(Ok(self.get_info())) } fn complete( diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index d938cd660..08791e5e5 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -384,7 +384,7 @@ pub struct Peer { tx: mpsc::Sender>, request_id_provider: Arc, progress_token_provider: Arc, - info: Arc>, + info: Arc>>>, } impl std::fmt::Debug for Peer { @@ -423,7 +423,7 @@ impl Peer { tx, request_id_provider, progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()), - info: Arc::new(tokio::sync::OnceCell::new_with(peer_info)), + info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), }, rx, ) @@ -484,16 +484,14 @@ impl Peer { peer: self.clone(), }) } - pub fn peer_info(&self) -> Option<&R::PeerInfo> { - self.info.get() + /// Snapshot of the peer's handshake info. + pub fn peer_info(&self) -> Option> { + self.info.read().expect("peer info lock poisoned").clone() } + /// Stores the peer's handshake info, overwriting any previous value. pub fn set_peer_info(&self, info: R::PeerInfo) { - if self.info.initialized() { - tracing::warn!("trying to set peer info, which is already initialized"); - } else { - let _ = self.info.set(info); - } + *self.info.write().expect("peer info lock poisoned") = Some(Arc::new(info)); } pub fn is_transport_closed(&self) -> bool { diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index e2e048960..6542e295b 100644 --- a/crates/rmcp/tests/test_server_initialization.rs +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -299,6 +299,67 @@ async fn server_pinned_version_used_as_fallback_for_unknown_client_request() { assert_eq!(negotiated, ProtocolVersion::V_2025_06_18); } +fn duplicate_init_request(id: u64, version: &str) -> ClientJsonRpcMessage { + msg(&format!( + r#"{{ + "jsonrpc": "2.0", + "id": {id}, + "method": "initialize", + "params": {{ + "protocolVersion": "{version}", + "capabilities": {{ "sampling": {{}} }}, + "clientInfo": {{ "name": "renegotiated-client", "version": "9.9.9" }} + }} + }}"# + )) +} + +#[tokio::test] +async fn server_accepts_duplicate_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(initialized_notification()).await.unwrap(); + + client + .send(duplicate_init_request(2, "2025-11-25")) + .await + .unwrap(); + let response = client.receive().await.unwrap(); + assert!( + matches!(response, ServerJsonRpcMessage::Response(_)), + "expected successful InitializeResult, got: {response:?}" + ); +} + +#[tokio::test] +async fn server_session_remains_usable_after_renegotiation() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + do_initialize(&mut client).await; + client.send(initialized_notification()).await.unwrap(); + client + .send(duplicate_init_request(2, "2025-11-25")) + .await + .unwrap(); + let _renegotiated = client.receive().await.unwrap(); + + client.send(ping_request(3)).await.unwrap(); + let pong = client.receive().await.unwrap(); + assert!( + matches!( + pong, + ServerJsonRpcMessage::Response(ref r) + if matches!(r.result, ServerResult::EmptyResult(_)) + ), + "expected EmptyResult ping after renegotiation, got: {pong:?}" + ); +} + // Server buffers multiple requests before initialized and processes them in order. #[tokio::test] async fn server_init_buffers_multiple_requests_before_initialized() { From 52e731bec60e534bcb874135ad365641e8a16218 Mon Sep 17 00:00:00 2001 From: Dawid Nowak Date: Thu, 11 Jun 2026 00:28:33 +0100 Subject: [PATCH 172/333] fix: Small improvement to progress demo (#898) Signed-off-by: Dawid Nowak --- .githooks/commit-msg | 50 -------------------- examples/servers/src/common/progress_demo.rs | 26 ++++++++-- examples/servers/src/progress_demo.rs | 5 ++ 3 files changed, 26 insertions(+), 55 deletions(-) delete mode 100755 .githooks/commit-msg diff --git a/.githooks/commit-msg b/.githooks/commit-msg deleted file mode 100755 index a59728be6..000000000 --- a/.githooks/commit-msg +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/sh -# Verify commit message follows conventional commit format -# https://www.conventionalcommits.org/ -# -# Uses npx commitlint if Node.js is available (same as CI), -# otherwise falls back to basic shell regex validation. - -commit_msg_file="$1" -commit_msg=$(cat "$commit_msg_file") - -# Skip merge commits -if echo "$commit_msg" | grep -qE "^Merge "; then - exit 0 -fi - -# Try to use commitlint via npx if Node.js is available -if command -v npx >/dev/null 2>&1; then - # Run commitlint with config-conventional rules (same as CI) - echo "$commit_msg" | npx --yes @commitlint/cli@latest --extends @commitlint/config-conventional - exit $? -fi - -# Fallback: basic shell regex validation if Node.js is not available -echo "Note: Node.js not found, using basic commit message validation." -echo " Install Node.js for full commitlint validation (same as CI)." -echo "" - -# Conventional commit types from @commitlint/config-conventional -types="build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test" - -# Pattern: type(optional-scope): description -# The description must start with lowercase and not end with period -pattern="^($types)(\(.+\))?(!)?: .+" - -if ! echo "$commit_msg" | head -1 | grep -qE "$pattern"; then - echo "ERROR: Commit message does not follow conventional commit format." - echo "" - echo "Expected format: (): " - echo "" - echo "Valid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test" - echo "" - echo "Examples:" - echo " feat: add new feature" - echo " fix(parser): resolve parsing issue" - echo " docs: update README" - echo "" - echo "Your commit message:" - echo " $(head -1 "$commit_msg_file")" - exit 1 -fi diff --git a/examples/servers/src/common/progress_demo.rs b/examples/servers/src/common/progress_demo.rs index 341b3e70a..ba6fa7d13 100644 --- a/examples/servers/src/common/progress_demo.rs +++ b/examples/servers/src/common/progress_demo.rs @@ -11,7 +11,7 @@ use rmcp::{ }; use serde_json::json; use tokio_stream::StreamExt; -use tracing::debug; +use tracing::{debug, info}; // a Stream data source that generates data in chunks #[derive(Clone)] @@ -30,7 +30,7 @@ impl StreamDataSource { } } pub fn from_text(text: &str) -> Self { - Self::new(text.as_bytes().to_vec(), 1) + Self::new(text.as_bytes().to_vec(), 5) } } @@ -61,7 +61,7 @@ impl ProgressDemo { #[allow(dead_code)] pub fn new() -> Self { Self { - data_source: StreamDataSource::from_text("Hello, world!"), + data_source: StreamDataSource::from_text("1111122222333334444455555"), } } #[tool(description = "Process data stream with progress updates")] @@ -70,6 +70,21 @@ impl ProgressDemo { ctx: RequestContext, ) -> Result { let mut counter = 0; + info!( + "Processing stream with progress token {:?}", + ctx.meta.get_key_value("progressToken") + ); + let Some((_, progress_token)) = ctx.meta.get_key_value("progressToken") else { + return Err(McpError::internal_error(format!("No progress token"), None)); + }; + + let Ok(progress_token) = serde_json::from_value::(progress_token.clone()) + else { + return Err(McpError::internal_error( + format!("Invalid format of the progress token"), + None, + )); + }; let mut data_source = self.data_source.clone(); loop { @@ -83,9 +98,9 @@ impl ProgressDemo { counter += 1; // create progress notification param let progress_param = ProgressNotificationParam { - progress_token: ProgressToken(NumberOrString::Number(counter)), + progress_token: ProgressToken(progress_token.clone()), progress: counter as f64, - total: None, + total: Some(5.0), message: Some(chunk_str.to_string()), }; @@ -104,6 +119,7 @@ impl ProgressDemo { )); } } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } Ok(CallToolResult::success(vec![Content::text(format!( diff --git a/examples/servers/src/progress_demo.rs b/examples/servers/src/progress_demo.rs index e9e147ea1..98e4f398e 100644 --- a/examples/servers/src/progress_demo.rs +++ b/examples/servers/src/progress_demo.rs @@ -10,6 +10,7 @@ use rmcp::{ mod common; use common::progress_demo::ProgressDemo; +use tracing_subscriber::EnvFilter; const HTTP_BIND_ADDRESS: &str = "127.0.0.1:8001"; @@ -20,6 +21,10 @@ async fn main() -> anyhow::Result<()> { .nth(1) .unwrap_or_else(|| env::var("TRANSPORT_MODE").unwrap_or_else(|_| "stdio".to_string())); + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + match transport_mode.as_str() { "stdio" => run_stdio().await, "http" | "streamhttp" => run_streamable_http().await, From 53c6daadd9419c67853505c9b8b44267256d0b7b Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 11 Jun 2026 20:43:49 +1000 Subject: [PATCH 173/333] fix(auth): apply offline_access to reauth paths (#897) * fix(auth): apply offline_access to reauth paths * Update crates/rmcp/src/transport/auth.rs Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp/src/transport/auth.rs | 70 +++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index be9abf149..37abfc37a 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1192,7 +1192,8 @@ impl AuthorizationManager { drop(attempts); let current_scopes = self.current_scopes.read().await.clone(); - let upgraded_scopes = Self::compute_scope_union(¤t_scopes, required_scope); + let mut upgraded_scopes = Self::compute_scope_union(¤t_scopes, required_scope); + self.add_offline_access_if_supported(&mut upgraded_scopes); debug!( "Requesting scope upgrade: current={:?}, required={}, union={:?}", @@ -1425,8 +1426,10 @@ impl AuthorizationManager { let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string()); let mut refresh_request = oauth_client.exchange_refresh_token(&refresh_token_value); - for scope in &stored_credentials.granted_scopes { - refresh_request = refresh_request.add_scope(Scope::new(scope.clone())); + let mut refresh_scopes = stored_credentials.granted_scopes; + self.add_offline_access_if_supported(&mut refresh_scopes); + for scope in refresh_scopes { + refresh_request = refresh_request.add_scope(Scope::new(scope)); } let token_result = refresh_request .request_async(&OAuthReqwestClient(self.http_client.clone())) @@ -3912,6 +3915,29 @@ mod tests { ); } + #[tokio::test] + async fn scope_upgrade_adds_offline_access_when_as_supports_it() { + let mut mgr = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + mgr.configure_client_id("my-client").unwrap(); + *mgr.current_scopes.write().await = vec!["profile".to_string()]; + + let auth_url = mgr.request_scope_upgrade("email").await.unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + let scope = parsed + .query_pairs() + .find_map(|(key, value)| (key == "scope").then(|| value.into_owned())) + .expect("scope should be present"); + let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); + scope_parts.sort_unstable(); + assert_eq!(scope_parts, vec!["email", "offline_access", "profile"]); + } + #[test] fn scope_upgrade_config_default_values() { let config = ScopeUpgradeConfig::default(); @@ -4461,6 +4487,44 @@ mod tests { assert_eq!(scope_parts, vec!["read", "write"]); } + #[tokio::test] + async fn refresh_token_adds_offline_access_when_as_supports_it() { + let (base_url, captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + scopes_supported: Some(vec!["read".to_string(), "offline_access".to_string()]), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec!["read".to_string()], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }; + manager.credential_store.save(stored).await.unwrap(); + + manager.refresh_token().await.unwrap(); + + let body = captured.lock().unwrap().take().unwrap(); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect(); + let scope = params + .get("scope") + .expect("scope should be present in refresh request"); + let mut scope_parts: Vec<&str> = scope.split_whitespace().collect(); + scope_parts.sort_unstable(); + assert_eq!(scope_parts, vec!["offline_access", "read"]); + } + #[tokio::test] async fn refresh_token_omits_scope_when_granted_scopes_is_empty() { let (base_url, captured) = start_token_server().await; From 5a78773faad4825d981a8db4d034f6cb87faebc0 Mon Sep 17 00:00:00 2001 From: 0xWeakSheep Date: Thu, 11 Jun 2026 22:18:02 +0800 Subject: [PATCH 174/333] fix: return tool errors for invalid arguments (#894) --- crates/rmcp/src/handler/server/router/tool.rs | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index c2bf299c8..35bd25a97 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -137,10 +137,24 @@ use crate::{ tool::{CallToolHandler, DynCallToolHandler, ToolCallContext}, tool_name_validation::validate_and_warn_tool_name, }, - model::{CallToolResult, Tool, ToolAnnotations}, + model::{CallToolResult, Content, ErrorCode, Tool, ToolAnnotations}, service::{MaybeBoxFuture, MaybeSend}, }; +const TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX: &str = "failed to deserialize parameters:"; + +fn into_tool_argument_error(error: crate::ErrorData) -> Result { + if error.code == ErrorCode::INVALID_PARAMS + && error + .message + .starts_with(TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX) + { + return Ok(CallToolResult::error(vec![Content::text(error.message)])); + } + + Err(error) +} + #[non_exhaustive] pub struct ToolRoute { #[allow(clippy::type_complexity)] @@ -555,7 +569,10 @@ where .get(name) .ok_or_else(|| crate::ErrorData::invalid_params("tool not found", None))?; - let result = (item.call)(context).await?; + let result = match (item.call)(context).await { + Ok(result) => result, + Err(error) => return into_tool_argument_error(error), + }; Ok(result) } @@ -611,6 +628,7 @@ mod tests { use super::*; use crate::{ RoleServer, + handler::server::wrapper::Parameters, model::{CallToolRequestParams, ErrorCode, NumberOrString}, service::{AtomicU32RequestIdProvider, Peer, RequestContext}, }; @@ -618,6 +636,57 @@ mod tests { struct DummyService; impl crate::handler::server::ServerHandler for DummyService {} + #[derive(serde::Deserialize, schemars::JsonSchema)] + struct RequiredParams { + project: String, + } + + fn requires_params(Parameters(params): Parameters) -> String { + params.project + } + + #[tokio::test] + async fn test_argument_deserialization_error_returns_tool_error_result() { + let service = DummyService; + let router = ToolRouter::new().with_route(ToolRoute::new( + crate::model::Tool::new( + "requires_params", + "requires params", + Arc::new(Default::default()), + ), + requires_params, + )); + + let id_provider: Arc = + Arc::new(AtomicU32RequestIdProvider::default()); + let (peer, _rx) = Peer::::new(id_provider, None); + let ctx = crate::handler::server::tool::ToolCallContext::new( + &service, + CallToolRequestParams { + meta: None, + name: Cow::Borrowed("requires_params"), + arguments: Some(Default::default()), + task: None, + }, + RequestContext::new(NumberOrString::Number(1), peer), + ); + + let result = router + .call(ctx) + .await + .expect("argument validation should be a tool result"); + assert_eq!(result.is_error, Some(true)); + + let text = result + .content + .first() + .and_then(|content| content.raw.as_text()) + .map(|text| text.text.as_str()) + .expect("tool error result should include text"); + assert!(text.contains("failed to deserialize parameters")); + assert!(text.contains("missing field `project`")); + } + #[tokio::test] async fn test_call_disabled_tool_returns_error() { let service = DummyService; From 266f870e6933053c9505f8c54ea6b614124bbf1f Mon Sep 17 00:00:00 2001 From: Loocor Date: Thu, 11 Jun 2026 22:51:57 +0800 Subject: [PATCH 175/333] docs: refine mcpmate listing copy (#885) --- README.md | 1 + docs/readme/README.zh-cn.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index a94023c1f..7ec71ac6a 100644 --- a/README.md +++ b/README.md @@ -1020,6 +1020,7 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. - [spreadsheet-mcp](https://github.com/PSU3D0/spreadsheet-mcp) - Token-efficient MCP server for spreadsheet analysis with automatic region detection, recalculation, screenshot, and editing support for LLM agents - [hyper-mcp](https://github.com/hyper-mcp-rs/hyper-mcp) - A fast, secure MCP server that extends its capabilities through WebAssembly (WASM) plugins - [rudof-mcp](https://github.com/rudof-project/rudof/tree/master/rudof_mcp) - RDF validation and data processing MCP server with ShEx/SHACL validation, SPARQL queries, and format conversion. Supports stdio and streamable HTTP transports with full MCP capabilities (tools, prompts, resources, logging, completions, tasks) +- [MCPMate](https://github.com/loocor/MCPMate) - Desktop app for progressive MCP management: start with guided server import, then grow into multi-client profiles and Unify meta tools to keep tool exposure, token use, and runtime state under control, with more options for efficiency, cost, and reliability - [McpMux](https://github.com/mcpmux/mcp-mux) - Desktop app to configure MCP servers once at McpMux, connect every AI client (Cursor, Claude Desktop, VS Code, Windsurf) through a single encrypted local gateway with Spaces for project organization, FeatureSets to switch toolsets per client, and a built-in server registry - [systemprompt-template](https://github.com/systempromptio/systemprompt-template) - Single-binary Rust runtime providing MCP governance — authentication, authorisation, rate-limiting, audit trails, and cost tracking for AI agents. Self-hosted, air-gap capable, 3,300+ req/s with sub-5ms governance overhead - [jilebi-mcp](https://github.com/datron/jilebi) - an extensible MCP server through plugins in Javascript with a secure permissions model diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md index 8c3d60671..5ddfaee5d 100644 --- a/docs/readme/README.zh-cn.md +++ b/docs/readme/README.zh-cn.md @@ -1010,6 +1010,7 @@ impl ServerHandler for TaskDemo {} - [spreadsheet-mcp](https://github.com/PSU3D0/spreadsheet-mcp) - 面向 LLM 智能体的高效 Token 使用的电子表格分析 MCP 服务,支持自动区域检测、重新计算、截图和编辑 - [hyper-mcp](https://github.com/hyper-mcp-rs/hyper-mcp) - 通过 WebAssembly (WASM) 插件扩展功能的快速、安全的 MCP 服务 - [rudof-mcp](https://github.com/rudof-project/rudof/tree/master/rudof_mcp) - RDF 验证和数据处理 MCP 服务,支持 ShEx/SHACL 验证、SPARQL 查询和格式转换。支持 stdio 和 Streamable HTTP 传输,具备完整的 MCP 功能(工具、提示词、资源、日志、补全、任务) +- [MCPMate](https://github.com/loocor/MCPMate) - 渐进式 MCP 管理桌面应用:从引导式服务导入开始,逐步扩展到多客户端配置集和 Unify 元工具,让能力暴露、Token 消耗与运行状态更可控,并在效率、成本和可靠性上提供更多选择 ## 开发 From 8f5310b4246ad493f4b685f6d8a0fd7778130d42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:15:14 -0400 Subject: [PATCH 176/333] chore(deps): update tower-http requirement from 0.6 to 0.7 (#906) Updates the requirements on [tower-http](https://github.com/tower-rs/tower-http) to permit the latest version. - [Release notes](https://github.com/tower-rs/tower-http/releases) - [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.0...tower-http-0.7.0) --- updated-dependencies: - dependency-name: tower-http dependency-version: 0.7.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/servers/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/servers/Cargo.toml b/examples/servers/Cargo.toml index 7dbd2fb4d..a544f3ea1 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -40,7 +40,7 @@ chrono = "0.4" uuid = { version = "1.6", features = ["v4", "serde"] } serde_urlencoded = "0.7" askama = { version = "0.16" } -tower-http = { version = "0.6", features = ["cors"] } +tower-http = { version = "0.7", features = ["cors"] } hyper = { version = "1" } hyper-util = { version = "0", features = ["server"] } tokio-util = { version = "0.7" } From 95a8e961e0bbfa60e8264995487ee226449f6a79 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:55:25 -0400 Subject: [PATCH 177/333] feat: standardize resource-not-found error code (SEP-2164) (#899) * feat: implement SEP-2164 resource not found errors * test: update protocol version utility expectations * feat: gate not-found code at server boundary --------- Co-authored-by: Michael Neale --- conformance/src/bin/server.rs | 2 +- crates/rmcp/src/handler/server.rs | 17 +++- crates/rmcp/src/model.rs | 6 ++ crates/rmcp/src/service.rs | 10 ++ crates/rmcp/tests/test_custom_headers.rs | 4 +- .../tests/test_resource_not_found_version.rs | 91 +++++++++++++++++++ 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 crates/rmcp/tests/test_resource_not_found_version.rs diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 28a9f1d91..c3424f612 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -615,7 +615,7 @@ impl ServerHandler for ConformanceServer { } else { Err(ErrorData::resource_not_found( format!("Resource not found: {}", uri), - None, + Some(json!({ "uri": uri })), )) } } diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 78c3be2f2..e6c3cc823 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -22,7 +22,9 @@ impl Service for H { request: ::PeerReq, context: RequestContext, ) -> Result<::Resp, McpError> { - match request { + // `context` is moved into the dispatch below, so read the negotiated version first. + let protocol_version = context.protocol_version(); + let result = match request { ClientRequest::InitializeRequest(request) => self .initialize(request.params, context) .await @@ -127,7 +129,18 @@ impl Service for H { .cancel_task(request.params, context) .await .map(ServerResult::CancelTaskResult), - } + }; + // SEP-2164: peers negotiating 2026-07-28+ get the standard INVALID_PARAMS code for + // resource-not-found; older peers keep RESOURCE_NOT_FOUND. ISO `YYYY-MM-DD` versions + // compare lexically the same as chronologically. + let use_invalid_params = + protocol_version.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); + result.map_err(|mut error| { + if use_invalid_params && error.code == ErrorCode::RESOURCE_NOT_FOUND { + error.code = ErrorCode::INVALID_PARAMS; + } + error + }) } async fn handle_notification( diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 4aabab1d0..7ade8435f 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -152,6 +152,7 @@ impl std::fmt::Display for ProtocolVersion { } impl ProtocolVersion { + pub const V_2026_07_28: Self = Self(Cow::Borrowed("2026-07-28")); pub const V_2025_11_25: Self = Self(Cow::Borrowed("2025-11-25")); pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); @@ -164,6 +165,7 @@ impl ProtocolVersion { Self::V_2025_03_26, Self::V_2025_06_18, Self::V_2025_11_25, + Self::V_2026_07_28, ]; /// Returns the string representation of this protocol version. @@ -193,6 +195,7 @@ impl<'de> Deserialize<'de> for ProtocolVersion { "2025-03-26" => return Ok(ProtocolVersion::V_2025_03_26), "2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18), "2025-11-25" => return Ok(ProtocolVersion::V_2025_11_25), + "2026-07-28" => return Ok(ProtocolVersion::V_2026_07_28), _ => {} } Ok(ProtocolVersion(Cow::Owned(s))) @@ -541,9 +544,12 @@ impl ErrorData { data, } } + /// Resource-not-found error (`-32002`). The server upgrades this to `INVALID_PARAMS` + /// (`-32602`) for peers negotiating protocol `2026-07-28` or newer (SEP-2164). pub fn resource_not_found(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data) } + pub fn parse_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::PARSE_ERROR, message, data) } diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 08791e5e5..af7fbbfc1 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -672,6 +672,16 @@ impl RequestContext { } } +#[cfg(feature = "server")] +impl RequestContext { + /// The protocol version the client negotiated, or `None` before peer info is recorded. + pub fn protocol_version(&self) -> Option { + self.peer + .peer_info() + .map(|info| info.protocol_version.clone()) + } +} + /// Request execution context #[derive(Debug, Clone)] #[non_exhaustive] diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 9b9dfc058..736dce18e 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -866,16 +866,18 @@ async fn test_server_rejects_unsupported_protocol_version() { fn test_protocol_version_utilities() { use rmcp::model::ProtocolVersion; + assert_eq!(ProtocolVersion::V_2026_07_28.as_str(), "2026-07-28"); assert_eq!(ProtocolVersion::V_2025_11_25.as_str(), "2025-11-25"); assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18"); assert_eq!(ProtocolVersion::V_2025_03_26.as_str(), "2025-03-26"); assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05"); - assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 4); + assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 5); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_11_25)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28)); } /// Integration test: Verify server validates only the Host header for DNS rebinding protection diff --git a/crates/rmcp/tests/test_resource_not_found_version.rs b/crates/rmcp/tests/test_resource_not_found_version.rs new file mode 100644 index 000000000..255accb8c --- /dev/null +++ b/crates/rmcp/tests/test_resource_not_found_version.rs @@ -0,0 +1,91 @@ +//! SEP-2164: the resource-not-found error code follows the negotiated protocol version. +//! +//! `2026-07-28` and newer get the standard `INVALID_PARAMS` (-32602); older versions +//! keep the legacy `RESOURCE_NOT_FOUND` (-32002). +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt, + model::{ + ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams, + ReadResourceResult, + }, + service::RequestContext, +}; + +#[derive(Debug, Clone, Default)] +struct ResourceServer; + +impl ServerHandler for ResourceServer { + async fn read_resource( + &self, + _request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + Err(ErrorData::resource_not_found("resource not found", None)) + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + ResourceServer + .serve(server_transport) + .await? + .waiting() + .await?; + anyhow::Ok(()) + }); + + let client = VersionedClient { + protocol_version: client_version, + } + .serve(client_transport) + .await + .expect("client should connect"); + + let error = client + .read_resource(ReadResourceRequestParams::new("missing://resource")) + .await + .expect_err("missing resource should error"); + + let code = match error { + ServiceError::McpError(data) => data.code, + other => panic!("expected McpError, got: {other:?}"), + }; + + client.cancel().await.expect("client should cancel"); + server_handle.await.expect("server task").expect("server"); + code +} + +#[tokio::test] +async fn legacy_version_gets_resource_not_found_code() { + assert_eq!( + not_found_code(ProtocolVersion::V_2025_11_25).await, + ErrorCode::RESOURCE_NOT_FOUND, + ); +} + +#[tokio::test] +async fn sep_2164_version_gets_invalid_params_code() { + assert_eq!( + not_found_code(ProtocolVersion::V_2026_07_28).await, + ErrorCode::INVALID_PARAMS, + ); +} From 4b82e41522a70468888c26944717201285d08563 Mon Sep 17 00:00:00 2001 From: Greg Virgin Date: Tue, 16 Jun 2026 22:13:33 -0400 Subject: [PATCH 178/333] docs(server): document Err vs Ok(CallToolResult::error) visibility contract on ServerHandler::call_tool (#854) * docs(server): document Err vs Ok(CallToolResult::error) visibility contract The MCP spec separates two failure modes that surface very differently in clients: - Err(ErrorData) is a JSON-RPC protocol error. Most MCP clients render it opaquely ("Tool result missing due to internal error") - the caller does not see the message text. - Ok(CallToolResult::error(content)) is a tool-level error. Clients render the content; the caller reads the message. The right shape for "the tool didn't work" is the latter, but Err is what most handlers reach for because it looks like the natural Rust return value. This commit adds rustdoc on both ServerHandler::call_tool and CallToolResult::error pointing handlers at the correct shape, with a worked example showing protocol errors (-32602 invalid_params) vs tool errors (empty result, downstream failure). This is the docs half of the visibility-contract ask. A follow-up may introduce a typed ToolOutcome sum type to enforce the distinction at compile time; this PR is the lower-risk version that unblocks the class immediately. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: update crates/rmcp/src/handler/server.rs * docs: update crates/rmcp/src/model.rs --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp/src/handler/server.rs | 28 +++++++++++++++++ crates/rmcp/src/model.rs | 50 ++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index e6c3cc823..aea596703 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -269,6 +269,34 @@ macro_rules! server_handler_methods { McpError::method_not_found::(), )) } + /// Handle a `tools/call` request from a client. + /// + /// # Choosing a return value + /// + /// MCP distinguishes two failure modes; the API forces you to pick + /// the right one explicitly because they reach the caller's UI very + /// differently: + /// + /// - `Ok(`[`CallToolResult::error`]`(...))` — the tool ran (or tried + /// to) and produced a failure the caller should see. The + /// `content` you supply is rendered in the caller's MCP client, + /// so the user gets your message. **This is the right return + /// value for almost every "the tool didn't work" path** — empty + /// results, validation failures the user can fix, downstream + /// service unavailability, etc. + /// + /// - `Err(`[`McpError`]`)` — a JSON-RPC protocol error. Use this + /// only when the request itself is unroutable: unknown tool + /// ([`ErrorCode::METHOD_NOT_FOUND`]), malformed request shape that + /// cannot be treated as a valid `tools/call`, or a server-internal + /// failure that means the server cannot serve any request right now + /// ([`ErrorCode::INTERNAL_ERROR`], `-32603`). MCP clients + /// typically render protocol errors opaquely; **the caller will + /// not see your message** — they see something like "Tool result + /// missing due to internal error". If you want the caller to read + /// your error, use `Ok(CallToolResult::error(...))`. + /// + /// See [`CallToolResult::error`] for a worked example. fn call_tool( &self, request: CallToolRequestParams, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 7ade8435f..5fb7eb9ed 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2844,7 +2844,55 @@ impl CallToolResult { meta: None, } } - /// Create an error tool result with unstructured content + + /// Create a tool-level error result with caller-visible content. + /// + /// # When to use this vs `Err(ErrorData)` + /// + /// MCP distinguishes two failure modes for a `call_tool` invocation, and + /// the right one to use depends on **whose problem it is**: + /// + /// - **Tool-level error** — `Ok(CallToolResult::error(...))`. + /// The request was valid and routed to your tool, but executing the + /// tool failed in a way the caller should see (a query returned no + /// rows, an external API returned 500, the user's input is plausible + /// but produced no result, etc.). The caller's MCP client renders the + /// `content` you provide; your message reaches the user. **This is the + /// right choice for almost every "the tool ran and didn't work" case.** + /// + /// - **Protocol error** — `Err(ErrorData)` with a JSON-RPC code. + /// The server cannot route the request at all, or an infrastructure + /// error makes the server itself unusable + /// ([`ErrorCode::INTERNAL_ERROR`], `-32603`). MCP clients typically + /// render protocol errors opaquely (e.g. "Tool result missing due to + /// internal error") — the caller does **not** see your message. + /// + /// # Example + /// + /// ```rust,ignore + /// use rmcp::model::{CallToolResult, Content, ErrorData}; + /// + /// async fn lookup(query: &str) -> Result { + /// // Caller passed a malformed query — the server can't run anything. + /// // This is a protocol error, the caller's client will render it + /// // as -32602 invalid_params: + /// if query.is_empty() { + /// return Err(ErrorData::invalid_params("query must be non-empty", None)); + /// } + /// + /// // Tool ran, no result. Caller should see the explanation: + /// let rows = run_query(query).await; + /// if rows.is_empty() { + /// return Ok(CallToolResult::error(vec![Content::text( + /// format!("no rows matched '{query}'"), + /// )])); + /// } + /// + /// Ok(CallToolResult::success(vec![Content::text(format_rows(&rows))])) + /// } + /// # async fn run_query(_: &str) -> Vec<&'static str> { vec![] } + /// # fn format_rows(_: &[&str]) -> String { String::new() } + /// ``` pub fn error(content: Vec) -> Self { CallToolResult { content, From 5d00e20f2a44bd6697cdd11954dcdbc62861da7c Mon Sep 17 00:00:00 2001 From: ContextVM-org Date: Wed, 17 Jun 2026 21:07:37 +0200 Subject: [PATCH 179/333] Add progress-aware request timeout reset (#858) * feat: add progress-aware request timeouts * Update crates/rmcp/src/service.rs Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> * refactor(rmcp): move helpers and simplify response waiting --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp/Cargo.toml | 5 + crates/rmcp/src/service.rs | 250 ++++++++++++++++-- crates/rmcp/src/service/server.rs | 4 + .../tests/test_request_timeout_progress.rs | 203 ++++++++++++++ 4 files changed, 441 insertions(+), 21 deletions(-) create mode 100644 crates/rmcp/tests/test_request_timeout_progress.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 8a5bd63a3..638679812 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -252,6 +252,11 @@ name = "test_progress_subscriber" required-features = ["server", "client", "macros"] path = "tests/test_progress_subscriber.rs" +[[test]] +name = "test_request_timeout_progress" +required-features = ["server", "client", "macros"] +path = "tests/test_request_timeout_progress.rs" + [[test]] name = "test_elicitation" required-features = ["elicitation", "client", "server"] diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index af7fbbfc1..9bc0fc979 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -42,8 +42,12 @@ pub(crate) type MaybeBoxFuture<'a, T> = BoxFuture<'a, T>; #[cfg(feature = "local")] pub(crate) type MaybeBoxFuture<'a, T> = LocalBoxFuture<'a, T>; +#[cfg(feature = "server")] +use crate::model::ClientNotification; #[cfg(feature = "server")] use crate::model::ServerJsonRpcMessage; +#[cfg(feature = "client")] +use crate::model::ServerNotification; use crate::{ error::ErrorData as McpError, model::{ @@ -299,7 +303,37 @@ impl ProgressTokenProvider for AtomicU32Provider { } } +#[doc(hidden)] +pub trait ProgressNotificationToken { + fn progress_token(&self) -> Option<&ProgressToken>; +} + +#[cfg(feature = "server")] +impl ProgressNotificationToken for ClientNotification { + fn progress_token(&self) -> Option<&ProgressToken> { + match self { + ClientNotification::ProgressNotification(notification) => { + Some(¬ification.params.progress_token) + } + _ => None, + } + } +} + +#[cfg(feature = "client")] +impl ProgressNotificationToken for ServerNotification { + fn progress_token(&self) -> Option<&ProgressToken> { + match self { + ServerNotification::ProgressNotification(notification) => { + Some(¬ification.params.progress_token) + } + _ => None, + } + } +} + type Responder = tokio::sync::oneshot::Sender; +type ProgressTimeoutWatchers = Arc>>>; /// A handle to a remote request /// @@ -314,40 +348,126 @@ pub struct RequestHandle { pub peer: Peer, pub id: RequestId, pub progress_token: ProgressToken, + progress_timeout_watchers: ProgressTimeoutWatchers, + progress_reset_rx: Option>, } impl RequestHandle { pub const REQUEST_TIMEOUT_REASON: &str = "request timeout"; - pub async fn await_response(self) -> Result { - if let Some(timeout) = self.options.timeout { - let timeout_result = tokio::time::timeout(timeout, async move { - self.rx.await.map_err(|_e| ServiceError::TransportClosed)? - }) - .await; - match timeout_result { - Ok(response) => response, + pub const REQUEST_MAX_TOTAL_TIMEOUT_REASON: &str = "maximum total timeout exceeded"; + + pub async fn await_response(mut self) -> Result { + let timeout = self.options.timeout; + let max_total_timeout = self.options.max_total_timeout; + let reset_timeout_on_progress = self.options.reset_timeout_on_progress; + + let has_progress_reset_rx = self.progress_reset_rx.is_some(); + let progress_token = self.progress_token.clone(); + + let result = match (timeout, max_total_timeout, reset_timeout_on_progress) { + (Some(timeout), None, false) => match tokio::time::timeout(timeout, &mut self.rx).await + { + Ok(response) => response.map_err(|_e| ServiceError::TransportClosed)?, Err(_) => { let error = Err(ServiceError::Timeout { timeout }); // cancel this request - let notification = CancelledNotification { - params: CancelledNotificationParam { - request_id: self.id, - reason: Some(Self::REQUEST_TIMEOUT_REASON.to_owned()), - }, - method: crate::model::CancelledNotificationMethod, - extensions: Default::default(), - }; - let _ = self.peer.send_notification(notification.into()).await; + self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON) + .await; error } + }, + (None, None, _) => (&mut self.rx) + .await + .map_err(|_e| ServiceError::TransportClosed)?, + _ => { + self.await_response_with_progress_timeout( + timeout, + max_total_timeout, + reset_timeout_on_progress, + ) + .await + } + }; + + Self::cleanup_progress_timeout_watcher( + &self.peer.progress_timeout_watchers, + &progress_token, + has_progress_reset_rx, + ) + .await; + result + } + + async fn send_timeout_cancel_notification(&self, reason: &str) { + let notification = CancelledNotification { + params: CancelledNotificationParam { + request_id: self.id.clone(), + reason: Some(reason.to_owned()), + }, + method: crate::model::CancelledNotificationMethod, + extensions: Default::default(), + }; + let _ = self.peer.send_notification(notification.into()).await; + } + + async fn await_response_with_progress_timeout( + &mut self, + timeout: Option, + max_total_timeout: Option, + reset_timeout_on_progress: bool, + ) -> Result { + let mut idle_sleep = timeout.map(tokio::time::sleep).map(Box::pin); + let mut max_total_sleep = max_total_timeout.map(tokio::time::sleep).map(Box::pin); + + loop { + tokio::select! { + biased; + + response = &mut self.rx => { + return response.map_err(|_e| ServiceError::TransportClosed)?; + } + _ = async { + if let Some(sleep) = idle_sleep.as_mut() { + sleep.as_mut().await; + } + }, if idle_sleep.is_some() => { + let timeout = timeout.expect("idle timeout exists when idle sleep exists"); + self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON).await; + return Err(ServiceError::Timeout { timeout }); + } + _ = async { + if let Some(sleep) = max_total_sleep.as_mut() { + sleep.as_mut().await; + } + }, if max_total_sleep.is_some() => { + let timeout = max_total_timeout.expect("max total timeout exists when max total sleep exists"); + self.send_timeout_cancel_notification(Self::REQUEST_MAX_TOTAL_TIMEOUT_REASON).await; + return Err(ServiceError::Timeout { timeout }); + } + progress = async { + match self.progress_reset_rx.as_mut() { + Some(rx) => rx.recv().await, + None => None, + } + }, if reset_timeout_on_progress && timeout.is_some() && self.progress_reset_rx.is_some() => { + if progress.is_some() { + if let (Some(timeout), Some(sleep)) = (timeout, idle_sleep.as_mut()) { + sleep.as_mut().reset(tokio::time::Instant::now() + timeout); + } + } + } } - } else { - self.rx.await.map_err(|_e| ServiceError::TransportClosed)? } } /// Cancel this request pub async fn cancel(self, reason: Option) -> Result<(), ServiceError> { + Self::cleanup_progress_timeout_watcher( + &self.progress_timeout_watchers, + &self.progress_token, + self.progress_reset_rx.is_some(), + ) + .await; let notification = CancelledNotification { params: CancelledNotificationParam { request_id: self.id, @@ -359,6 +479,19 @@ impl RequestHandle { self.peer.send_notification(notification.into()).await?; Ok(()) } + + async fn cleanup_progress_timeout_watcher( + progress_timeout_watchers: &ProgressTimeoutWatchers, + progress_token: &ProgressToken, + has_progress_reset_rx: bool, + ) { + if has_progress_reset_rx { + progress_timeout_watchers + .write() + .await + .remove(progress_token); + } + } } #[derive(Debug)] @@ -384,6 +517,7 @@ pub struct Peer { tx: mpsc::Sender>, request_id_provider: Arc, progress_token_provider: Arc, + progress_timeout_watchers: ProgressTimeoutWatchers, info: Arc>>>, } @@ -403,12 +537,33 @@ type ProxyOutbound = mpsc::Receiver>; pub struct PeerRequestOptions { pub timeout: Option, pub meta: Option, + /// Reset the request timeout when a matching progress notification is received. + pub reset_timeout_on_progress: bool, + /// Maximum total time to wait for the request, regardless of progress notifications. + pub max_total_timeout: Option, } impl PeerRequestOptions { pub fn no_options() -> Self { Self::default() } + + pub fn with_timeout(timeout: Duration) -> Self { + Self { + timeout: Some(timeout), + ..Self::default() + } + } + + pub fn reset_timeout_on_progress(mut self) -> Self { + self.reset_timeout_on_progress = true; + self + } + + pub fn with_max_total_timeout(mut self, timeout: Duration) -> Self { + self.max_total_timeout = Some(timeout); + self + } } impl Peer { @@ -423,6 +578,7 @@ impl Peer { tx, request_id_provider, progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()), + progress_timeout_watchers: Default::default(), info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), }, rx, @@ -468,22 +624,68 @@ impl Peer { request.get_meta_mut().extend(meta); } let (responder, receiver) = tokio::sync::oneshot::channel(); - self.tx + let progress_reset_rx = if options.reset_timeout_on_progress && options.timeout.is_some() { + let (sender, receiver) = mpsc::channel(1); + self.progress_timeout_watchers + .write() + .await + .insert(progress_token.clone(), sender); + Some(receiver) + } else { + None + }; + if self + .tx .send(PeerSinkMessage::Request { request, id: id.clone(), responder, }) .await - .map_err(|_m| ServiceError::TransportClosed)?; + .is_err() + { + if progress_reset_rx.is_some() { + self.progress_timeout_watchers + .write() + .await + .remove(&progress_token); + } + return Err(ServiceError::TransportClosed); + } Ok(RequestHandle { id, rx: receiver, progress_token, options, peer: self.clone(), + progress_timeout_watchers: self.progress_timeout_watchers.clone(), + progress_reset_rx, }) } + + async fn notify_progress_timeout_watcher(&self, progress_token: &ProgressToken) { + let sender = self + .progress_timeout_watchers + .read() + .await + .get(progress_token) + .cloned(); + if let Some(sender) = sender { + match sender.try_send(()) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + tracing::trace!(?progress_token, "progress timeout watcher channel is full"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + self.progress_timeout_watchers + .write() + .await + .remove(progress_token); + } + } + } + } + /// Snapshot of the peer's handshake info. pub fn peer_info(&self) -> Option> { self.info.read().expect("peer info lock poisoned").clone() @@ -700,6 +902,7 @@ pub fn serve_directly( ) -> RunningService where R: ServiceRole, + R::PeerNot: ProgressNotificationToken, S: Service, T: IntoTransport, E: std::error::Error + Send + Sync + 'static, @@ -716,6 +919,7 @@ pub fn serve_directly_with_ct( ) -> RunningService where R: ServiceRole, + R::PeerNot: ProgressNotificationToken, S: Service, T: IntoTransport, E: std::error::Error + Send + Sync + 'static, @@ -756,6 +960,7 @@ fn serve_inner( ) -> RunningService where R: ServiceRole, + R::PeerNot: ProgressNotificationToken, S: Service, T: Transport + 'static, { @@ -1002,6 +1207,9 @@ where } Err(notification) => notification, }; + if let Some(progress_token) = notification.progress_token() { + peer.notify_progress_timeout_watcher(progress_token).await; + } { let service = shared_service.clone(); let mut extensions = Extensions::new(); diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 173fdb428..aa51e4704 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -362,6 +362,8 @@ macro_rules! method { let options = crate::service::PeerRequestOptions { timeout, meta: None, + reset_timeout_on_progress: false, + max_total_timeout: None, }; let result = self .send_request_with_option(request, options) @@ -390,6 +392,8 @@ macro_rules! method { let options = crate::service::PeerRequestOptions { timeout, meta: None, + reset_timeout_on_progress: false, + max_total_timeout: None, }; let result = self .send_request_with_option(request, options) diff --git a/crates/rmcp/tests/test_request_timeout_progress.rs b/crates/rmcp/tests/test_request_timeout_progress.rs new file mode 100644 index 000000000..ff3f5369a --- /dev/null +++ b/crates/rmcp/tests/test_request_timeout_progress.rs @@ -0,0 +1,203 @@ +#![cfg(not(feature = "local"))] + +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use rmcp::{ + ClientHandler, Peer, RoleServer, ServiceError, ServiceExt, + model::{CallToolRequestParams, ClientRequest, Meta, ProgressNotificationParam, Request}, + service::PeerRequestOptions, + tool, tool_router, +}; + +#[derive(Clone, Default)] +struct ProgressCountingClient { + progress_count: Arc, +} + +impl ClientHandler for ProgressCountingClient { + async fn on_progress( + &self, + _params: ProgressNotificationParam, + _context: rmcp::service::NotificationContext, + ) { + self.progress_count.fetch_add(1, Ordering::SeqCst); + } +} + +struct ProgressTimeoutServer; + +impl ProgressTimeoutServer { + fn new() -> Self { + Self + } +} + +#[tool_router(server_handler)] +impl ProgressTimeoutServer { + #[tool] + async fn delayed_without_progress(&self) -> Result<(), rmcp::ErrorData> { + tokio::time::sleep(Duration::from_millis(250)).await; + Ok(()) + } + + #[tool] + async fn delayed_with_progress( + &self, + meta: Meta, + client: Peer, + ) -> Result<(), rmcp::ErrorData> { + let progress_token = meta + .get_progress_token() + .ok_or(rmcp::ErrorData::invalid_params( + "Progress token is required", + None, + ))?; + + for step in 0..4 { + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = client + .notify_progress(ProgressNotificationParam { + progress_token: progress_token.clone(), + progress: step as f64, + total: Some(4.0), + message: Some("working".into()), + }) + .await; + } + + Ok(()) + } + + #[tool] + async fn delayed_with_unrelated_progress( + &self, + client: Peer, + ) -> Result<(), rmcp::ErrorData> { + for step in 0..4 { + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = client + .notify_progress(ProgressNotificationParam { + progress_token: rmcp::model::ProgressToken( + rmcp::model::NumberOrString::Number(999_999), + ), + progress: step as f64, + total: Some(4.0), + message: Some("unrelated".into()), + }) + .await; + } + + Ok(()) + } +} + +async fn start_pair() +-> anyhow::Result> { + let server = ProgressTimeoutServer::new(); + let client = ProgressCountingClient::default(); + let (transport_server, transport_client) = tokio::io::duplex(4096); + + tokio::spawn(async move { + let service = server.serve(transport_server).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + Ok(client.serve(transport_client).await?) +} + +async fn call_tool_with_options( + client: &rmcp::service::RunningService, + name: &str, + options: PeerRequestOptions, +) -> Result { + client + .send_request_with_option( + ClientRequest::CallToolRequest(Request::new(CallToolRequestParams::new( + name.to_owned(), + ))), + options, + ) + .await? + .await_response() + .await +} + +#[tokio::test] +async fn request_timeout_still_expires_without_progress() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_without_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)), + ) + .await; + + assert!(matches!(result, Err(ServiceError::Timeout { .. }))); + Ok(()) +} + +#[tokio::test] +async fn progress_does_not_reset_timeout_by_default() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_with_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)), + ) + .await; + + assert!(matches!(result, Err(ServiceError::Timeout { .. }))); + Ok(()) +} + +#[tokio::test] +async fn matching_progress_resets_timeout_when_enabled() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_with_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(), + ) + .await; + + assert!(result.is_ok()); + assert!(client.service().progress_count.load(Ordering::SeqCst) > 0); + Ok(()) +} + +#[tokio::test] +async fn max_total_timeout_wins_over_progress_reset() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_with_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)) + .reset_timeout_on_progress() + .with_max_total_timeout(Duration::from_millis(125)), + ) + .await; + + assert!(matches!(result, Err(ServiceError::Timeout { .. }))); + Ok(()) +} + +#[tokio::test] +async fn unrelated_progress_does_not_reset_timeout() -> anyhow::Result<()> { + let client = start_pair().await?; + let result = call_tool_with_options( + &client, + "delayed_with_unrelated_progress", + PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(), + ) + .await; + + assert!(matches!(result, Err(ServiceError::Timeout { .. }))); + Ok(()) +} From bf71eb8b09dea9f808f8cf418d363ac56c7616c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 06:59:25 -0400 Subject: [PATCH 180/333] chore(deps): bump actions/checkout from 6 to 7 (#911) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 24 ++++++++++++------------ .github/workflows/codeql.yml | 2 +- .github/workflows/release-plz.yml | 4 ++-- .github/workflows/triage.yml | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba1eddb61..c49bea004 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -39,7 +39,7 @@ jobs: name: Code Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Rust fmt run: rustup toolchain install nightly --component rustfmt @@ -51,7 +51,7 @@ jobs: name: Lint with Clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -66,7 +66,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -106,7 +106,7 @@ jobs: name: spell check with typos runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Spell Check Repo uses: crate-ci/typos@master @@ -114,7 +114,7 @@ jobs: name: Run Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # install nodejs - name: Setup Node.js @@ -143,7 +143,7 @@ jobs: name: Run Tests (no local feature) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # install nodejs - name: Setup Node.js @@ -179,7 +179,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # install nodejs - name: Setup Node.js @@ -214,7 +214,7 @@ jobs: name: Example test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 # install nodejs - name: Setup Node.js @@ -270,7 +270,7 @@ jobs: name: Security Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -287,7 +287,7 @@ jobs: name: Generate Documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@nightly @@ -313,7 +313,7 @@ jobs: # This happened recently in the attack on `tj-actions/changed-files`, but # has happened many times before as well. - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Update Rust run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6182e8fd7..0ae4e6360 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,7 +21,7 @@ jobs: language: [rust, javascript-typescript, python, actions] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index cb92ee8ca..04f51190e 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -20,7 +20,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install Rust toolchain @@ -51,7 +51,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install Rust toolchain diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index c5043bd80..d0d9186a5 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -39,7 +39,7 @@ jobs: TRIAGE_MODEL: ${{ vars.TRIAGE_MODEL || 'gpt-4o-mini' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install jq run: sudo apt-get install -y jq From 4fd4986b6204e7bf580b23592a99bc338e2899e5 Mon Sep 17 00:00:00 2001 From: Abdoul <64937934+abdouloued@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:55:51 -0700 Subject: [PATCH 181/333] fix(elicitation): preserve enumNames through ElicitationSchema serde round-trip (#905) * fix(elicitation): preserve enumNames through ElicitationSchema serde round-trip UntitledSingleSelectEnumSchema lacked deny_unknown_fields, so a legacy enum payload containing enumNames was silently matched by that variant (ignoring the field) rather than falling through to LegacyEnumSchema. The enumNames array was lost on re-serialization. Add deny_unknown_fields to UntitledSingleSelectEnumSchema so that any unknown field (including enumNames) causes serde to try the next untagged variant, reaching LegacyEnumSchema correctly. Also add skip_serializing_if = "Option::is_none" to LegacyEnumSchema::enum_names so that an untitled legacy enum without enumNames does not serialize "enumNames": null. Fixes #903 * test(elicitation): regenerate server schema snapshot for deny_unknown_fields deny_unknown_fields on UntitledSingleSelectEnumSchema makes schemars emit additionalProperties: false for that definition. Regenerate the golden schema fixtures to match (UPDATE_SCHEMA=1 cargo test -p rmcp --test test_message_schema --all-features). --- crates/rmcp/src/model/elicitation_schema.rs | 42 ++++++++++++++++++- .../server_json_rpc_message_schema.json | 1 + ...erver_json_rpc_message_schema_current.json | 1 + 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 73ab62257..0e8244c46 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -544,12 +544,13 @@ pub struct LegacyEnumSchema { pub description: Option>, #[serde(rename = "enum")] pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] pub enum_names: Option>, } /// Untitled single-select #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct UntitledSingleSelectEnumSchema { @@ -1742,6 +1743,45 @@ mod tests { Ok(()) } + #[test] + fn test_legacy_enum_schema_roundtrip_preserves_enum_names() -> anyhow::Result<()> { + // Regression test for: legacy enum payload with `enumNames` was silently + // deserialized as `UntitledSingleSelectEnumSchema` (which has no `enumNames` + // field), causing the array to be dropped on re-serialization. + let input = serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "type": "string", + "enum": ["opt1", "opt2", "opt3"], + "enumNames": ["Option One", "Option Two", "Option Three"] + } + } + }); + let schema: ElicitationSchema = serde_json::from_value(input.clone())?; + let output = serde_json::to_value(&schema)?; + assert_eq!( + output["properties"]["choice"]["enumNames"], + serde_json::json!(["Option One", "Option Two", "Option Three"]), + ); + Ok(()) + } + + #[test] + fn test_legacy_enum_schema_no_enum_names_omits_field() -> anyhow::Result<()> { + // `LegacyEnumSchema` with `enum_names: None` must not serialize `"enumNames": null`. + let schema = EnumSchema::Legacy(LegacyEnumSchema { + type_: StringTypeConst, + title: None, + description: None, + enum_: vec!["a".to_string(), "b".to_string()], + enum_names: None, + }); + let json = serde_json::to_value(&schema)?; + assert!(!json.as_object().unwrap().contains_key("enumNames")); + Ok(()) + } + #[test] fn test_enum_schema_titled_multi_select_serialization() -> anyhow::Result<()> { let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index c1c6d1b2c..6de03e31f 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -3642,6 +3642,7 @@ "$ref": "#/definitions/StringTypeConst" } }, + "additionalProperties": false, "required": [ "type", "enum" diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index c1c6d1b2c..6de03e31f 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -3642,6 +3642,7 @@ "$ref": "#/definitions/StringTypeConst" } }, + "additionalProperties": false, "required": [ "type", "enum" From 443677ca313f84e8a64d368de9f6dd230ba2041f Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:59:32 -0400 Subject: [PATCH 182/333] fix: align progress timeout token (#909) --- crates/rmcp/src/service.rs | 40 ++++++++++--------- .../tests/test_request_timeout_progress.rs | 32 ++++++++++----- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 9bc0fc979..70045d115 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -348,7 +348,6 @@ pub struct RequestHandle { pub peer: Peer, pub id: RequestId, pub progress_token: ProgressToken, - progress_timeout_watchers: ProgressTimeoutWatchers, progress_reset_rx: Option>, } @@ -416,8 +415,10 @@ impl RequestHandle { max_total_timeout: Option, reset_timeout_on_progress: bool, ) -> Result { - let mut idle_sleep = timeout.map(tokio::time::sleep).map(Box::pin); - let mut max_total_sleep = max_total_timeout.map(tokio::time::sleep).map(Box::pin); + let mut idle_sleep = + timeout.map(|timeout| (timeout, Box::pin(tokio::time::sleep(timeout)))); + let mut max_total_sleep = + max_total_timeout.map(|timeout| (timeout, Box::pin(tokio::time::sleep(timeout)))); loop { tokio::select! { @@ -427,32 +428,34 @@ impl RequestHandle { return response.map_err(|_e| ServiceError::TransportClosed)?; } _ = async { - if let Some(sleep) = idle_sleep.as_mut() { + if let Some((_, sleep)) = idle_sleep.as_mut() { sleep.as_mut().await; } }, if idle_sleep.is_some() => { - let timeout = timeout.expect("idle timeout exists when idle sleep exists"); - self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON).await; - return Err(ServiceError::Timeout { timeout }); + if let Some((timeout, _)) = idle_sleep.as_ref() { + self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON).await; + return Err(ServiceError::Timeout { timeout: *timeout }); + } } _ = async { - if let Some(sleep) = max_total_sleep.as_mut() { + if let Some((_, sleep)) = max_total_sleep.as_mut() { sleep.as_mut().await; } }, if max_total_sleep.is_some() => { - let timeout = max_total_timeout.expect("max total timeout exists when max total sleep exists"); - self.send_timeout_cancel_notification(Self::REQUEST_MAX_TOTAL_TIMEOUT_REASON).await; - return Err(ServiceError::Timeout { timeout }); + if let Some((timeout, _)) = max_total_sleep.as_ref() { + self.send_timeout_cancel_notification(Self::REQUEST_MAX_TOTAL_TIMEOUT_REASON).await; + return Err(ServiceError::Timeout { timeout: *timeout }); + } } progress = async { match self.progress_reset_rx.as_mut() { Some(rx) => rx.recv().await, None => None, } - }, if reset_timeout_on_progress && timeout.is_some() && self.progress_reset_rx.is_some() => { + }, if reset_timeout_on_progress && idle_sleep.is_some() && self.progress_reset_rx.is_some() => { if progress.is_some() { - if let (Some(timeout), Some(sleep)) = (timeout, idle_sleep.as_mut()) { - sleep.as_mut().reset(tokio::time::Instant::now() + timeout); + if let Some((timeout, sleep)) = idle_sleep.as_mut() { + sleep.as_mut().reset(tokio::time::Instant::now() + *timeout); } } } @@ -463,7 +466,7 @@ impl RequestHandle { /// Cancel this request pub async fn cancel(self, reason: Option) -> Result<(), ServiceError> { Self::cleanup_progress_timeout_watcher( - &self.progress_timeout_watchers, + &self.peer.progress_timeout_watchers, &self.progress_token, self.progress_reset_rx.is_some(), ) @@ -617,12 +620,12 @@ impl Peer { ) -> Result, ServiceError> { let id = self.request_id_provider.next_request_id(); let progress_token = self.progress_token_provider.next_progress_token(); - request - .get_meta_mut() - .set_progress_token(progress_token.clone()); if let Some(meta) = options.meta.clone() { request.get_meta_mut().extend(meta); } + request + .get_meta_mut() + .set_progress_token(progress_token.clone()); let (responder, receiver) = tokio::sync::oneshot::channel(); let progress_reset_rx = if options.reset_timeout_on_progress && options.timeout.is_some() { let (sender, receiver) = mpsc::channel(1); @@ -658,7 +661,6 @@ impl Peer { progress_token, options, peer: self.clone(), - progress_timeout_watchers: self.progress_timeout_watchers.clone(), progress_reset_rx, }) } diff --git a/crates/rmcp/tests/test_request_timeout_progress.rs b/crates/rmcp/tests/test_request_timeout_progress.rs index ff3f5369a..af62a466b 100644 --- a/crates/rmcp/tests/test_request_timeout_progress.rs +++ b/crates/rmcp/tests/test_request_timeout_progress.rs @@ -10,7 +10,10 @@ use std::{ use rmcp::{ ClientHandler, Peer, RoleServer, ServiceError, ServiceExt, - model::{CallToolRequestParams, ClientRequest, Meta, ProgressNotificationParam, Request}, + model::{ + CallToolRequestParams, ClientRequest, Meta, NumberOrString, ProgressNotificationParam, + ProgressToken, Request, + }, service::PeerRequestOptions, tool, tool_router, }; @@ -32,12 +35,6 @@ impl ClientHandler for ProgressCountingClient { struct ProgressTimeoutServer; -impl ProgressTimeoutServer { - fn new() -> Self { - Self - } -} - #[tool_router(server_handler)] impl ProgressTimeoutServer { #[tool] @@ -83,9 +80,7 @@ impl ProgressTimeoutServer { tokio::time::sleep(Duration::from_millis(50)).await; let _ = client .notify_progress(ProgressNotificationParam { - progress_token: rmcp::model::ProgressToken( - rmcp::model::NumberOrString::Number(999_999), - ), + progress_token: ProgressToken(NumberOrString::Number(999_999)), progress: step as f64, total: Some(4.0), message: Some("unrelated".into()), @@ -99,7 +94,7 @@ impl ProgressTimeoutServer { async fn start_pair() -> anyhow::Result> { - let server = ProgressTimeoutServer::new(); + let server = ProgressTimeoutServer; let client = ProgressCountingClient::default(); let (transport_server, transport_client) = tokio::io::duplex(4096); @@ -172,6 +167,21 @@ async fn matching_progress_resets_timeout_when_enabled() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn generated_progress_token_overrides_option_meta_token() -> anyhow::Result<()> { + let client = start_pair().await?; + let mut options = + PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(); + options.meta = Some(Meta::with_progress_token(ProgressToken( + NumberOrString::Number(999_999), + ))); + + let result = call_tool_with_options(&client, "delayed_with_progress", options).await; + + assert!(result.is_ok()); + Ok(()) +} + #[tokio::test] async fn max_total_timeout_wins_over_progress_reset() -> anyhow::Result<()> { let client = start_pair().await?; From 3c5ce2b0d78a5dee13457076c72971213829e670 Mon Sep 17 00:00:00 2001 From: King Star Date: Mon, 22 Jun 2026 23:34:18 +0800 Subject: [PATCH 183/333] fix(auth): align OAuth metadata discovery ordering (#887) --- crates/rmcp/src/transport/auth.rs | 2 +- crates/rmcp/tests/test_client_credentials.rs | 46 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 37abfc37a..4f055b015 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1508,7 +1508,7 @@ impl AuthorizationManager { push_candidate("/.well-known/oauth-authorization-server".to_string()); push_candidate("/.well-known/openid-configuration".to_string()); } else { - // Path components present: follow spec priority order + // Path components present: prefer OAuth discovery before OpenID Connect fallbacks. // 1. OAuth 2.0 with path insertion push_candidate(format!("/.well-known/oauth-authorization-server/{trimmed}")); // 2. OpenID Connect with path insertion diff --git a/crates/rmcp/tests/test_client_credentials.rs b/crates/rmcp/tests/test_client_credentials.rs index a90b8b0e6..b2698d1b3 100644 --- a/crates/rmcp/tests/test_client_credentials.rs +++ b/crates/rmcp/tests/test_client_credentials.rs @@ -136,6 +136,25 @@ async fn start_mock_server() -> (String, SocketAddr) { (base_url, addr) } +async fn start_path_insert_metadata_server() -> (String, SocketAddr) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + + let app = Router::new() + .route( + "/.well-known/oauth-authorization-server/mcp", + get(auth_server_metadata_handler), + ) + .route("/token", post(token_handler)); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (base_url, addr) +} + #[tokio::test] async fn test_client_credentials_flow_client_secret() { let (base_url, _addr) = start_mock_server().await; @@ -162,6 +181,33 @@ async fn test_client_credentials_flow_client_secret() { assert_eq!(token, "m2m-access-token-12345"); } +#[tokio::test] +async fn test_client_credentials_discovers_path_inserted_oauth_metadata() { + let (base_url, _addr) = start_path_insert_metadata_server().await; + let resource_url = format!("{base_url}/mcp"); + + let mut oauth_state = OAuthState::new(&resource_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "test-m2m-client".to_string(), + client_secret: "test-m2m-secret".to_string(), + scopes: vec!["read".to_string()], + resource: Some(resource_url), + }; + + oauth_state + .authenticate_client_credentials(config) + .await + .unwrap(); + + let manager = oauth_state + .into_authorization_manager() + .expect("Should be in Authorized state"); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "m2m-access-token-12345"); +} + #[tokio::test] async fn test_client_credentials_invalid_secret() { let (base_url, _addr) = start_mock_server().await; From de898dd842016b84ba615070bd02499c214f94d7 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 18:24:32 +0100 Subject: [PATCH 184/333] Allow custom HTTP clients for OAuth (#908) * feat: allow custom HTTP clients for OAuth * fix(auth): preserve configured client for refresh * fix(auth): harden OAuth HTTP adapter * refactor(auth): simplify OAuth HTTP plumbing * fix(auth): stop refresh token redirects by default * fix(auth): re-export OAuth HTTP client types --- crates/rmcp/src/transport.rs | 5 +- crates/rmcp/src/transport/auth.rs | 701 ++++++++++++++++++++++++------ 2 files changed, 579 insertions(+), 127 deletions(-) diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 89568b3dd..8cc48aa41 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -101,8 +101,9 @@ pub use auth::JwtSigningAlgorithm; pub use auth::{ AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, ClientCredentialsConfig, CredentialStore, EXTENSION_OAUTH_CLIENT_CREDENTIALS, - InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore, - StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams, + InMemoryCredentialStore, InMemoryStateStore, OAuthHttpClient, OAuthHttpClientError, + OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, + StateStore, StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams, }; // #[cfg(feature = "transport-ws")] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 4f055b015..5ccc9bc1f 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1,19 +1,22 @@ use std::{ collections::HashMap, + future::Future, + pin::Pin, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; use async_trait::async_trait; +use futures::StreamExt; use oauth2::{ AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, - EmptyExtraTokenFields, ExtraTokenFields, HttpClientError, HttpRequest, HttpResponse, - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, - StandardTokenResponse, TokenResponse, TokenUrl, basic::BasicTokenType, + EmptyExtraTokenFields, ExtraTokenFields, HttpRequest, HttpResponse, PkceCodeChallenge, + PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, + TokenResponse, TokenUrl, basic::BasicTokenType, }; use reqwest::{ - Client as HttpClient, IntoUrl, StatusCode, Url, - header::{AUTHORIZATION, WWW_AUTHENTICATE}, + Client as ReqwestClient, IntoUrl, StatusCode, Url, + header::{AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -23,39 +26,152 @@ use tracing::{debug, warn}; use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; -/// Owned wrapper around [`reqwest::Client`] that implements [`AsyncHttpClient`] for oauth2. -struct OAuthReqwestClient(HttpClient); +const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024; -impl<'c> AsyncHttpClient<'c> for OAuthReqwestClient { - type Error = HttpClientError; +/// Redirect handling requested for an outbound OAuth HTTP operation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum OAuthHttpRedirectPolicy { + /// Follow redirects using the client's normal limits. + #[default] + Follow, + /// Return the redirect response without following its location. + Stop, +} - type Future = std::pin::Pin< - Box> + Send + Sync + 'c>, - >; +/// A complete outbound HTTP operation requested by the OAuth implementation. +#[non_exhaustive] +pub struct OAuthHttpRequest { + /// HTTP request with an absolute URI and buffered body. + pub request: HttpRequest, + /// Redirect behavior required by the OAuth operation. + pub redirect_policy: OAuthHttpRedirectPolicy, + /// Suggested maximum duration for the operation, or no SDK-specified timeout. + /// Implementations with their own timeout policy may retain it instead. + pub timeout: Option, +} - fn call(&'c self, request: HttpRequest) -> Self::Future { +impl OAuthHttpRequest { + fn new(request: HttpRequest, redirect_policy: OAuthHttpRedirectPolicy) -> Self { + Self { + request, + redirect_policy, + timeout: Some(DEFAULT_HTTP_TIMEOUT), + } + } +} + +/// Error returned by a custom OAuth HTTP client. +#[derive(Debug, Error)] +#[error("{message}")] +pub struct OAuthHttpClientError { + message: String, +} + +impl OAuthHttpClientError { + /// Create an error from a transport-provided message. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +/// Future returned by [`OAuthHttpClient::execute`]. +pub type OAuthHttpClientFuture<'a> = + Pin> + Send + 'a>>; + +/// Executes every outbound HTTP request made by the OAuth state machine. +/// +/// Implementations may route requests through a remote execution environment. +/// They must honor the request's redirect policy and return the raw response +/// status, headers, and body. +pub trait OAuthHttpClient: Send + Sync { + /// Execute one OAuth HTTP operation. + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_>; +} + +struct ReqwestOAuthHttpClient { + follow_redirects: ReqwestClient, + stop_redirects: ReqwestClient, +} + +impl ReqwestOAuthHttpClient { + fn new(follow_redirects: ReqwestClient) -> Result { + let stop_redirects = ReqwestClient::builder() + .timeout(DEFAULT_HTTP_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| AuthError::InternalError(error.to_string()))?; + Ok(Self { + follow_redirects, + stop_redirects, + }) + } +} + +impl OAuthHttpClient for ReqwestOAuthHttpClient { + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { Box::pin(async move { - let response = self - .0 - .execute(request.try_into().map_err(Box::new)?) + let OAuthHttpRequest { + request, + redirect_policy, + .. + } = request; + let client = match redirect_policy { + OAuthHttpRedirectPolicy::Follow => &self.follow_redirects, + OAuthHttpRedirectPolicy::Stop => &self.stop_redirects, + }; + let request = reqwest::Request::try_from(request) + .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + let response = client + .execute(request) .await - .map_err(Box::new)?; + .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; let mut builder = oauth2::http::Response::builder() .status(response.status()) .version(response.version()); - - for (name, value) in response.headers().iter() { + for (name, value) in response.headers() { builder = builder.header(name, value); } - + let mut body = Vec::new(); + let mut body_stream = response.bytes_stream(); + while let Some(chunk) = body_stream.next().await { + let chunk = chunk.map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES - body.len() { + return Err(OAuthHttpClientError::new(format!( + "OAuth HTTP response body exceeds {MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES} bytes" + ))); + } + body.extend_from_slice(&chunk); + } builder - .body(response.bytes().await.map_err(Box::new)?.to_vec()) - .map_err(HttpClientError::Http) + .body(body) + .map_err(|error| OAuthHttpClientError::new(error.to_string())) }) } } +struct OAuth2HttpClient<'a> { + client: &'a dyn OAuthHttpClient, + redirect_policy: OAuthHttpRedirectPolicy, +} + +impl<'c> AsyncHttpClient<'c> for OAuth2HttpClient<'_> { + type Error = OAuthHttpClientError; + + type Future = std::pin::Pin< + Box> + Send + 'c>, + >; + + fn call(&'c self, request: HttpRequest) -> Self::Future { + self.client + .execute(OAuthHttpRequest::new(request, self.redirect_policy)) + } +} + const DEFAULT_EXCHANGE_URL: &str = "http://localhost"; /// Default OIDC Dynamic Client Registration `application_type` (SEP-837) @@ -639,7 +755,9 @@ impl Default for ScopeUpgradeConfig { /// oauth2 auth manager pub struct AuthorizationManager { - http_client: HttpClient, + http_client: Arc, + // Preserve legacy reqwest refresh behavior without weakening custom clients. + refresh_redirect_policy: OAuthHttpRedirectPolicy, metadata: Option, oauth_client: Option, credential_store: Arc, @@ -732,14 +850,36 @@ impl AuthorizationManager { /// create new auth manager with base url pub async fn new(base_url: U) -> Result { - let base_url = base_url.into_url()?; - let http_client = HttpClient::builder() - .timeout(Duration::from_secs(30)) + let http_client = ReqwestClient::builder() + .timeout(DEFAULT_HTTP_TIMEOUT) .build() .map_err(|e| AuthError::InternalError(e.to_string()))?; + Self::new_inner( + base_url, + Arc::new(ReqwestOAuthHttpClient::new(http_client)?), + OAuthHttpRedirectPolicy::Stop, + ) + .await + } + + /// Create an auth manager with a client used for every OAuth HTTP operation. + pub async fn new_with_oauth_http_client( + base_url: U, + http_client: Arc, + ) -> Result { + Self::new_inner(base_url, http_client, OAuthHttpRedirectPolicy::Stop).await + } + + async fn new_inner( + base_url: U, + http_client: Arc, + refresh_redirect_policy: OAuthHttpRedirectPolicy, + ) -> Result { + let base_url = base_url.into_url()?; let manager = Self { http_client, + refresh_redirect_policy, metadata: None, oauth_client: None, credential_store: Arc::new(InMemoryCredentialStore::new()), @@ -804,8 +944,9 @@ impl AuthorizationManager { Ok(false) } - pub fn with_client(&mut self, http_client: HttpClient) -> Result<(), AuthError> { - self.http_client = http_client; + pub fn with_client(&mut self, http_client: ReqwestClient) -> Result<(), AuthError> { + self.http_client = Arc::new(ReqwestOAuthHttpClient::new(http_client)?); + self.refresh_redirect_policy = OAuthHttpRedirectPolicy::Follow; Ok(()) } @@ -957,11 +1098,21 @@ impl AuthorizationManager { application_type: application_type.clone(), }; + let request = oauth2::http::Request::builder() + .method("POST") + .uri(registration_url) + .header(CONTENT_TYPE, "application/json") + .body( + serde_json::to_vec(®istration_request) + .map_err(|error| AuthError::RegistrationFailed(error.to_string()))?, + ) + .map_err(|error| AuthError::RegistrationFailed(error.to_string()))?; let response = match self .http_client - .post(registration_url) - .json(®istration_request) - .send() + .execute(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Follow, + )) .await { Ok(response) => response, @@ -975,10 +1126,7 @@ impl AuthorizationManager { if !response.status().is_success() { let status = response.status(); - let error_text = match response.text().await { - Ok(text) => text, - Err(_) => "cannot get error details".to_string(), - }; + let error_text = String::from_utf8_lossy(response.body()); return Err(AuthError::RegistrationFailed(format!( "HTTP {}: {}", @@ -986,16 +1134,17 @@ impl AuthorizationManager { ))); } - debug!("registration response: {:?}", response); - let reg_response = match response.json::().await { - Ok(response) => response, - Err(e) => { - return Err(AuthError::RegistrationFailed(format!( - "analyze response error: {}", - e - ))); - } - }; + debug!("registration response status: {:?}", response.status()); + let reg_response = + match serde_json::from_slice::(response.body()) { + Ok(response) => response, + Err(e) => { + return Err(AuthError::RegistrationFailed(format!( + "analyze response error: {}", + e + ))); + } + }; let config = OAuthClientConfig { client_id: reg_response.client_id, @@ -1287,10 +1436,6 @@ impl AuthorizationManager { // Reconstruct the PKCE verifier let pkce_verifier = stored_state.into_pkce_verifier(); - let http_client = reqwest::ClientBuilder::new() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| AuthError::InternalError(e.to_string()))?; debug!("client_id: {:?}", oauth_client.client_id()); // exchange token @@ -1298,7 +1443,10 @@ impl AuthorizationManager { .exchange_code(AuthorizationCode::new(code.to_string())) .set_pkce_verifier(pkce_verifier) .add_extra_param("resource", self.base_url.to_string()) - .request_async(&OAuthReqwestClient(http_client)) + .request_async(&OAuth2HttpClient { + client: self.http_client.as_ref(), + redirect_policy: OAuthHttpRedirectPolicy::Stop, + }) .await { Ok(token) => token, @@ -1432,7 +1580,10 @@ impl AuthorizationManager { refresh_request = refresh_request.add_scope(Scope::new(scope)); } let token_result = refresh_request - .request_async(&OAuthReqwestClient(self.http_client.clone())) + .request_async(&OAuth2HttpClient { + client: self.http_client.as_ref(), + redirect_policy: self.refresh_redirect_policy, + }) .await .map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?; @@ -1539,13 +1690,7 @@ impl AuthorizationManager { discovery_url: &Url, ) -> Result, AuthError> { debug!("discovery url: {:?}", discovery_url); - let response = match self - .http_client - .get(discovery_url.clone()) - .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") - .send() - .await - { + let response = match self.discovery_get(discovery_url).await { Ok(r) => r, Err(e) => { debug!("discovery request failed: {}", e); @@ -1558,8 +1703,7 @@ impl AuthorizationManager { return Ok(None); } - let body = response.text().await?; - match serde_json::from_str::(&body) { + match serde_json::from_slice::(response.body()) { Ok(metadata) => Ok(Some(metadata)), Err(err) => { debug!("Failed to parse metadata for {}: {}", discovery_url, err); @@ -1659,13 +1803,7 @@ impl AuthorizationManager { /// Extract the resource metadata url from the WWW-Authenticate header value. /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for async fn fetch_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { - let response = match self - .http_client - .get(url.clone()) - .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") - .send() - .await - { + let response = match self.discovery_get(url).await { Ok(r) => r, Err(e) => { debug!("resource metadata probe failed: {}", e); @@ -1712,13 +1850,7 @@ impl AuthorizationManager { "resource metadata discovery url: {:?}", resource_metadata_url ); - let response = match self - .http_client - .get(resource_metadata_url.clone()) - .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") - .send() - .await - { + let response = match self.discovery_get(resource_metadata_url).await { Ok(r) => r, Err(e) => { debug!("resource metadata request failed: {}", e); @@ -1734,7 +1866,7 @@ impl AuthorizationManager { return Ok(None); } - let metadata = match response.json::().await { + let metadata = match serde_json::from_slice::(response.body()) { Ok(metadata) => metadata, Err(e) => { debug!("failed to parse resource metadata as JSON: {}", e); @@ -1744,6 +1876,21 @@ impl AuthorizationManager { Ok(Some(metadata)) } + async fn discovery_get(&self, url: &Url) -> Result { + let request = oauth2::http::Request::builder() + .method("GET") + .uri(url.as_str()) + .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") + .body(Vec::new()) + .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + self.http_client + .execute(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Follow, + )) + .await + } + /// extract parameters from WWW-Authenticate header (resource_metadata and scope) fn extract_www_authenticate_params(header: &str, base_url: &Url) -> WWWAuthenticateParams { let mut params = WWWAuthenticateParams::default(); @@ -2016,13 +2163,11 @@ impl AuthorizationManager { request = request.add_extra_param("resource", resource); } - let http_client = reqwest::ClientBuilder::new() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| AuthError::InternalError(e.to_string()))?; - let token_result = match request - .request_async(&OAuthReqwestClient(http_client)) + .request_async(&OAuth2HttpClient { + client: self.http_client.as_ref(), + redirect_policy: OAuthHttpRedirectPolicy::Stop, + }) .await { Ok(token) => token, @@ -2135,28 +2280,28 @@ impl AuthorizationManager { } let body_str = serializer.finish(); - let http_client = reqwest::ClientBuilder::new() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| AuthError::InternalError(e.to_string()))?; - - let response = http_client - .post(token_endpoint_url.as_str()) - .header("content-type", "application/x-www-form-urlencoded") - .body(body_str) - .send() + let request = oauth2::http::Request::builder() + .method("POST") + .uri(token_endpoint_url.as_str()) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(body_str.into_bytes()) + .map_err(|error| AuthError::ClientCredentialsError(error.to_string()))?; + let response = self + .http_client + .execute(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Stop, + )) .await .map_err(|e| { AuthError::ClientCredentialsError(format!("Token exchange request failed: {e}")) })?; let status = response.status(); - let body = response.bytes().await.map_err(|e| { - AuthError::ClientCredentialsError(format!("Failed to read token response: {e}")) - })?; + let body = response.body(); if !status.is_success() { - let msg = if let Ok(v) = serde_json::from_slice::(&body) { + let msg = if let Ok(v) = serde_json::from_slice::(body) { let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"); let desc = v .get("error_description") @@ -2169,7 +2314,7 @@ impl AuthorizationManager { return Err(AuthError::ClientCredentialsError(msg)); } - let token_result = serde_json::from_slice::(&body).map_err(|e| { + let token_result = serde_json::from_slice::(body).map_err(|e| { AuthError::ClientCredentialsError(format!("Failed to parse token response: {e}")) })?; @@ -2415,12 +2560,12 @@ impl AuthorizationSession { /// http client extension, automatically add authorization header pub struct AuthorizedHttpClient { auth_manager: Arc, - inner_client: HttpClient, + inner_client: ReqwestClient, } impl AuthorizedHttpClient { /// create new authorized http client - pub fn new(auth_manager: Arc, client: Option) -> Self { + pub fn new(auth_manager: Arc, client: Option) -> Self { let inner_client = client.unwrap_or_default(); Self { auth_manager, @@ -2467,10 +2612,34 @@ pub enum OAuthState { } impl OAuthState { + fn oauth_http_client_config(&self) -> (Arc, OAuthHttpRedirectPolicy) { + let manager = match self { + OAuthState::Unauthorized(manager) | OAuthState::Authorized(manager) => manager, + OAuthState::Session(session) => &session.auth_manager, + OAuthState::AuthorizedHttpClient(client) => &client.auth_manager, + }; + ( + Arc::clone(&manager.http_client), + manager.refresh_redirect_policy, + ) + } + + async fn placeholder(&self) -> Result { + let (http_client, refresh_redirect_policy) = self.oauth_http_client_config(); + Ok(OAuthState::Unauthorized( + AuthorizationManager::new_inner( + DEFAULT_EXCHANGE_URL, + http_client, + refresh_redirect_policy, + ) + .await?, + )) + } + /// Create new OAuth state machine pub async fn new( base_url: U, - client: Option, + client: Option, ) -> Result { let mut manager = AuthorizationManager::new(base_url).await?; if let Some(client) = client { @@ -2480,6 +2649,16 @@ impl OAuthState { Ok(OAuthState::Unauthorized(manager)) } + /// Create an OAuth state machine that routes all OAuth HTTP operations + /// through the supplied client. + pub async fn new_with_oauth_http_client( + base_url: U, + client: Arc, + ) -> Result { + let manager = AuthorizationManager::new_with_oauth_http_client(base_url, client).await?; + Ok(OAuthState::Unauthorized(manager)) + } + /// Get client_id and OAuth credentials pub async fn get_credentials(&self) -> Result { // return client_id and credentials @@ -2500,10 +2679,13 @@ impl OAuthState { credentials: OAuthTokenResponse, ) -> Result<(), AuthError> { if let OAuthState::Unauthorized(manager) = self { - let mut manager = std::mem::replace( - manager, - AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?, - ); + let replacement = AuthorizationManager::new_inner( + DEFAULT_EXCHANGE_URL, + Arc::clone(&manager.http_client), + manager.refresh_redirect_policy, + ) + .await?; + let mut manager = std::mem::replace(manager, replacement); let granted_scopes: Vec = credentials .scopes() @@ -2553,10 +2735,8 @@ impl OAuthState { client_name: Option<&str>, client_metadata_url: Option<&str>, ) -> Result<(), AuthError> { - if let OAuthState::Unauthorized(mut manager) = std::mem::replace( - self, - OAuthState::Unauthorized(AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?), - ) { + let placeholder = self.placeholder().await?; + if let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) { debug!("start discovery"); let metadata = manager.discover_metadata().await?; manager.metadata = Some(metadata); @@ -2588,10 +2768,8 @@ impl OAuthState { /// complete authorization pub async fn complete_authorization(&mut self) -> Result<(), AuthError> { - if let OAuthState::Session(session) = std::mem::replace( - self, - OAuthState::Unauthorized(AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?), - ) { + let placeholder = self.placeholder().await?; + if let OAuthState::Session(session) = std::mem::replace(self, placeholder) { *self = OAuthState::Authorized(session.auth_manager); Ok(()) } else { @@ -2600,10 +2778,8 @@ impl OAuthState { } /// covert to authorized http client pub async fn to_authorized_http_client(&mut self) -> Result<(), AuthError> { - if let OAuthState::Authorized(manager) = std::mem::replace( - self, - OAuthState::Authorized(AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?), - ) { + let placeholder = self.placeholder().await?; + if let OAuthState::Authorized(manager) = std::mem::replace(self, placeholder) { *self = OAuthState::AuthorizedHttpClient(AuthorizedHttpClient::new( Arc::new(manager), None, @@ -2622,8 +2798,7 @@ impl OAuthState { required_scope: &str, redirect_uri: &str, ) -> Result { - let placeholder = - OAuthState::Authorized(AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?); + let placeholder = self.placeholder().await?; let old = std::mem::replace(self, placeholder); let OAuthState::Authorized(manager) = old else { *self = old; @@ -2755,10 +2930,8 @@ impl OAuthState { &mut self, config: ClientCredentialsConfig, ) -> Result<(), AuthError> { - let OAuthState::Unauthorized(mut manager) = std::mem::replace( - self, - OAuthState::Unauthorized(AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?), - ) else { + let placeholder = self.placeholder().await?; + let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) else { return Err(AuthError::InternalError( "Client credentials flow requires Unauthorized state".to_string(), )); @@ -2784,18 +2957,228 @@ impl OAuthState { #[cfg(test)] mod tests { - use std::{collections::HashMap, sync::Arc}; + use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex as StdMutex}, + }; - use oauth2::{AuthType, CsrfToken, PkceCodeVerifier}; + use oauth2::{AuthType, CsrfToken, HttpResponse, PkceCodeVerifier}; use url::Url; use super::{ AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, - InMemoryStateStore, OAuthClientConfig, ScopeUpgradeConfig, StateStore, - StoredAuthorizationState, is_https_url, + InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError, + OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, + StateStore, StoredAuthorizationState, is_https_url, }; use crate::transport::auth::VendorExtraTokenFields; + #[derive(Clone, Debug, PartialEq, Eq)] + struct RecordedOAuthRequest { + method: String, + uri: String, + redirect_policy: OAuthHttpRedirectPolicy, + body: Vec, + } + + #[derive(Clone, Default)] + struct RecordingOAuthHttpClient { + requests: Arc>>, + responses: Arc>>, + } + + impl RecordingOAuthHttpClient { + fn with_responses(responses: Vec) -> Self { + Self { + responses: Arc::new(StdMutex::new(responses.into())), + ..Default::default() + } + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + } + + impl OAuthHttpClient for RecordingOAuthHttpClient { + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + self.requests.lock().unwrap().push(RecordedOAuthRequest { + method: request.request.method().to_string(), + uri: request.request.uri().to_string(), + redirect_policy: request.redirect_policy, + body: request.request.body().clone(), + }); + let response = self.responses.lock().unwrap().pop_front(); + Box::pin(async move { + response.ok_or_else(|| OAuthHttpClientError::new("missing fake response")) + }) + } + } + + fn http_response(status: u16, body: serde_json::Value) -> HttpResponse { + oauth2::http::Response::builder() + .status(status) + .body(serde_json::to_vec(&body).unwrap()) + .unwrap() + } + + #[tokio::test] + async fn custom_http_client_handles_protected_resource_discovery() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.discover_metadata().await.unwrap(); + + assert_eq!(metadata.token_endpoint, "https://auth.example.com/token"); + assert_eq!( + client.requests(), + vec![ + RecordedOAuthRequest { + method: "GET".to_string(), + uri: "https://mcp.example.com/mcp".to_string(), + redirect_policy: OAuthHttpRedirectPolicy::Follow, + body: Vec::new(), + }, + RecordedOAuthRequest { + method: "GET".to_string(), + uri: "https://mcp.example.com/.well-known/oauth-protected-resource".to_string(), + redirect_policy: OAuthHttpRedirectPolicy::Follow, + body: Vec::new(), + }, + RecordedOAuthRequest { + method: "GET".to_string(), + uri: "https://auth.example.com/.well-known/oauth-authorization-server" + .to_string(), + redirect_policy: OAuthHttpRedirectPolicy::Follow, + body: Vec::new(), + }, + ] + ); + } + + #[tokio::test] + async fn custom_http_client_handles_registration_exchange_and_refresh() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + http_response( + 201, + serde_json::json!({ + "client_id": "test-client", + "redirect_uris": ["http://localhost/callback"] + }), + ), + http_response( + 200, + serde_json::json!({ + "access_token": "access-1", + "token_type": "bearer", + "refresh_token": "refresh-1", + "expires_in": 3600 + }), + ), + http_response( + 200, + serde_json::json!({ + "access_token": "access-2", + "token_type": "bearer", + "refresh_token": "refresh-2", + "expires_in": 3600 + }), + ), + ]); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + manager.set_metadata(AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: Some("https://auth.example.com/register".to_string()), + response_types_supported: Some(vec!["code".to_string()]), + ..Default::default() + }); + manager + .register_client( + "Codex", + "http://localhost/callback", + &["profile", "offline_access"], + ) + .await + .unwrap(); + let authorization_url = manager + .get_authorization_url(&["profile", "offline_access"]) + .await + .unwrap(); + let state = Url::parse(&authorization_url) + .unwrap() + .query_pairs() + .find(|(name, _)| name == "state") + .unwrap() + .1 + .into_owned(); + + manager + .exchange_code_for_token("authorization-code", &state) + .await + .unwrap(); + manager.refresh_token().await.unwrap(); + + let requests = client.requests(); + let registration: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(registration["scope"], "profile offline_access"); + assert_eq!( + requests + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + vec![ + "https://auth.example.com/register", + "https://auth.example.com/token", + "https://auth.example.com/token", + ] + ); + assert_eq!( + requests + .iter() + .map(|request| request.redirect_policy) + .collect::>(), + vec![ + OAuthHttpRedirectPolicy::Follow, + OAuthHttpRedirectPolicy::Stop, + OAuthHttpRedirectPolicy::Stop, + ] + ); + } + // -- url helpers -- #[test] @@ -4420,6 +4803,74 @@ mod tests { ); } + #[tokio::test] + async fn refresh_token_uses_client_configured_by_with_client() { + use axum::{Router, body::Body, http::Response, routing::post}; + + let received_header = Arc::new(std::sync::Mutex::new(None)); + let received_header_clone = Arc::clone(&received_header); + let app = Router::new().route( + "/token", + post(move |headers: axum::http::HeaderMap| { + let received_header = Arc::clone(&received_header_clone); + async move { + *received_header.lock().unwrap() = headers + .get("x-custom-client") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, + )) + .unwrap() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("http://{addr}/authorize"), + token_endpoint: format!("http://{addr}/token"), + ..Default::default() + })) + .await; + let mut default_headers = reqwest::header::HeaderMap::new(); + default_headers.insert("x-custom-client", "configured".parse().unwrap()); + manager + .with_client( + reqwest::Client::builder() + .default_headers(default_headers) + .build() + .unwrap(), + ) + .unwrap(); + manager.configure_client(test_client_config()).unwrap(); + manager + .credential_store + .save(StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }) + .await + .unwrap(); + + manager.refresh_token().await.unwrap(); + + assert_eq!( + received_header.lock().unwrap().as_deref(), + Some("configured") + ); + } + async fn start_token_server() -> (String, Arc>>) { use axum::{Router, body::Body, http::Response, routing::post}; let captured: Arc>> = Arc::new(std::sync::Mutex::new(None)); From 6d020c9684876666dca3b8a6efe9b99ccba39c04 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:34:16 -0400 Subject: [PATCH 185/333] fix(auth): preserve configured reqwest client (#917) --- crates/rmcp/src/transport/auth.rs | 157 +++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 5ccc9bc1f..e2a8541e5 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -944,8 +944,20 @@ impl AuthorizationManager { Ok(false) } + /// Use a caller-configured `reqwest::Client` for every OAuth HTTP operation, + /// preserving all of its settings (proxy, TLS, timeout, default headers). + /// + /// The same client is reused for all requests, so its own redirect policy applies + /// and [`OAuthHttpRedirectPolicy::Stop`] is not enforced for token operations. + /// Callers needing strict no-redirect handling should pass a custom + /// [`OAuthHttpClient`] to [`AuthorizationManager::new_with_oauth_http_client`]. pub fn with_client(&mut self, http_client: ReqwestClient) -> Result<(), AuthError> { - self.http_client = Arc::new(ReqwestOAuthHttpClient::new(http_client)?); + // One client for both modes: a built reqwest::Client can't be rebuilt as a + // no-redirect variant without dropping the caller's configuration. + self.http_client = Arc::new(ReqwestOAuthHttpClient { + follow_redirects: http_client.clone(), + stop_redirects: http_client, + }); self.refresh_redirect_policy = OAuthHttpRedirectPolicy::Follow; Ok(()) } @@ -4871,6 +4883,149 @@ mod tests { ); } + #[tokio::test] + async fn exchange_code_uses_client_configured_by_with_client() { + use axum::{Router, body::Body, http::Response, routing::post}; + + let received_header = Arc::new(std::sync::Mutex::new(None)); + let received_header_clone = Arc::clone(&received_header); + let app = Router::new().route( + "/token", + post(move |headers: axum::http::HeaderMap| { + let received_header = Arc::clone(&received_header_clone); + async move { + *received_header.lock().unwrap() = headers + .get("x-custom-client") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600}"#, + )) + .unwrap() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("http://{addr}/authorize"), + token_endpoint: format!("http://{addr}/token"), + ..Default::default() + })) + .await; + let mut default_headers = reqwest::header::HeaderMap::new(); + default_headers.insert("x-custom-client", "configured".parse().unwrap()); + manager + .with_client( + reqwest::Client::builder() + .default_headers(default_headers) + .build() + .unwrap(), + ) + .unwrap(); + manager.configure_client(test_client_config()).unwrap(); + let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); + let state = Url::parse(&authorization_url) + .unwrap() + .query_pairs() + .find(|(name, _)| name == "state") + .unwrap() + .1 + .into_owned(); + + manager + .exchange_code_for_token("authorization-code", &state) + .await + .unwrap(); + + assert_eq!( + received_header.lock().unwrap().as_deref(), + Some("configured") + ); + } + + #[tokio::test] + async fn exchange_code_follows_redirects_with_with_client() { + use std::sync::atomic::{AtomicBool, Ordering}; + + use axum::{ + Router, + body::Body, + http::{Response, StatusCode}, + routing::post, + }; + + // The token endpoint replies with a 307 redirect; the with_client path reuses + // the caller's redirect-following client, so the request is expected to follow + // it to the final endpoint that returns the token. + let final_endpoint_hit = Arc::new(AtomicBool::new(false)); + let final_endpoint_hit_clone = Arc::clone(&final_endpoint_hit); + let app = Router::new() + .route( + "/token", + post(|| async { + Response::builder() + .status(StatusCode::TEMPORARY_REDIRECT) + .header("location", "/token-final") + .body(Body::empty()) + .unwrap() + }), + ) + .route( + "/token-final", + post(move || { + let final_endpoint_hit = Arc::clone(&final_endpoint_hit_clone); + async move { + final_endpoint_hit.store(true, Ordering::SeqCst); + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"redirected-token","token_type":"Bearer","expires_in":3600}"#, + )) + .unwrap() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("http://{addr}/authorize"), + token_endpoint: format!("http://{addr}/token"), + ..Default::default() + })) + .await; + manager + .with_client(reqwest::Client::builder().build().unwrap()) + .unwrap(); + manager.configure_client(test_client_config()).unwrap(); + let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); + let state = Url::parse(&authorization_url) + .unwrap() + .query_pairs() + .find(|(name, _)| name == "state") + .unwrap() + .1 + .into_owned(); + + manager + .exchange_code_for_token("authorization-code", &state) + .await + .unwrap(); + + assert!( + final_endpoint_hit.load(Ordering::SeqCst), + "with_client path should follow redirects on token exchange" + ); + } + async fn start_token_server() -> (String, Arc>>) { use axum::{Router, body::Body, http::Response, routing::post}; let captured: Arc>> = Arc::new(std::sync::Mutex::new(None)); From 25220361d5540715294c501c289d79de4bec2bfc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:20:21 -0400 Subject: [PATCH 186/333] chore: release v1.8.0 (#850) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 16 ++++++++++++++++ crates/rmcp/CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 06e831825..4a112a94e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.7.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.7.0", path = "./crates/rmcp-macros" } +rmcp = { version = "1.8.0", path = "./crates/rmcp" } +rmcp-macros = { version = "1.8.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.7.0" +version = "1.8.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 13d809d5c..05a7bc2fc 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.7.0...rmcp-macros-v1.8.0) - 2026-06-22 + +### Added + +- deprecate roots, sampling, and logging (SEP-2577) ([#884](https://github.com/modelcontextprotocol/rust-sdk/pull/884)) + +### Fixed + +- strip and validate tool outputSchema and inputSchema ([#860](https://github.com/modelcontextprotocol/rust-sdk/pull/860)) +- remove unnecessary fields from tools' inputSchema ([#856](https://github.com/modelcontextprotocol/rust-sdk/pull/856)) + +### Other + +- refine mcpmate listing copy ([#885](https://github.com/modelcontextprotocol/rust-sdk/pull/885)) +- added jilebi-mcp to the list of built with rmcp ([#861](https://github.com/modelcontextprotocol/rust-sdk/pull/861)) + ## [1.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.6.0...rmcp-macros-v1.7.0) - 2026-05-13 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 841ce6c66..0d71a36b8 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0) - 2026-06-22 + +### Added + +- standardize resource-not-found error code (SEP-2164) ([#899](https://github.com/modelcontextprotocol/rust-sdk/pull/899)) +- validate OAuth authorization response issuer ([#896](https://github.com/modelcontextprotocol/rust-sdk/pull/896)) +- specify OIDC application_type during dynamic client registration (SEP-837) ([#883](https://github.com/modelcontextprotocol/rust-sdk/pull/883)) +- deprecate roots, sampling, and logging (SEP-2577) ([#884](https://github.com/modelcontextprotocol/rust-sdk/pull/884)) + +### Fixed + +- *(auth)* preserve configured reqwest client ([#917](https://github.com/modelcontextprotocol/rust-sdk/pull/917)) +- *(auth)* align OAuth metadata discovery ordering ([#887](https://github.com/modelcontextprotocol/rust-sdk/pull/887)) +- align progress timeout token ([#909](https://github.com/modelcontextprotocol/rust-sdk/pull/909)) +- *(elicitation)* preserve enumNames through ElicitationSchema serde round-trip ([#905](https://github.com/modelcontextprotocol/rust-sdk/pull/905)) +- return tool errors for invalid arguments ([#894](https://github.com/modelcontextprotocol/rust-sdk/pull/894)) +- *(auth)* apply offline_access to reauth paths ([#897](https://github.com/modelcontextprotocol/rust-sdk/pull/897)) +- update peer info on duplicate initialize ([#862](https://github.com/modelcontextprotocol/rust-sdk/pull/862)) +- strip and validate tool outputSchema and inputSchema ([#860](https://github.com/modelcontextprotocol/rust-sdk/pull/860)) +- remove unnecessary fields from tools' inputSchema ([#856](https://github.com/modelcontextprotocol/rust-sdk/pull/856)) +- reject init header/body version mismatch ([#853](https://github.com/modelcontextprotocol/rust-sdk/pull/853)) +- align protocol version negotiation ([#855](https://github.com/modelcontextprotocol/rust-sdk/pull/855)) +- accept 200 with empty body in response to notifications in addition to 202 ([#849](https://github.com/modelcontextprotocol/rust-sdk/pull/849)) + +### Other + +- Allow custom HTTP clients for OAuth ([#908](https://github.com/modelcontextprotocol/rust-sdk/pull/908)) +- Add progress-aware request timeout reset ([#858](https://github.com/modelcontextprotocol/rust-sdk/pull/858)) +- *(server)* document Err vs Ok(CallToolResult::error) visibility contract on ServerHandler::call_tool ([#854](https://github.com/modelcontextprotocol/rust-sdk/pull/854)) +- refine mcpmate listing copy ([#885](https://github.com/modelcontextprotocol/rust-sdk/pull/885)) +- added jilebi-mcp to the list of built with rmcp ([#861](https://github.com/modelcontextprotocol/rust-sdk/pull/861)) + ## [1.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.6.0...rmcp-v1.7.0) - 2026-05-13 ### Added From 0a95c3b608b9b2c0a932fb3a8e8474dbea8a27bc Mon Sep 17 00:00:00 2001 From: Brice Fernandes Date: Tue, 23 Jun 2026 21:35:40 +0100 Subject: [PATCH 187/333] fix(rmcp): add Audio variant to PromptMessageContent (#865) The spec's prompt-message ContentBlock union is `text | image | audio | resource_link | resource`, but PromptMessageContent omitted `Audio`. Because the enum is `#[serde(tag = "type")]` with no catch-all, a spec-conformant `{"type":"audio",...}` content block failed to deserialize with "unknown variant `audio`", breaking prompts/get for any server that returns audio prompt content (the audio analogue of #842 / #843). The supporting AudioContent type already existed, and Audio was already a variant of the general RawContent enum (tool results, sampling) -- only PromptMessageContent lacked it. Add the flattened Audio variant (mirroring Image), a PromptMessage::new_audio constructor (mirroring new_image), and serialization + deserialization regression tests. Fixes #864. Co-authored-by: Claude Opus 4.8 (1M context) --- crates/rmcp/src/model/prompt.rs | 80 ++++++++++++++++++- .../server_json_rpc_message_schema.json | 31 +++++++ ...erver_json_rpc_message_schema_current.json | 31 +++++++ 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index e3bf4061a..a44183fbd 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use super::{ AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource, - content::{EmbeddedResource, ImageContent}, + content::{AudioContent, EmbeddedResource, ImageContent}, resource::ResourceContents, }; @@ -157,6 +157,11 @@ pub enum PromptMessageContent { #[serde(flatten)] image: ImageContent, }, + /// Audio content with base64-encoded data + Audio { + #[serde(flatten)] + audio: AudioContent, + }, /// Embedded server-side resource Resource { #[serde(flatten)] @@ -230,6 +235,29 @@ impl PromptMessage { } } + /// Create a new audio message. `annotations` is optional. + #[cfg(feature = "base64")] + pub fn new_audio( + role: PromptMessageRole, + data: &[u8], + mime_type: &str, + annotations: Option, + ) -> Self { + use base64::{Engine, prelude::BASE64_STANDARD}; + + let base64 = BASE64_STANDARD.encode(data); + Self { + role, + content: PromptMessageContent::Audio { + audio: crate::model::RawAudioContent { + data: base64, + mime_type: mime_type.into(), + } + .optional_annotate(annotations), + }, + } + } + /// Create a new resource message. `resource_meta`, `resource_content_meta`, and `annotations` are optional. pub fn new_resource( role: PromptMessageRole, @@ -307,6 +335,56 @@ mod tests { assert!(!json.contains("mime_type")); } + #[test] + fn test_prompt_message_audio_serialization_and_deserialization() { + // Audio is part of the spec's ContentBlock union for prompt messages + // (text | image | audio | resource_link | resource). Ensure the Audio + // variant serializes to the flat, spec-compliant shape + // `{ "type": "audio", "data", "mimeType" }` and parses back. + // See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts + let content = PromptMessageContent::Audio { + audio: crate::model::RawAudioContent { + data: "YXVkaW8=".to_string(), + mime_type: "audio/wav".to_string(), + } + .no_annotation(), + }; + + let value = serde_json::to_value(&content).unwrap(); + assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("audio")); + assert_eq!(value.get("data").and_then(|v| v.as_str()), Some("YXVkaW8=")); + assert_eq!( + value.get("mimeType").and_then(|v| v.as_str()), + Some("audio/wav"), + "expected camelCase mimeType, got: {value:#?}" + ); + + // Regression: a spec-valid audio content block must deserialize into + // the Audio variant (previously failed with "unknown variant `audio`"). + let json = r#"{"type":"audio","data":"YXVkaW8=","mimeType":"audio/wav"}"#; + let parsed: PromptMessageContent = serde_json::from_str(json).unwrap(); + assert_eq!(parsed, content); + } + + #[test] + #[cfg(feature = "base64")] + fn test_prompt_message_new_audio_constructor() { + let message = + PromptMessage::new_audio(PromptMessageRole::User, b"hello", "audio/wav", None); + let value = serde_json::to_value(&message).unwrap(); + let content = value.get("content").expect("content present"); + assert_eq!(content.get("type").and_then(|v| v.as_str()), Some("audio")); + assert_eq!( + content.get("mimeType").and_then(|v| v.as_str()), + Some("audio/wav") + ); + // base64 of "hello" + assert_eq!( + content.get("data").and_then(|v| v.as_str()), + Some("aGVsbG8=") + ); + } + #[test] fn test_prompt_message_resource_link_serialization() { use super::super::resource::RawResource; diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 6de03e31f..5cb0cc8f1 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -2115,6 +2115,37 @@ "mimeType" ] }, + { + "description": "Audio content with base64-encoded data", + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "type": { + "type": "string", + "const": "audio" + } + }, + "required": [ + "type", + "data", + "mimeType" + ] + }, { "description": "Embedded server-side resource", "type": "object", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 6de03e31f..5cb0cc8f1 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -2115,6 +2115,37 @@ "mimeType" ] }, + { + "description": "Audio content with base64-encoded data", + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "type": { + "type": "string", + "const": "audio" + } + }, + "required": [ + "type", + "data", + "mimeType" + ] + }, { "description": "Embedded server-side resource", "type": "object", From b79e0d9df3f2a60abb8526e2f6f08d2aa84ba381 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:53:57 -0400 Subject: [PATCH 188/333] ci: honor breaking semver markers (#922) --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c49bea004..eadd2794f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,12 +80,22 @@ jobs: with: tool: cargo-semver-checks + - name: Determine semver release type + run: | + if git log --format=%B \ + ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} \ + | grep -Eq '(^[A-Za-z0-9_-]+(\([^)]*\))?!:|^BREAKING[ -]CHANGE:)'; then + echo "SEMVER_RELEASE_TYPE=major" >> "$GITHUB_ENV" + else + echo "SEMVER_RELEASE_TYPE=minor" >> "$GITHUB_ENV" + fi + - name: Check rmcp (default features) run: | cargo semver-checks \ --package rmcp \ --baseline-rev ${{ github.event.pull_request.base.sha }} \ - --release-type minor \ + --release-type "$SEMVER_RELEASE_TYPE" \ --only-explicit-features \ --features default @@ -98,7 +108,7 @@ jobs: cargo semver-checks \ --package rmcp \ --baseline-rev ${{ github.event.pull_request.base.sha }} \ - --release-type minor \ + --release-type "$SEMVER_RELEASE_TYPE" \ --only-explicit-features \ --features "$FEATURES" From 42a106983359061ffc3340207e60bc20945b4574 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:19:04 -0400 Subject: [PATCH 189/333] ci: add cargo-public-api check for breaking API changes (#924) * chore: bump Rust toolchain to 1.96 * ci: add cargo-public-api breaking-change check Adds a release-type-aware `cargo-public-api` diff job that fails on changed/removed public items unless the PR's commits mark a breaking (major) release. This catches source-breaking API changes that cargo-semver-checks cannot yet detect (e.g. function return-type or field-type changes). --- .github/workflows/ci.yml | 71 ++++++++++++++++++++++++++++++++++++++++ rust-toolchain.toml | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eadd2794f..bbe6c2618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,77 @@ jobs: --only-explicit-features \ --features "$FEATURES" + public-api: + name: Public API Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # cargo-public-api builds rustdoc JSON, which requires a nightly toolchain + # to be installed (it does not need to be the default; the tool invokes it + # via `cargo +nightly`). + - name: Install Rust + uses: dtolnay/rust-toolchain@nightly + + - uses: Swatinem/rust-cache@v2 + + - name: Install cargo-public-api + uses: taiki-e/install-action@v2 + with: + tool: cargo-public-api + + # Mirror the SemVer Check job's release-type detection: a breaking-change + # commit marker (`!:` or `BREAKING CHANGE:`) means a major release (any API + # change is allowed); otherwise a minor release (additions allowed, but + # changed/removed public items are denied). This catches breaking changes + # that cargo-semver-checks cannot yet detect, such as a change to a + # function's return type or a field's type. + # See https://github.com/obi1kenobi/cargo-semver-checks/issues/5 + - name: Determine release type and deny flags + run: | + if git log --format=%B \ + ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} \ + | grep -Eq '(^[A-Za-z0-9_-]+(\([^)]*\))?!:|^BREAKING[ -]CHANGE:)'; then + SEMVER_RELEASE_TYPE=major + else + SEMVER_RELEASE_TYPE=minor + fi + case "$SEMVER_RELEASE_TYPE" in + major) DENY="" ;; + patch) DENY="--deny added --deny changed --deny removed" ;; + *) DENY="--deny changed --deny removed" ;; + esac + echo "SEMVER_RELEASE_TYPE=$SEMVER_RELEASE_TYPE" >> "$GITHUB_ENV" + echo "DENY=$DENY" >> "$GITHUB_ENV" + + - name: Check rmcp (default features) + run: | + cargo public-api \ + --package rmcp \ + -ss \ + diff \ + $DENY \ + --force \ + ${{ github.event.pull_request.base.sha }}..${{ github.sha }} + + - name: Check rmcp (all features except local) + run: | + FEATURES=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] + | select(startswith("__") | not) + | select(. != "local")] | join(",")') + cargo public-api \ + --package rmcp \ + --features "$FEATURES" \ + -ss \ + diff \ + $DENY \ + --force \ + ${{ github.event.pull_request.base.sha }}..${{ github.sha }} + spelling: name: spell check with typos runs-on: ubuntu-latest diff --git a/rust-toolchain.toml b/rust-toolchain.toml index f04d1f29b..7f81472ba 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.92" +channel = "1.96" components = ["rustc", "rust-std", "cargo", "clippy", "rustfmt", "rust-docs"] From 77932141e3e87f950986e4ee3e698cd6e6ba7cd6 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:41:13 -0400 Subject: [PATCH 190/333] feat!: align model types with MCP 2025-11-25 spec (#927) * feat!: align model types with MCP 2025-11-25 spec * fix: complete 2025-11-25 model spec conformance --- conformance/src/bin/client.rs | 61 +- conformance/src/bin/server.rs | 137 +- crates/rmcp-macros/src/task_handler.rs | 10 +- crates/rmcp/src/handler/client.rs | 55 +- crates/rmcp/src/handler/server.rs | 34 +- crates/rmcp/src/handler/server/prompt.rs | 1 + crates/rmcp/src/handler/server/router/tool.rs | 8 +- crates/rmcp/src/handler/server/tool.rs | 2 +- crates/rmcp/src/model.rs | 507 ++++-- crates/rmcp/src/model/annotated.rs | 201 +-- crates/rmcp/src/model/capabilities.rs | 59 +- crates/rmcp/src/model/content.rs | 382 +++-- crates/rmcp/src/model/elicitation_schema.rs | 196 ++- crates/rmcp/src/model/meta.rs | 18 +- crates/rmcp/src/model/prompt.rs | 238 +-- crates/rmcp/src/model/resource.rs | 313 ++-- crates/rmcp/src/model/task.rs | 89 +- crates/rmcp/src/service.rs | 26 +- crates/rmcp/src/service/server.rs | 14 +- .../streamable_http_server/session/local.rs | 25 +- crates/rmcp/tests/common/handlers.rs | 10 +- crates/rmcp/tests/test_completion.rs | 41 +- crates/rmcp/tests/test_complex_schema.rs | 2 +- crates/rmcp/tests/test_deserialization.rs | 3 +- crates/rmcp/tests/test_elicitation.rs | 448 +++-- .../rmcp/tests/test_embedded_resource_meta.rs | 46 +- .../tests/test_inflight_response_drain.rs | 2 +- crates/rmcp/tests/test_logging.rs | 29 +- .../client_json_rpc_message_schema.json | 900 ++++++---- ...lient_json_rpc_message_schema_current.json | 900 ++++++---- .../server_json_rpc_message_schema.json | 1476 ++++++++--------- ...erver_json_rpc_message_schema_current.json | 1476 ++++++++--------- crates/rmcp/tests/test_notification.rs | 2 +- crates/rmcp/tests/test_progress_subscriber.rs | 11 +- .../tests/test_prompt_macro_annotations.rs | 37 +- crates/rmcp/tests/test_prompt_macros.rs | 35 +- crates/rmcp/tests/test_prompt_routers.rs | 10 +- .../tests/test_request_timeout_progress.rs | 25 +- crates/rmcp/tests/test_resource_link.rs | 26 +- .../tests/test_resource_link_integration.rs | 53 +- crates/rmcp/tests/test_sampling.rs | 60 +- .../rmcp/tests/test_sse_concurrent_streams.rs | 6 +- crates/rmcp/tests/test_structured_output.rs | 5 +- crates/rmcp/tests/test_task.rs | 33 +- .../tests/test_task_support_validation.rs | 10 +- crates/rmcp/tests/test_tool_macros.rs | 8 +- crates/rmcp/tests/test_tool_result_meta.rs | 6 +- examples/clients/src/task_stdio.rs | 26 +- examples/servers/src/common/counter.rs | 36 +- examples/servers/src/common/progress_demo.rs | 14 +- examples/servers/src/common/task_demo.rs | 6 +- examples/servers/src/completion_stdio.rs | 19 +- .../servers/src/elicitation_enum_inference.rs | 6 +- examples/servers/src/elicitation_stdio.rs | 19 +- examples/servers/src/prompt_stdio.rs | 43 +- examples/servers/src/sampling_stdio.rs | 2 +- 56 files changed, 4321 insertions(+), 3886 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 41a94f701..8dabff0ff 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -45,48 +45,46 @@ struct ElicitationDefaultsClientHandler; impl ClientHandler for ElicitationDefaultsClientHandler { fn get_info(&self) -> ClientInfo { let mut info = ClientInfo::default(); - info.capabilities.elicitation = Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: None, - }); + info.capabilities.elicitation = Some( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ); info } async fn create_elicitation( &self, - request: CreateElicitationRequestParams, + request: ElicitRequestParams, _cx: RequestContext, - ) -> Result { + ) -> Result { let content = match &request { - CreateElicitationRequestParams::FormElicitationParams { + ElicitRequestParams::FormElicitationParams { requested_schema, .. } => { let mut defaults = serde_json::Map::new(); for (name, prop) in &requested_schema.properties { match prop { - PrimitiveSchema::String(s) => { + PrimitiveSchemaDefinition::String(s) => { if let Some(d) = &s.default { defaults.insert(name.clone(), Value::String(d.clone())); } } - PrimitiveSchema::Number(n) => { + PrimitiveSchemaDefinition::Number(n) => { if let Some(d) = n.default { defaults.insert(name.clone(), json!(d)); } } - PrimitiveSchema::Integer(i) => { + PrimitiveSchemaDefinition::Integer(i) => { if let Some(d) = i.default { defaults.insert(name.clone(), json!(d)); } } - PrimitiveSchema::Boolean(b) => { + PrimitiveSchemaDefinition::Boolean(b) => { if let Some(d) = b.default { defaults.insert(name.clone(), Value::Bool(d)); } } - PrimitiveSchema::Enum(e) => { + PrimitiveSchemaDefinition::Enum(e) => { let val = match e { EnumSchema::Single(SingleSelectEnumSchema::Untitled(u)) => { u.default.as_ref().map(|d| Value::String(d.clone())) @@ -109,22 +107,24 @@ impl ClientHandler for ElicitationDefaultsClientHandler { }) } EnumSchema::Legacy(_) => None, + _ => None, }; if let Some(v) = val { defaults.insert(name.clone(), v); } } + _ => {} } } Some(Value::Object(defaults)) } _ => Some(json!({})), }; - Ok(CreateElicitationResult { - action: ElicitationAction::Accept, - content, - meta: None, - }) + let mut result = ElicitResult::new(ElicitationAction::Accept); + if let Some(c) = content { + result = result.with_content(c); + } + Ok(result) } } @@ -134,12 +134,10 @@ struct FullClientHandler; impl ClientHandler for FullClientHandler { fn get_info(&self) -> ClientInfo { let mut info = ClientInfo::default(); - info.capabilities.elicitation = Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: None, - }); + info.capabilities.elicitation = Some( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ); info } @@ -158,7 +156,7 @@ impl ClientHandler for FullClientHandler { Ok(CreateMessageResult::new( SamplingMessage::new( Role::Assistant, - SamplingMessageContent::text(format!( + SamplingMessageContentBlock::text(format!( "This is a mock LLM response to: {}", prompt_text )), @@ -170,14 +168,11 @@ impl ClientHandler for FullClientHandler { async fn create_elicitation( &self, - _request: CreateElicitationRequestParams, + _request: ElicitRequestParams, _cx: RequestContext, - ) -> Result { - Ok(CreateElicitationResult { - action: ElicitationAction::Accept, - content: Some(json!({"username": "testuser", "email": "test@example.com"})), - meta: None, - }) + ) -> Result { + Ok(ElicitResult::new(ElicitationAction::Accept) + .with_content(json!({"username": "testuser", "email": "test@example.com"}))) } } diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index c3424f612..b0b0d635c 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -217,25 +217,21 @@ impl ServerHandler for ConformanceServer { ) -> Result { let args = request.arguments.unwrap_or_default(); match request.name.as_ref() { - "test_simple_text" => Ok(CallToolResult::success(vec![Content::text( + "test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text( "This is a simple text response for testing.", )])), - "test_image_content" => Ok(CallToolResult::success(vec![Content::image( + "test_image_content" => Ok(CallToolResult::success(vec![ContentBlock::image( TEST_IMAGE_DATA, "image/png", )])), "test_audio_content" => { - let audio = RawContent::Audio(RawAudioContent { - data: TEST_AUDIO_DATA.into(), - mime_type: "audio/wav".into(), - }) - .no_annotation(); + let audio = ContentBlock::Audio(AudioContent::new(TEST_AUDIO_DATA, "audio/wav")); Ok(CallToolResult::success(vec![audio])) } - "test_embedded_resource" => Ok(CallToolResult::success(vec![Content::resource( + "test_embedded_resource" => Ok(CallToolResult::success(vec![ContentBlock::resource( ResourceContents::TextResourceContents { uri: "test://embedded-resource".into(), mime_type: Some("text/plain".into()), @@ -245,9 +241,9 @@ impl ServerHandler for ConformanceServer { )])), "test_multiple_content_types" => Ok(CallToolResult::success(vec![ - Content::text("Multiple content types test:"), - Content::image(TEST_IMAGE_DATA, "image/png"), - Content::resource(ResourceContents::TextResourceContents { + ContentBlock::text("Multiple content types test:"), + ContentBlock::image(TEST_IMAGE_DATA, "image/png"), + ContentBlock::resource(ResourceContents::TextResourceContents { uri: "test://mixed-content-resource".into(), mime_type: Some("application/json".into()), text: r#"{"test":"data","value":123}"#.into(), @@ -263,21 +259,20 @@ impl ServerHandler for ConformanceServer { ] { let _ = cx .peer - .notify_logging_message(LoggingMessageNotificationParam { - level: LoggingLevel::Info, - logger: Some("conformance-server".into()), - data: json!(msg), - }) + .notify_logging_message( + LoggingMessageNotificationParam::new(LoggingLevel::Info, json!(msg)) + .with_logger("conformance-server"), + ) .await; tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( "Logging test completed", )])) } - "test_error_handling" => Ok(CallToolResult::error(vec![Content::text( + "test_error_handling" => Ok(CallToolResult::error(vec![ContentBlock::text( "This tool intentionally returns an error for testing", )])), @@ -290,18 +285,17 @@ impl ServerHandler for ConformanceServer { if let Some(token) = &progress_token { let _ = cx .peer - .notify_progress(ProgressNotificationParam { - progress_token: token.clone(), - progress, - total: Some(100.0), - message: Some(message.into()), - }) + .notify_progress( + ProgressNotificationParam::new(token.clone(), progress) + .with_total(100.0) + .with_message(message), + ) .await; } tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( "Progress test completed", )])) } @@ -328,12 +322,12 @@ impl ServerHandler for ConformanceServer { .and_then(|c| c.as_text()) .map(|t| t.text.clone()) .unwrap_or_else(|| "No text response".into()); - Ok(CallToolResult::success(vec![Content::text(format!( + Ok(CallToolResult::success(vec![ContentBlock::text(format!( "LLM response: {}", text ))])) } - Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!( "Sampling error: {}", e ))])), @@ -365,23 +359,24 @@ impl ServerHandler for ConformanceServer { match cx .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + .create_elicitation(ElicitRequestParams::FormElicitationParams { meta: None, message: message.into(), requested_schema: schema, }) .await { - Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + Ok(result) => Ok(CallToolResult::success(vec![ContentBlock::text(format!( "User response: action={}, content={:?}", match result.action { ElicitationAction::Accept => "accept", ElicitationAction::Decline => "decline", ElicitationAction::Cancel => "cancel", + _ => "unknown", }, result.content ))])), - Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!( "Elicitation error: {}", e ))])), @@ -425,23 +420,24 @@ impl ServerHandler for ConformanceServer { match cx .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + .create_elicitation(ElicitRequestParams::FormElicitationParams { meta: None, message: "Please provide values (all have defaults)".into(), requested_schema: schema, }) .await { - Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + Ok(result) => Ok(CallToolResult::success(vec![ContentBlock::text(format!( "Elicitation completed: action={}, content={:?}", match result.action { ElicitationAction::Accept => "accept", ElicitationAction::Decline => "decline", ElicitationAction::Cancel => "cancel", + _ => "unknown", }, result.content ))])), - Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!( "Elicitation error: {}", e ))])), @@ -493,22 +489,23 @@ impl ServerHandler for ConformanceServer { match cx .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + .create_elicitation(ElicitRequestParams::FormElicitationParams { meta: None, message: "Test enum schema improvements".into(), requested_schema: schema, }) .await { - Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!( + Ok(result) => Ok(CallToolResult::success(vec![ContentBlock::text(format!( "Enum elicitation completed: action={}", match result.action { ElicitationAction::Accept => "accept", ElicitationAction::Decline => "decline", ElicitationAction::Cancel => "cancel", + _ => "unknown", } ))])), - Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!( "Elicitation error: {}", e ))])), @@ -517,7 +514,7 @@ impl ServerHandler for ConformanceServer { "json_schema_2020_12_tool" => { let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world"); - Ok(CallToolResult::success(vec![Content::text(format!( + Ok(CallToolResult::success(vec![ContentBlock::text(format!( "Hello, {}!", name ))])) @@ -525,7 +522,7 @@ impl ServerHandler for ConformanceServer { "test_reconnection" => { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( "Reconnection test completed", )])) } @@ -545,28 +542,12 @@ impl ServerHandler for ConformanceServer { Ok(ListResourcesResult { meta: None, resources: vec![ - RawResource { - uri: "test://static-text".into(), - name: "Static Text Resource".into(), - title: None, - description: Some("A static text resource for testing".into()), - mime_type: Some("text/plain".into()), - size: None, - icons: None, - meta: None, - } - .no_annotation(), - RawResource { - uri: "test://static-binary".into(), - name: "Static Binary Resource".into(), - title: None, - description: Some("A static binary/blob resource for testing".into()), - mime_type: Some("image/png".into()), - size: None, - icons: None, - meta: None, - } - .no_annotation(), + Resource::new("test://static-text", "Static Text Resource") + .with_description("A static text resource for testing") + .with_mime_type("text/plain"), + Resource::new("test://static-binary", "Static Binary Resource") + .with_description("A static binary/blob resource for testing") + .with_mime_type("image/png"), ], next_cursor: None, }) @@ -630,15 +611,9 @@ impl ServerHandler for ConformanceServer { Ok(ListResourceTemplatesResult { meta: None, resource_templates: vec![ - RawResourceTemplate { - uri_template: "test://template/{id}/data".into(), - name: "Dynamic Resource".into(), - title: None, - description: Some("A dynamic resource with parameter substitution".into()), - mime_type: Some("application/json".into()), - icons: None, - } - .no_annotation(), + ResourceTemplate::new("test://template/{id}/data", "Dynamic Resource") + .with_description("A dynamic resource with parameter substitution") + .with_mime_type("application/json"), ], next_cursor: None, }) @@ -711,7 +686,7 @@ impl ServerHandler for ConformanceServer { ) -> Result { match request.name.as_str() { "test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text( - PromptMessageRole::User, + Role::User, "This is a simple test prompt.", )]) .with_description("A simple test prompt")), @@ -723,15 +698,15 @@ impl ServerHandler for ConformanceServer { .and_then(|v| v.as_str()) .unwrap_or("friendly"); Ok(GetPromptResult::new(vec![PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!("Please greet {} in a {} style.", name, style), )]) .with_description("A prompt with arguments")) } "test_prompt_with_embedded_resource" => Ok(GetPromptResult::new(vec![ - PromptMessage::new_text(PromptMessageRole::User, "Here is a resource:"), + PromptMessage::new_text(Role::User, "Here is a resource:"), PromptMessage::new_resource( - PromptMessageRole::User, + Role::User, "test://static-text".into(), Some("text/plain".into()), Some("Resource content for prompt".into()), @@ -742,19 +717,10 @@ impl ServerHandler for ConformanceServer { ]) .with_description("A prompt with an embedded resource")), "test_prompt_with_image" => { - let image_content = RawImageContent { - data: TEST_IMAGE_DATA.into(), - mime_type: "image/png".into(), - meta: None, - }; + let image_content = ImageContent::new(TEST_IMAGE_DATA, "image/png"); Ok(GetPromptResult::new(vec![ - PromptMessage::new_text(PromptMessageRole::User, "Here is an image:"), - PromptMessage::new( - PromptMessageRole::User, - PromptMessageContent::Image { - image: image_content.no_annotation(), - }, - ), + PromptMessage::new_text(Role::User, "Here is an image:"), + PromptMessage::new(Role::User, ContentBlock::Image(image_content)), ]) .with_description("A prompt with an image")) } @@ -787,6 +753,7 @@ impl ServerHandler for ConformanceServer { vec![prompt_ref.name.clone()] } } + _ => vec![], }; Ok(CompleteResult::new( CompletionInfo::new(values).map_err(|e| ErrorData::internal_error(e, None))?, diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index ba8df4b96..5815fd35f 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -111,7 +111,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { use rmcp::task_manager::current_timestamp; @@ -145,7 +145,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result syn::Result, ) -> Result { use std::time::Duration; @@ -242,7 +242,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result Service for H { .list_roots(context) .await .map(ClientResult::ListRootsResult), - ServerRequest::CreateElicitationRequest(request) => self + ServerRequest::ElicitRequest(request) => self .create_elicitation(request.params, context) .await - .map(ClientResult::CreateElicitationResult), + .map(ClientResult::ElicitResult), ServerRequest::CustomRequest(request) => self .on_custom_request(request, context) .await @@ -64,10 +64,13 @@ impl Service for H { ServerNotification::PromptListChangedNotification(_notification_no_param) => { self.on_prompt_list_changed(context).await } - ServerNotification::ElicitationCompletionNotification(notification) => { + ServerNotification::ElicitationCompleteNotification(notification) => { self.on_url_elicitation_notification_complete(notification.params, context) .await } + ServerNotification::TaskStatusNotification(notification) => { + self.on_task_status(notification.params, context).await + } ServerNotification::CustomNotification(notification) => { self.on_custom_notification(notification, context).await } @@ -125,7 +128,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { /// /// # Example /// ```rust,ignore - /// use rmcp::model::CreateElicitationRequestParam; + /// use rmcp::model::ElicitRequestParams; /// use rmcp::{ /// model::ErrorData as McpError, /// model::*, @@ -136,23 +139,23 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { /// impl ClientHandler for MyClient { /// async fn create_elicitation( /// &self, - /// request: CreateElicitationRequestParam, + /// request: ElicitRequestParams, /// context: RequestContext, - /// ) -> Result { + /// ) -> Result { /// match request { - /// CreateElicitationRequestParam::FormElicitationParam {meta, message, requested_schema,} => { + /// ElicitRequestParams::FormElicitationParam {meta, message, requested_schema,} => { /// // Display message to user and collect input according to requested_schema /// let user_input = get_user_input(message, requested_schema).await?; - /// Ok(CreateElicitationResult { + /// Ok(ElicitResult { /// action: ElicitationAction::Accept, /// content: Some(user_input), /// meta: None, /// }) /// } - /// CreateElicitationRequestParam::UrlElicitationParam {meta, message, url, elicitation_id,} => { + /// ElicitRequestParams::UrlElicitationParam {meta, message, url, elicitation_id,} => { /// // Open URL in browser for user to complete elicitation /// open_url_in_browser(url).await?; - /// Ok(CreateElicitationResult { + /// Ok(ElicitResult { /// action: ElicitationAction::Accept, /// content: None, /// meta: None, @@ -164,13 +167,12 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { /// ``` fn create_elicitation( &self, - request: CreateElicitationRequestParams, + request: ElicitRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ - { + ) -> impl Future> + MaybeSendFuture + '_ { // Default implementation declines all requests - real clients should override this let _ = (request, context); - std::future::ready(Ok(CreateElicitationResult { + std::future::ready(Ok(ElicitResult { action: ElicitationAction::Decline, content: None, meta: None, @@ -245,6 +247,13 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } + fn on_task_status( + &self, + params: TaskStatusNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } fn on_custom_notification( &self, notification: CustomNotification, @@ -283,22 +292,24 @@ macro_rules! impl_client_handler_for_wrapper { &self, params: CreateMessageRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ + { (**self).create_message(params, context) } fn list_roots( &self, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ + { (**self).list_roots(context) } fn create_elicitation( &self, - request: CreateElicitationRequestParams, + request: ElicitRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).create_elicitation(request, context) } @@ -363,6 +374,14 @@ macro_rules! impl_client_handler_for_wrapper { (**self).on_prompt_list_changed(context) } + fn on_task_status( + &self, + params: TaskStatusNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_task_status(params, context) + } + fn on_custom_notification( &self, notification: CustomNotification, diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index aea596703..54964559d 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -117,11 +117,11 @@ impl Service for H { .list_tasks(request.params, context) .await .map(ServerResult::ListTasksResult), - ClientRequest::GetTaskInfoRequest(request) => self + ClientRequest::GetTaskRequest(request) => self .get_task_info(request.params, context) .await .map(ServerResult::GetTaskResult), - ClientRequest::GetTaskResultRequest(request) => self + ClientRequest::GetTaskPayloadRequest(request) => self .get_task_result(request.params, context) .await .map(ServerResult::GetTaskPayloadResult), @@ -161,6 +161,9 @@ impl Service for H { ClientNotification::RootsListChangedNotification(_notification) => { self.on_roots_list_changed(context).await } + ClientNotification::TaskStatusNotification(notification) => { + self.on_task_status(notification.params, context).await + } ClientNotification::CustomNotification(notification) => { self.on_custom_notification(notification, context).await } @@ -359,6 +362,13 @@ macro_rules! server_handler_methods { ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } + fn on_task_status( + &self, + params: TaskStatusNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } fn on_custom_notification( &self, notification: CustomNotification, @@ -382,20 +392,20 @@ macro_rules! server_handler_methods { fn get_task_info( &self, - request: GetTaskInfoParams, + request: GetTaskParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) + std::future::ready(Err(McpError::method_not_found::())) } fn get_task_result( &self, - request: GetTaskResultParams, + request: GetTaskPayloadParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) + std::future::ready(Err(McpError::method_not_found::())) } fn cancel_task( @@ -578,6 +588,14 @@ macro_rules! impl_server_handler_for_wrapper { (**self).on_roots_list_changed(context) } + fn on_task_status( + &self, + params: TaskStatusNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_task_status(params, context) + } + fn on_custom_notification( &self, notification: CustomNotification, @@ -600,7 +618,7 @@ macro_rules! impl_server_handler_for_wrapper { fn get_task_info( &self, - request: GetTaskInfoParams, + request: GetTaskParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { (**self).get_task_info(request, context) @@ -608,7 +626,7 @@ macro_rules! impl_server_handler_for_wrapper { fn get_task_result( &self, - request: GetTaskResultParams, + request: GetTaskPayloadParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { (**self).get_task_result(request, context) diff --git a/crates/rmcp/src/handler/server/prompt.rs b/crates/rmcp/src/handler/server/prompt.rs index 11ca4bf83..c291ef70b 100644 --- a/crates/rmcp/src/handler/server/prompt.rs +++ b/crates/rmcp/src/handler/server/prompt.rs @@ -105,6 +105,7 @@ impl IntoGetPromptResult for Vec { Ok(GetPromptResult { description: None, messages: self, + meta: None, }) } } diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 35bd25a97..ae096c00c 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -137,7 +137,7 @@ use crate::{ tool::{CallToolHandler, DynCallToolHandler, ToolCallContext}, tool_name_validation::validate_and_warn_tool_name, }, - model::{CallToolResult, Content, ErrorCode, Tool, ToolAnnotations}, + model::{CallToolResult, ContentBlock, ErrorCode, Tool, ToolAnnotations}, service::{MaybeBoxFuture, MaybeSend}, }; @@ -149,7 +149,9 @@ fn into_tool_argument_error(error: crate::ErrorData) -> Result { pub service: &'s S, pub name: Cow<'static, str>, pub arguments: Option, - pub task: Option, + pub task: Option, } impl<'s, S> ToolCallContext<'s, S> { diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 5fb7eb9ed..cf2fe5900 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1,4 +1,8 @@ -use std::{borrow::Cow, sync::Arc}; +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::Arc, +}; mod annotated; mod capabilities; mod content; @@ -698,10 +702,24 @@ impl CustomResult { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct CancelledNotificationParam { - pub request_id: RequestId, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl CancelledNotificationParam { + pub fn new(request_id: Option, reason: Option) -> Self { + Self { + request_id, + reason, + meta: None, + } + } } const_string!(CancelledNotificationMethod = "notifications/cancelled"); @@ -864,6 +882,8 @@ pub struct InitializeResult { /// Optional human-readable instructions about using this server #[serde(skip_serializing_if = "Option::is_none")] pub instructions: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl InitializeResult { @@ -874,6 +894,7 @@ impl InitializeResult { capabilities, server_info: Implementation::from_build_env(), instructions: None, + meta: None, } } @@ -907,6 +928,7 @@ impl Default for ServerInfo { capabilities: ServerCapabilities::default(), server_info: Implementation::from_build_env(), instructions: None, + meta: None, } } } @@ -1107,7 +1129,7 @@ const_string!(ProgressNotificationMethod = "notifications/progress"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ProgressNotificationParam { pub progress_token: ProgressToken, /// The progress thus far. This should increase every time progress is made, even if the total is unknown. @@ -1118,6 +1140,8 @@ pub struct ProgressNotificationParam { /// An optional message describing the current progress. #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl ProgressNotificationParam { @@ -1128,6 +1152,7 @@ impl ProgressNotificationParam { progress, total: None, message: None, + meta: None, } } @@ -1250,12 +1275,17 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams; pub struct ReadResourceResult { /// The actual content of the resource pub contents: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl ReadResourceResult { /// Create a new ReadResourceResult with the given contents. pub fn new(contents: Vec) -> Self { - Self { contents } + Self { + contents, + meta: None, + } } } @@ -1352,16 +1382,21 @@ const_string!(ResourceUpdatedNotificationMethod = "notifications/resources/updat #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ResourceUpdatedNotificationParam { /// The URI of the resource that was updated pub uri: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl ResourceUpdatedNotificationParam { /// Create a new ResourceUpdatedNotificationParam. pub fn new(uri: impl Into) -> Self { - Self { uri: uri.into() } + Self { + uri: uri.into(), + meta: None, + } } } @@ -1506,7 +1541,7 @@ const_string!(LoggingMessageNotificationMethod = "notifications/message"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct LoggingMessageNotificationParam { /// The severity level of this log message pub level: LoggingLevel, @@ -1515,6 +1550,8 @@ pub struct LoggingMessageNotificationParam { pub logger: Option, /// The actual log data pub data: Value, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl LoggingMessageNotificationParam { @@ -1524,6 +1561,7 @@ impl LoggingMessageNotificationParam { level, logger: None, data, + meta: None, } } @@ -1564,7 +1602,7 @@ pub enum Role { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum ToolChoiceMode { /// Model decides whether to use tools #[default] @@ -1666,11 +1704,11 @@ impl SamplingContent { } } -impl SamplingMessageContent { +impl SamplingMessageContentBlock { /// Get the text content if this is a Text variant - pub fn as_text(&self) -> Option<&RawTextContent> { + pub fn as_text(&self) -> Option<&TextContent> { match self { - SamplingMessageContent::Text(text) => Some(text), + SamplingMessageContentBlock::Text(text) => Some(text), _ => None, } } @@ -1678,7 +1716,7 @@ impl SamplingMessageContent { /// Get the tool use content if this is a ToolUse variant pub fn as_tool_use(&self) -> Option<&ToolUseContent> { match self { - SamplingMessageContent::ToolUse(tool_use) => Some(tool_use), + SamplingMessageContentBlock::ToolUse(tool_use) => Some(tool_use), _ => None, } } @@ -1686,7 +1724,7 @@ impl SamplingMessageContent { /// Get the tool result content if this is a ToolResult variant pub fn as_tool_result(&self) -> Option<&ToolResultContent> { match self { - SamplingMessageContent::ToolResult(tool_result) => Some(tool_result), + SamplingMessageContentBlock::ToolResult(tool_result) => Some(tool_result), _ => None, } } @@ -1716,7 +1754,7 @@ pub struct SamplingMessage { /// The role of the message sender (User or Assistant) pub role: Role, /// The actual content of the message (text, image, audio, tool use, or tool result) - pub content: SamplingContent, + pub content: SamplingContent, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, } @@ -1725,37 +1763,37 @@ pub struct SamplingMessage { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum SamplingMessageContent { - Text(RawTextContent), - Image(RawImageContent), - Audio(RawAudioContent), +#[non_exhaustive] +pub enum SamplingMessageContentBlock { + Text(TextContent), + Image(ImageContent), + Audio(AudioContent), /// Assistant only ToolUse(ToolUseContent), /// User only ToolResult(ToolResultContent), } -impl SamplingMessageContent { +#[deprecated(since = "2.0.0", note = "Renamed to SamplingMessageContentBlock")] +pub type SamplingMessageContent = SamplingMessageContentBlock; + +impl SamplingMessageContentBlock { /// Create a text content pub fn text(text: impl Into) -> Self { - Self::Text(RawTextContent { - text: text.into(), - meta: None, - }) + Self::Text(TextContent::new(text)) } pub fn tool_use(id: impl Into, name: impl Into, input: JsonObject) -> Self { Self::ToolUse(ToolUseContent::new(id, name, input)) } - pub fn tool_result(tool_use_id: impl Into, content: Vec) -> Self { + pub fn tool_result(tool_use_id: impl Into, content: Vec) -> Self { Self::ToolResult(ToolResultContent::new(tool_use_id, content)) } } impl SamplingMessage { - pub fn new(role: Role, content: impl Into) -> Self { + pub fn new(role: Role, content: impl Into) -> Self { Self { role, content: SamplingContent::Single(content.into()), @@ -1763,7 +1801,7 @@ impl SamplingMessage { } } - pub fn new_multiple(role: Role, contents: Vec) -> Self { + pub fn new_multiple(role: Role, contents: Vec) -> Self { Self { role, content: SamplingContent::Multiple(contents), @@ -1772,17 +1810,17 @@ impl SamplingMessage { } pub fn user_text(text: impl Into) -> Self { - Self::new(Role::User, SamplingMessageContent::text(text)) + Self::new(Role::User, SamplingMessageContentBlock::text(text)) } pub fn assistant_text(text: impl Into) -> Self { - Self::new(Role::Assistant, SamplingMessageContent::text(text)) + Self::new(Role::Assistant, SamplingMessageContentBlock::text(text)) } - pub fn user_tool_result(tool_use_id: impl Into, content: Vec) -> Self { + pub fn user_tool_result(tool_use_id: impl Into, content: Vec) -> Self { Self::new( Role::User, - SamplingMessageContent::tool_result(tool_use_id, content), + SamplingMessageContentBlock::tool_result(tool_use_id, content), ) } @@ -1793,56 +1831,52 @@ impl SamplingMessage { ) -> Self { Self::new( Role::Assistant, - SamplingMessageContent::tool_use(id, name, input), + SamplingMessageContentBlock::tool_use(id, name, input), ) } } -// Conversion from RawTextContent to SamplingMessageContent -impl From for SamplingMessageContent { - fn from(text: RawTextContent) -> Self { - SamplingMessageContent::Text(text) +impl From for SamplingMessageContentBlock { + fn from(text: TextContent) -> Self { + SamplingMessageContentBlock::Text(text) } } -// Conversion from String to SamplingMessageContent (as text) -impl From for SamplingMessageContent { +// Conversion from String to SamplingMessageContentBlock (as text) +impl From for SamplingMessageContentBlock { fn from(text: String) -> Self { - SamplingMessageContent::text(text) + SamplingMessageContentBlock::text(text) } } -impl From<&str> for SamplingMessageContent { +impl From<&str> for SamplingMessageContentBlock { fn from(text: &str) -> Self { - SamplingMessageContent::text(text) + SamplingMessageContentBlock::text(text) } } -// Backward compatibility: Convert Content to SamplingMessageContent -// Note: Resource and ResourceLink variants are not supported in sampling messages -impl TryFrom for SamplingMessageContent { +impl TryFrom for SamplingMessageContentBlock { type Error = &'static str; - fn try_from(content: Content) -> Result { - match content.raw { - RawContent::Text(text) => Ok(SamplingMessageContent::Text(text)), - RawContent::Image(image) => Ok(SamplingMessageContent::Image(image)), - RawContent::Audio(audio) => Ok(SamplingMessageContent::Audio(audio)), - RawContent::Resource(_) => { + fn try_from(content: ContentBlock) -> Result { + match content { + ContentBlock::Text(text) => Ok(SamplingMessageContentBlock::Text(text)), + ContentBlock::Image(image) => Ok(SamplingMessageContentBlock::Image(image)), + ContentBlock::Audio(audio) => Ok(SamplingMessageContentBlock::Audio(audio)), + ContentBlock::Resource(_) => { Err("Resource content is not supported in sampling messages") } - RawContent::ResourceLink(_) => { + ContentBlock::ResourceLink(_) => { Err("ResourceLink content is not supported in sampling messages") } } } } -// Backward compatibility: Convert Content to SamplingContent -impl TryFrom for SamplingContent { +impl TryFrom for SamplingContent { type Error = &'static str; - fn try_from(content: Content) -> Result { + fn try_from(content: ContentBlock) -> Result { Ok(SamplingContent::Single(content.try_into()?)) } } @@ -1853,7 +1887,7 @@ impl TryFrom for SamplingContent { /// should be provided to the LLM when processing sampling requests. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum ContextInclusion { /// Include context from all connected MCP servers #[serde(rename = "allServers")] @@ -1884,7 +1918,7 @@ pub struct CreateMessageRequestParams { pub meta: Option, /// Task metadata for async task management (SEP-1319) #[serde(skip_serializing_if = "Option::is_none")] - pub task: Option, + pub task: Option, /// The conversation history and current messages pub messages: Vec, /// Preferences for model selection and behavior @@ -1925,10 +1959,10 @@ impl RequestParamsMeta for CreateMessageRequestParams { } impl TaskAugmentedRequestParamsMeta for CreateMessageRequestParams { - fn task(&self) -> Option<&JsonObject> { + fn task(&self) -> Option<&TaskMetadata> { self.task.as_ref() } - fn task_mut(&mut self) -> &mut Option { + fn task_mut(&mut self) -> &mut Option { &mut self.task } } @@ -2012,10 +2046,10 @@ impl CreateMessageRequestParams { for content in msg.content.iter() { // ToolUse only in assistant messages, ToolResult only in user messages match content { - SamplingMessageContent::ToolUse(_) if msg.role != Role::Assistant => { + SamplingMessageContentBlock::ToolUse(_) if msg.role != Role::Assistant => { return Err("ToolUse content is only allowed in assistant messages".into()); } - SamplingMessageContent::ToolResult(_) if msg.role != Role::User => { + SamplingMessageContentBlock::ToolResult(_) if msg.role != Role::User => { return Err("ToolResult content is only allowed in user messages".into()); } _ => {} @@ -2026,11 +2060,11 @@ impl CreateMessageRequestParams { let contents: Vec<_> = msg.content.iter().collect(); let has_tool_result = contents .iter() - .any(|c| matches!(c, SamplingMessageContent::ToolResult(_))); + .any(|c| matches!(c, SamplingMessageContentBlock::ToolResult(_))); if has_tool_result && contents .iter() - .any(|c| !matches!(c, SamplingMessageContent::ToolResult(_))) + .any(|c| !matches!(c, SamplingMessageContentBlock::ToolResult(_))) { return Err( "SamplingMessage with tool result content MUST NOT contain other content types" @@ -2050,13 +2084,13 @@ impl CreateMessageRequestParams { for msg in &self.messages { if msg.role == Role::Assistant { for content in msg.content.iter() { - if let SamplingMessageContent::ToolUse(tu) = content { + if let SamplingMessageContentBlock::ToolUse(tu) = content { pending_tool_use_ids.push(tu.id.clone()); } } } else if msg.role == Role::User { for content in msg.content.iter() { - if let SamplingMessageContent::ToolResult(tr) = content { + if let SamplingMessageContentBlock::ToolResult(tr) = content { if !pending_tool_use_ids.contains(&tr.tool_use_id) { return Err(format!( "ToolResult with toolUseId '{}' has no matching ToolUse", @@ -2181,7 +2215,7 @@ impl ModelHint { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct CompletionContext { /// Previously resolved argument values that can inform completion suggestions #[serde(skip_serializing_if = "Option::is_none")] @@ -2272,7 +2306,7 @@ pub type CompleteRequest = Request #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct CompletionInfo { pub values: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -2354,22 +2388,27 @@ impl CompletionInfo { #[non_exhaustive] pub struct CompleteResult { pub completion: CompletionInfo, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl CompleteResult { /// Create a new CompleteResult with the given completion info. pub fn new(completion: CompletionInfo) -> Self { - Self { completion } + Self { + completion, + meta: None, + } } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(tag = "type")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum Reference { #[serde(rename = "ref/resource")] - Resource(ResourceReference), + Resource(ResourceTemplateReference), #[serde(rename = "ref/prompt")] Prompt(PromptReference), } @@ -2388,7 +2427,7 @@ impl Reference { /// Create a resource reference pub fn for_resource(uri: impl Into) -> Self { - Self::Resource(ResourceReference { uri: uri.into() }) + Self::Resource(ResourceTemplateReference { uri: uri.into() }) } /// Get the reference type as a string @@ -2418,11 +2457,20 @@ impl Reference { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct ResourceReference { +#[non_exhaustive] +pub struct ResourceTemplateReference { pub uri: String, } +impl ResourceTemplateReference { + pub fn new(uri: impl Into) -> Self { + Self { uri: uri.into() } + } +} + +#[deprecated(since = "2.0.0", note = "Renamed to ResourceTemplateReference")] +pub type ResourceReference = ResourceTemplateReference; + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] @@ -2452,12 +2500,21 @@ const_string!(CompleteRequestMethod = "completion/complete"); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ArgumentInfo { pub name: String, pub value: String, } +impl ArgumentInfo { + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: value.into(), + } + } +} + // ============================================================================= // ROOTS AND WORKSPACE MANAGEMENT // ============================================================================= @@ -2469,6 +2526,8 @@ pub struct Root { pub uri: String, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl Root { @@ -2477,6 +2536,7 @@ impl Root { Self { uri: uri.into(), name: None, + meta: None, } } @@ -2485,6 +2545,12 @@ impl Root { self.name = Some(name.into()); self } + + /// Sets the protocol-level metadata for this root. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } } const_string!(ListRootsRequestMethod = "roots/list"); @@ -2496,12 +2562,20 @@ pub type ListRootsRequest = RequestNoParam; #[non_exhaustive] pub struct ListRootsResult { pub roots: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl ListRootsResult { /// Creates a new `ListRootsResult` with the given roots. pub fn new(roots: Vec) -> Self { - Self { roots } + Self { roots, meta: None } + } + + /// Sets the protocol-level metadata for this result. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self } } @@ -2527,7 +2601,7 @@ const_string!(ElicitationCompletionNotificationMethod = "notifications/elicitati #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "lowercase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum ElicitationAction { /// User accepts the request and provides the requested information Accept, @@ -2567,7 +2641,7 @@ enum CreateElicitationRequestParamDeserializeHelper { }, } -impl TryFrom for CreateElicitationRequestParams { +impl TryFrom for ElicitRequestParams { type Error = serde_json::Error; fn try_from( @@ -2583,7 +2657,7 @@ impl TryFrom for CreateElicitati meta, message, requested_schema, - } => Ok(CreateElicitationRequestParams::FormElicitationParams { + } => Ok(ElicitRequestParams::FormElicitationParams { meta, message, requested_schema, @@ -2593,7 +2667,7 @@ impl TryFrom for CreateElicitati message, url, elicitation_id, - } => Ok(CreateElicitationRequestParams::UrlElicitationParams { + } => Ok(ElicitRequestParams::UrlElicitationParams { meta, message, url, @@ -2614,7 +2688,7 @@ impl TryFrom for CreateElicitati /// ```rust /// use rmcp::model::*; /// -/// let params = CreateElicitationRequestParams::FormElicitationParams { +/// let params = ElicitRequestParams::FormElicitationParams { /// meta: None, /// message: "Please provide your email".to_string(), /// requested_schema: ElicitationSchema::builder() @@ -2626,7 +2700,7 @@ impl TryFrom for CreateElicitati /// 2. URL-based elicitation request /// ```rust /// use rmcp::model::*; -/// let params = CreateElicitationRequestParams::UrlElicitationParams { +/// let params = ElicitRequestParams::UrlElicitationParams { /// meta: None, /// message: "Please provide your feedback at the following URL".to_string(), /// url: "https://example.com/feedback".to_string(), @@ -2639,8 +2713,8 @@ impl TryFrom for CreateElicitati try_from = "CreateElicitationRequestParamDeserializeHelper" )] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum CreateElicitationRequestParams { +#[non_exhaustive] +pub enum ElicitRequestParams { #[serde(rename = "form", rename_all = "camelCase")] FormElicitationParams { /// Protocol-level metadata for this request (SEP-1319) @@ -2674,24 +2748,27 @@ pub enum CreateElicitationRequestParams { }, } -impl RequestParamsMeta for CreateElicitationRequestParams { +impl RequestParamsMeta for ElicitRequestParams { fn meta(&self) -> Option<&Meta> { match self { - CreateElicitationRequestParams::FormElicitationParams { meta, .. } => meta.as_ref(), - CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta.as_ref(), + ElicitRequestParams::FormElicitationParams { meta, .. } => meta.as_ref(), + ElicitRequestParams::UrlElicitationParams { meta, .. } => meta.as_ref(), } } fn meta_mut(&mut self) -> &mut Option { match self { - CreateElicitationRequestParams::FormElicitationParams { meta, .. } => meta, - CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta, + ElicitRequestParams::FormElicitationParams { meta, .. } => meta, + ElicitRequestParams::UrlElicitationParams { meta, .. } => meta, } } } -/// Deprecated: Use [`CreateElicitationRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use CreateElicitationRequestParams instead")] -pub type CreateElicitationRequestParam = CreateElicitationRequestParams; +/// Deprecated: Use [`ElicitRequestParams`] instead (SEP-1319 compliance). +#[deprecated(since = "0.13.0", note = "Use ElicitRequestParams instead")] +pub type CreateElicitationRequestParam = ElicitRequestParams; + +#[deprecated(since = "2.0.0", note = "Renamed to ElicitRequestParams")] +pub type CreateElicitationRequestParams = ElicitRequestParams; /// The result returned by a client in response to an elicitation request. /// @@ -2700,8 +2777,8 @@ pub type CreateElicitationRequestParam = CreateElicitationRequestParams; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct CreateElicitationResult { +#[non_exhaustive] +pub struct ElicitResult { /// The user's decision on how to handle the elicitation request pub action: ElicitationAction, @@ -2716,8 +2793,8 @@ pub struct CreateElicitationResult { pub meta: Option, } -impl CreateElicitationResult { - /// Create a new CreateElicitationResult. +impl ElicitResult { + /// Create a new ElicitResult. pub fn new(action: ElicitationAction) -> Self { Self { action, @@ -2739,17 +2816,24 @@ impl CreateElicitationResult { } } +#[deprecated(since = "2.0.0", note = "Renamed to ElicitResult")] +pub type CreateElicitationResult = ElicitResult; + /// Request type for creating an elicitation to gather user input -pub type CreateElicitationRequest = - Request; +pub type ElicitRequest = Request; + +#[deprecated(since = "2.0.0", note = "Renamed to ElicitRequest")] +pub type CreateElicitationRequest = ElicitRequest; /// Notification parameters for an url elicitation completion notification. #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ElicitationResponseNotificationParam { pub elicitation_id: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl ElicitationResponseNotificationParam { @@ -2757,14 +2841,18 @@ impl ElicitationResponseNotificationParam { pub fn new(elicitation_id: impl Into) -> Self { Self { elicitation_id: elicitation_id.into(), + meta: None, } } } /// Notification sent when an url elicitation process is completed. -pub type ElicitationCompletionNotification = +pub type ElicitationCompleteNotification = Notification; +#[deprecated(since = "2.0.0", note = "Renamed to ElicitationCompleteNotification")] +pub type ElicitationCompletionNotification = ElicitationCompleteNotification; + // ============================================================================= // TOOL EXECUTION RESULTS // ============================================================================= @@ -2780,7 +2868,7 @@ pub type ElicitationCompletionNotification = pub struct CallToolResult { /// The content returned by the tool (text, images, etc.) #[serde(default)] - pub content: Vec, + pub content: Vec, /// An optional JSON object that represents the structured result of the tool call #[serde(skip_serializing_if = "Option::is_none")] pub structured_content: Option, @@ -2805,7 +2893,7 @@ impl<'de> Deserialize<'de> for CallToolResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Helper { - content: Option>, + content: Option>, structured_content: Option, is_error: Option, #[serde(rename = "_meta")] @@ -2836,7 +2924,7 @@ impl<'de> Deserialize<'de> for CallToolResult { impl CallToolResult { /// Create a successful tool result with unstructured content - pub fn success(content: Vec) -> Self { + pub fn success(content: Vec) -> Self { CallToolResult { content, structured_content: None, @@ -2883,17 +2971,17 @@ impl CallToolResult { /// // Tool ran, no result. Caller should see the explanation: /// let rows = run_query(query).await; /// if rows.is_empty() { - /// return Ok(CallToolResult::error(vec![Content::text( + /// return Ok(CallToolResult::error(vec![ContentBlock::text( /// format!("no rows matched '{query}'"), /// )])); /// } /// - /// Ok(CallToolResult::success(vec![Content::text(format_rows(&rows))])) + /// Ok(CallToolResult::success(vec![ContentBlock::text(format_rows(&rows))])) /// } /// # async fn run_query(_: &str) -> Vec<&'static str> { vec![] } /// # fn format_rows(_: &[&str]) -> String { String::new() } /// ``` - pub fn error(content: Vec) -> Self { + pub fn error(content: Vec) -> Self { CallToolResult { content, structured_content: None, @@ -2917,7 +3005,7 @@ impl CallToolResult { /// ``` pub fn structured(value: Value) -> Self { CallToolResult { - content: vec![Content::text(value.to_string())], + content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(false), meta: None, @@ -2943,7 +3031,7 @@ impl CallToolResult { /// ``` pub fn structured_error(value: Value) -> Self { CallToolResult { - content: vec![Content::text(value.to_string())], + content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(true), meta: None, @@ -3018,7 +3106,7 @@ pub struct CallToolRequestParams { pub arguments: Option, /// Task metadata for async task management (SEP-1319) #[serde(skip_serializing_if = "Option::is_none")] - pub task: Option, + pub task: Option, } impl CallToolRequestParams { @@ -3039,7 +3127,7 @@ impl CallToolRequestParams { } /// Sets the task metadata for this tool call. - pub fn with_task(mut self, task: JsonObject) -> Self { + pub fn with_task(mut self, task: TaskMetadata) -> Self { self.task = Some(task); self } @@ -3055,10 +3143,10 @@ impl RequestParamsMeta for CallToolRequestParams { } impl TaskAugmentedRequestParamsMeta for CallToolRequestParams { - fn task(&self) -> Option<&JsonObject> { + fn task(&self) -> Option<&TaskMetadata> { self.task.as_ref() } - fn task_mut(&mut self) -> &mut Option { + fn task_mut(&mut self) -> &mut Option { &mut self.task } } @@ -3134,6 +3222,8 @@ pub struct GetPromptResult { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub messages: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl GetPromptResult { @@ -3142,6 +3232,7 @@ impl GetPromptResult { Self { description: None, messages, + meta: None, } } @@ -3156,21 +3247,35 @@ impl GetPromptResult { // TASK MANAGEMENT // ============================================================================= -const_string!(GetTaskInfoMethod = "tasks/get"); -pub type GetTaskInfoRequest = Request; +const_string!(GetTaskMethod = "tasks/get"); +pub type GetTaskRequest = Request; + +#[deprecated(since = "2.0.0", note = "Renamed to GetTaskMethod")] +pub type GetTaskInfoMethod = GetTaskMethod; +#[deprecated(since = "2.0.0", note = "Renamed to GetTaskRequest")] +pub type GetTaskInfoRequest = GetTaskRequest; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct GetTaskInfoParams { +#[non_exhaustive] +pub struct GetTaskParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, pub task_id: String, } -impl RequestParamsMeta for GetTaskInfoParams { +impl GetTaskParams { + pub fn new(task_id: impl Into) -> Self { + Self { + meta: None, + task_id: task_id.into(), + } + } +} + +impl RequestParamsMeta for GetTaskParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() } @@ -3179,28 +3284,44 @@ impl RequestParamsMeta for GetTaskInfoParams { } } -/// Deprecated: Use [`GetTaskInfoParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use GetTaskInfoParams instead")] -pub type GetTaskInfoParam = GetTaskInfoParams; +#[deprecated(since = "2.0.0", note = "Renamed to GetTaskParams")] +pub type GetTaskInfoParams = GetTaskParams; + +#[deprecated(since = "0.13.0", note = "Use GetTaskParams instead")] +pub type GetTaskInfoParam = GetTaskParams; const_string!(ListTasksMethod = "tasks/list"); pub type ListTasksRequest = RequestOptionalParam; -const_string!(GetTaskResultMethod = "tasks/result"); -pub type GetTaskResultRequest = Request; +const_string!(GetTaskPayloadMethod = "tasks/result"); +pub type GetTaskPayloadRequest = Request; + +#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadMethod")] +pub type GetTaskResultMethod = GetTaskPayloadMethod; +#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadRequest")] +pub type GetTaskResultRequest = GetTaskPayloadRequest; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct GetTaskResultParams { +#[non_exhaustive] +pub struct GetTaskPayloadParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, pub task_id: String, } -impl RequestParamsMeta for GetTaskResultParams { +impl GetTaskPayloadParams { + pub fn new(task_id: impl Into) -> Self { + Self { + meta: None, + task_id: task_id.into(), + } + } +} + +impl RequestParamsMeta for GetTaskPayloadParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() } @@ -3209,9 +3330,10 @@ impl RequestParamsMeta for GetTaskResultParams { } } -/// Deprecated: Use [`GetTaskResultParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use GetTaskResultParams instead")] -pub type GetTaskResultParam = GetTaskResultParams; +#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadParams")] +pub type GetTaskResultParams = GetTaskPayloadParams; +#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadParams")] +pub type GetTaskResultParam = GetTaskPayloadParams; const_string!(CancelTaskMethod = "tasks/cancel"); pub type CancelTaskRequest = Request; @@ -3219,7 +3341,7 @@ pub type CancelTaskRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct CancelTaskParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -3227,6 +3349,15 @@ pub struct CancelTaskParams { pub task_id: String, } +impl CancelTaskParams { + pub fn new(task_id: impl Into) -> Self { + Self { + meta: None, + task_id: task_id.into(), + } + } +} + impl RequestParamsMeta for CancelTaskParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() @@ -3239,6 +3370,59 @@ impl RequestParamsMeta for CancelTaskParams { /// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance). #[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")] pub type CancelTaskParam = CancelTaskParams; + +// --------------------------------------------------------------------------- +// Task status notification (spec `notifications/tasks/status`) +// --------------------------------------------------------------------------- +const_string!(TaskStatusNotificationMethod = "notifications/tasks/status"); + +/// Parameters for a task status notification (spec `TaskStatusNotificationParams`). +/// +/// The task fields are flattened at the top level: `NotificationParams & Task`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct TaskStatusNotificationParam { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(flatten)] + pub task: crate::model::Task, +} + +impl TaskStatusNotificationParam { + pub fn new(task: crate::model::Task) -> Self { + Self { meta: None, task } + } + + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } +} + +impl From for TaskStatusNotificationParam { + fn from(task: crate::model::Task) -> Self { + Self::new(task) + } +} + +impl Deref for TaskStatusNotificationParam { + type Target = crate::model::Task; + + fn deref(&self) -> &Self::Target { + &self.task + } +} + +impl DerefMut for TaskStatusNotificationParam { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.task + } +} + +pub type TaskStatusNotification = + Notification; /// Deprecated: Use [`GetTaskResult`] instead (spec alignment). #[deprecated(since = "0.15.0", note = "Use GetTaskResult instead")] pub type GetTaskInfoResult = GetTaskResult; @@ -3251,17 +3435,16 @@ pub struct ListTasksResult { pub tasks: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub total: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl ListTasksResult { - /// Create a new ListTasksResult. pub fn new(tasks: Vec) -> Self { Self { tasks, next_cursor: None, - total: None, + meta: None, } } } @@ -3335,9 +3518,9 @@ ts_union!( | UnsubscribeRequest | CallToolRequest | ListToolsRequest - | GetTaskInfoRequest + | GetTaskRequest | ListTasksRequest - | GetTaskResultRequest + | GetTaskPayloadRequest | CancelTaskRequest | CustomRequest; ); @@ -3358,9 +3541,9 @@ impl ClientRequest { ClientRequest::UnsubscribeRequest(r) => r.method.as_str(), ClientRequest::CallToolRequest(r) => r.method.as_str(), ClientRequest::ListToolsRequest(r) => r.method.as_str(), - ClientRequest::GetTaskInfoRequest(r) => r.method.as_str(), + ClientRequest::GetTaskRequest(r) => r.method.as_str(), ClientRequest::ListTasksRequest(r) => r.method.as_str(), - ClientRequest::GetTaskResultRequest(r) => r.method.as_str(), + ClientRequest::GetTaskPayloadRequest(r) => r.method.as_str(), ClientRequest::CancelTaskRequest(r) => r.method.as_str(), ClientRequest::CustomRequest(r) => r.method.as_str(), } @@ -3373,6 +3556,7 @@ ts_union!( | ProgressNotification | InitializedNotification | RootsListChangedNotification + | TaskStatusNotification | CustomNotification; ); @@ -3380,7 +3564,7 @@ ts_union!( export type ClientResult = box CreateMessageResult | ListRootsResult - | CreateElicitationResult + | ElicitResult | EmptyResult | CustomResult; ); @@ -3398,7 +3582,7 @@ ts_union!( | PingRequest | CreateMessageRequest | ListRootsRequest - | CreateElicitationRequest + | ElicitRequest | CustomRequest; ); @@ -3411,7 +3595,8 @@ ts_union!( | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification - | ElicitationCompletionNotification + | ElicitationCompleteNotification + | TaskStatusNotification | CustomNotification; ); @@ -3425,7 +3610,7 @@ ts_union!( | ListResourceTemplatesResult | ReadResourceResult | ListToolsResult - | CreateElicitationResult + | ElicitResult | CreateTaskResult | ListTasksResult | GetTaskResult @@ -3477,6 +3662,30 @@ mod tests { use super::*; + #[test] + #[allow(deprecated)] + fn deprecated_aliases_still_resolve() { + // 하위호환: 구 이름이 새 타입으로 여전히 resolve되는지 확인. + let _: CreateElicitationResult = ElicitResult::new(ElicitationAction::Accept); + let _: GetTaskResultParams = GetTaskPayloadParams::new("task-1"); + let _: ResourceReference = ResourceTemplateReference::new("res://x"); + } + + #[test] + fn cancelled_notification_request_id_is_optional_on_wire() { + // None → requestId 생략 + let p = CancelledNotificationParam::new(None, Some("user cancelled".into())); + let v = serde_json::to_value(&p).unwrap(); + assert!(v.get("requestId").is_none()); + + // Some → requestId 방출 + 라운드트립 + let p = CancelledNotificationParam::new(Some(RequestId::Number(1)), None); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["requestId"], json!(1)); + let back: CancelledNotificationParam = serde_json::from_value(v).unwrap(); + assert_eq!(back.request_id, Some(RequestId::Number(1))); + } + #[test] fn test_notification_serde() { let raw = json!( { @@ -3704,6 +3913,7 @@ mod tests { capabilities, server_info, instructions, + .. }) => { assert_eq!(capabilities.logging.unwrap().len(), 0); assert_eq!(capabilities.prompts.unwrap().list_changed, Some(true)); @@ -3924,6 +4134,7 @@ mod tests { website_url: Some("https://docs.example.com".to_string()), }, instructions: None, + meta: None, }; let json = serde_json::to_value(&init_result).unwrap(); @@ -3952,9 +4163,9 @@ mod tests { "required": ["name", "age"] } }); - let elicitation: CreateElicitationRequestParams = + let elicitation: ElicitRequestParams = serde_json::from_value(json_data_without_tag).expect("Deserialization failed"); - if let CreateElicitationRequestParams::FormElicitationParams { + if let ElicitRequestParams::FormElicitationParams { meta, message, requested_schema, @@ -3985,9 +4196,9 @@ mod tests { "required": ["name", "age"] } }); - let elicitation_form: CreateElicitationRequestParams = + let elicitation_form: ElicitRequestParams = serde_json::from_value(json_data_form).expect("Deserialization failed"); - if let CreateElicitationRequestParams::FormElicitationParams { + if let ElicitRequestParams::FormElicitationParams { meta, message, requested_schema, @@ -4011,9 +4222,9 @@ mod tests { "url": "https://example.com/form", "elicitationId": "elicitation-123" }); - let elicitation_url: CreateElicitationRequestParams = + let elicitation_url: ElicitRequestParams = serde_json::from_value(json_data_url).expect("Deserialization failed"); - if let CreateElicitationRequestParams::UrlElicitationParams { + if let ElicitRequestParams::UrlElicitationParams { meta, message, url, @@ -4034,7 +4245,7 @@ mod tests { #[test] fn test_elicitation_serialization() { - let form_elicitation = CreateElicitationRequestParams::FormElicitationParams { + let form_elicitation = ElicitRequestParams::FormElicitationParams { meta: Some(Meta(object!({ "meta_form_key_1": "meta form value 1" }))), message: "Please provide more details.".to_string(), requested_schema: ElicitationSchema::builder() @@ -4058,7 +4269,7 @@ mod tests { }); assert_eq!(json_form, expected_form_json); - let url_elicitation = CreateElicitationRequestParams::UrlElicitationParams { + let url_elicitation = ElicitRequestParams::UrlElicitationParams { meta: Some(Meta(object!({ "meta_url_key_1": "meta url value 1" }))), message: "Please fill out the form at the following URL.".to_string(), url: "https://example.com/form".to_string(), diff --git a/crates/rmcp/src/model/annotated.rs b/crates/rmcp/src/model/annotated.rs index e2e750824..06800d1f9 100644 --- a/crates/rmcp/src/model/annotated.rs +++ b/crates/rmcp/src/model/annotated.rs @@ -1,13 +1,15 @@ -use std::ops::{Deref, DerefMut}; +//! Annotations for content blocks and resources. +//! +//! The `Annotations` struct carries optional hints about audience, priority, and freshness. +//! Individual content/resource types embed `annotations: Option` directly. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use super::{ - RawAudioContent, RawContent, RawEmbeddedResource, RawImageContent, RawResource, - RawResourceTemplate, RawTextContent, Role, -}; +use super::Role; +/// Optional annotations for the client. The client can use annotations to inform how objects are +/// used or displayed. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -35,192 +37,23 @@ impl Annotations { audience: None, } } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct Annotated { - #[serde(flatten)] - pub raw: T, - #[serde(skip_serializing_if = "Option::is_none")] - pub annotations: Option, -} -impl Deref for Annotated { - type Target = T; - fn deref(&self) -> &Self::Target { - &self.raw + pub fn with_audience(mut self, audience: Vec) -> Self { + self.audience = Some(audience); + self } -} -impl DerefMut for Annotated { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.raw + pub fn with_priority(mut self, priority: f32) -> Self { + self.priority = Some(priority); + self } -} -impl Annotated { - pub fn new(raw: T, annotations: Option) -> Self { - Self { raw, annotations } - } - pub fn remove_annotation(&mut self) -> Option { - self.annotations.take() - } - pub fn audience(&self) -> Option<&Vec> { - self.annotations.as_ref().and_then(|a| a.audience.as_ref()) - } - pub fn priority(&self) -> Option { - self.annotations.as_ref().and_then(|a| a.priority) - } - pub fn timestamp(&self) -> Option> { - self.annotations.as_ref().and_then(|a| a.last_modified) - } - pub fn with_audience(self, audience: Vec) -> Annotated - where - Self: Sized, - { - if let Some(annotations) = self.annotations { - Annotated { - raw: self.raw, - annotations: Some(Annotations { - audience: Some(audience), - ..annotations - }), - } - } else { - Annotated { - raw: self.raw, - annotations: Some(Annotations { - audience: Some(audience), - priority: None, - last_modified: None, - }), - } - } - } - pub fn with_priority(self, priority: f32) -> Annotated - where - Self: Sized, - { - if let Some(annotations) = self.annotations { - Annotated { - raw: self.raw, - annotations: Some(Annotations { - priority: Some(priority), - ..annotations - }), - } - } else { - Annotated { - raw: self.raw, - annotations: Some(Annotations { - priority: Some(priority), - last_modified: None, - audience: None, - }), - } - } - } - pub fn with_timestamp(self, timestamp: DateTime) -> Annotated - where - Self: Sized, - { - if let Some(annotations) = self.annotations { - Annotated { - raw: self.raw, - annotations: Some(Annotations { - last_modified: Some(timestamp), - ..annotations - }), - } - } else { - Annotated { - raw: self.raw, - annotations: Some(Annotations { - last_modified: Some(timestamp), - priority: None, - audience: None, - }), - } - } - } - pub fn with_timestamp_now(self) -> Annotated - where - Self: Sized, - { - self.with_timestamp(Utc::now()) + pub fn with_timestamp(mut self, timestamp: DateTime) -> Self { + self.last_modified = Some(timestamp); + self } -} - -mod sealed { - pub trait Sealed {} -} -macro_rules! annotate { - ($T: ident) => { - impl sealed::Sealed for $T {} - impl AnnotateAble for $T {} - }; -} - -annotate!(RawContent); -annotate!(RawTextContent); -annotate!(RawImageContent); -annotate!(RawAudioContent); -annotate!(RawEmbeddedResource); -annotate!(RawResource); -annotate!(RawResourceTemplate); -pub trait AnnotateAble: sealed::Sealed { - fn optional_annotate(self, annotations: Option) -> Annotated - where - Self: Sized, - { - Annotated::new(self, annotations) - } - fn annotate(self, annotations: Annotations) -> Annotated - where - Self: Sized, - { - Annotated::new(self, Some(annotations)) - } - fn no_annotation(self) -> Annotated - where - Self: Sized, - { - Annotated::new(self, None) - } - fn with_audience(self, audience: Vec) -> Annotated - where - Self: Sized, - { - self.annotate(Annotations { - audience: Some(audience), - ..Default::default() - }) - } - fn with_priority(self, priority: f32) -> Annotated - where - Self: Sized, - { - self.annotate(Annotations { - priority: Some(priority), - ..Default::default() - }) - } - fn with_timestamp(self, timestamp: DateTime) -> Annotated - where - Self: Sized, - { - self.annotate(Annotations { - last_modified: Some(timestamp), - ..Default::default() - }) - } - fn with_timestamp_now(self) -> Annotated - where - Self: Sized, - { + pub fn with_timestamp_now(self) -> Self { self.with_timestamp(Utc::now()) } } diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 1d32f975a..b42e40c67 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -34,7 +34,7 @@ pub type ExtensionCapabilities = BTreeMap; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct PromptsCapability { #[serde(skip_serializing_if = "Option::is_none")] pub list_changed: Option, @@ -43,7 +43,7 @@ pub struct PromptsCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ResourcesCapability { #[serde(skip_serializing_if = "Option::is_none")] pub subscribe: Option, @@ -54,7 +54,7 @@ pub struct ResourcesCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ToolsCapability { #[serde(skip_serializing_if = "Option::is_none")] pub list_changed: Option, @@ -66,7 +66,7 @@ pub struct ToolsCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct RootsCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub list_changed: Option, @@ -76,7 +76,7 @@ pub struct RootsCapabilities { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct TasksCapability { #[serde(skip_serializing_if = "Option::is_none")] pub requests: Option, @@ -90,7 +90,7 @@ pub struct TasksCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct TaskRequestsCapability { #[serde(skip_serializing_if = "Option::is_none")] pub sampling: Option, @@ -106,7 +106,7 @@ pub struct TaskRequestsCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct SamplingTaskCapability { #[serde(skip_serializing_if = "Option::is_none")] pub create_message: Option, @@ -115,7 +115,7 @@ pub struct SamplingTaskCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ElicitationTaskCapability { #[serde(skip_serializing_if = "Option::is_none")] pub create: Option, @@ -124,7 +124,7 @@ pub struct ElicitationTaskCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ToolsTaskCapability { #[serde(skip_serializing_if = "Option::is_none")] pub call: Option, @@ -205,7 +205,7 @@ impl TasksCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct FormElicitationCapability { /// Whether the client supports JSON Schema validation for elicitation responses. /// When true, the client will validate user input against the requested_schema @@ -214,19 +214,36 @@ pub struct FormElicitationCapability { pub schema_validation: Option, } +impl FormElicitationCapability { + pub fn new() -> Self { + Self::default() + } + + pub fn with_schema_validation(mut self, enabled: bool) -> Self { + self.schema_validation = Some(enabled); + self + } +} + /// Capability for URL mode elicitation. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct UrlElicitationCapability {} +impl UrlElicitationCapability { + pub fn new() -> Self { + Self::default() + } +} + /// Elicitation allows servers to request interactive input from users during tool execution. /// This capability indicates that a client can handle elicitation requests and present /// appropriate UI to users for collecting the requested information. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ElicitationCapability { /// Whether client supports form-based elicitation. #[serde(skip_serializing_if = "Option::is_none")] @@ -236,6 +253,22 @@ pub struct ElicitationCapability { pub url: Option, } +impl ElicitationCapability { + pub fn new() -> Self { + Self::default() + } + + pub fn with_form(mut self, form: FormElicitationCapability) -> Self { + self.form = Some(form); + self + } + + pub fn with_url(mut self, url: UrlElicitationCapability) -> Self { + self.url = Some(url); + self + } +} + /// Sampling capability with optional sub-capabilities (SEP-1577). /// /// Deprecated by SEP-2577; remains functional and will be removed in a future @@ -244,7 +277,7 @@ pub struct ElicitationCapability { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct SamplingCapability { /// Support for `tools` and `toolChoice` parameters #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index 7054e2b0e..c32f81b3d 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -1,78 +1,177 @@ -//! Content sent around agents, extensions, and LLMs -//! The various content types can be display to humans but also understood by models -//! They include optional annotations used to help inform agent usage +//! Content types that flow between agents, tools, prompts, and LLMs. +//! +//! The core union is [`ContentBlock`] (text | image | audio | resource_link | resource), +//! matching the MCP 2025-11-25 `ContentBlock` definition. Each variant carries optional +//! [`Annotations`] and `_meta` inline. +//! +//! [`SamplingMessageContentBlock`] extends the union with `tool_use` and `tool_result` +//! variants for sampling messages (SEP-1577). + use serde::{Deserialize, Serialize}; use serde_json::json; -use super::{AnnotateAble, Annotated, resource::ResourceContents}; +use super::{Annotations, Meta, resource::ResourceContents}; + +// --------------------------------------------------------------------------- +// Flat content structs +// --------------------------------------------------------------------------- +/// Text content block (spec `TextContent`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RawTextContent { +#[non_exhaustive] +pub struct TextContent { + /// The text content of the message. pub text: String, - /// Optional protocol-level metadata for this content block + /// Optional protocol-level metadata for this content block. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, + /// Optional annotations describing how the client should use this content. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl TextContent { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + meta: None, + annotations: None, + } + } + + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } } -pub type TextContent = Annotated; + +/// Image content with base64-encoded data (spec `ImageContent`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RawImageContent { - /// The base64-encoded image +#[non_exhaustive] +pub struct ImageContent { + /// The base64-encoded image data. pub data: String, + /// The MIME type of the image (e.g. `image/png`). pub mime_type: String, - /// Optional protocol-level metadata for this content block + /// Optional protocol-level metadata for this content block. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, + /// Optional annotations describing how the client should use this content. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, } -pub type ImageContent = Annotated; +impl ImageContent { + pub fn new(data: impl Into, mime_type: impl Into) -> Self { + Self { + data: data.into(), + mime_type: mime_type.into(), + meta: None, + annotations: None, + } + } + + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} + +/// Audio content with base64-encoded data (spec `AudioContent`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RawEmbeddedResource { - /// Optional protocol-level metadata for this content block +#[non_exhaustive] +pub struct AudioContent { + /// The base64-encoded audio data. + pub data: String, + /// The MIME type of the audio (e.g. `audio/wav`). + pub mime_type: String, + /// Optional protocol-level metadata for this content block. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - pub resource: ResourceContents, + pub meta: Option, + /// Optional annotations describing how the client should use this content. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, } -impl RawEmbeddedResource { - /// Create a new RawEmbeddedResource. - pub fn new(resource: ResourceContents) -> Self { +impl AudioContent { + pub fn new(data: impl Into, mime_type: impl Into) -> Self { Self { + data: data.into(), + mime_type: mime_type.into(), meta: None, - resource, + annotations: None, } } + + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } } -pub type EmbeddedResource = Annotated; +/// Embedded resource content (spec `EmbeddedResource`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct EmbeddedResource { + /// The embedded resource contents (text or blob). + pub resource: ResourceContents, + /// Optional protocol-level metadata for this content block. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Optional annotations describing how the client should use this content. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} impl EmbeddedResource { + pub fn new(resource: ResourceContents) -> Self { + Self { + resource, + meta: None, + annotations: None, + } + } + pub fn get_text(&self) -> String { match &self.resource { ResourceContents::TextResourceContents { text, .. } => text.clone(), _ => String::new(), } } -} -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RawAudioContent { - pub data: String, - pub mime_type: String, -} + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } -pub type AudioContent = Annotated; + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} /// Tool call request from assistant (SEP-1577). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -80,15 +179,11 @@ pub type AudioContent = Annotated; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct ToolUseContent { - /// Unique identifier for this tool call pub id: String, - /// Name of the tool to call pub name: String, - /// Input arguments for the tool pub input: super::JsonObject, - /// Optional metadata (preserved for caching) #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } /// Tool execution result in user message (SEP-1577). @@ -97,18 +192,12 @@ pub struct ToolUseContent { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct ToolResultContent { - /// Optional metadata #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, - /// ID of the corresponding tool use + pub meta: Option, pub tool_use_id: String, - /// Content blocks returned by the tool - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub content: Vec, - /// Optional structured result + pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub structured_content: Option, - /// Whether tool execution failed #[serde(skip_serializing_if = "Option::is_none")] pub is_error: Option, } @@ -125,7 +214,7 @@ impl ToolUseContent { } impl ToolResultContent { - pub fn new(tool_use_id: impl Into, content: Vec) -> Self { + pub fn new(tool_use_id: impl Into, content: Vec) -> Self { Self { meta: None, tool_use_id: tool_use_id.into(), @@ -135,7 +224,7 @@ impl ToolResultContent { } } - pub fn error(tool_use_id: impl Into, content: Vec) -> Self { + pub fn error(tool_use_id: impl Into, content: Vec) -> Self { Self { meta: None, tool_use_id: tool_use_id.into(), @@ -146,21 +235,26 @@ impl ToolResultContent { } } +// --------------------------------------------------------------------------- +// ContentBlock — the unified content union (spec `ContentBlock`) +// --------------------------------------------------------------------------- + +/// Unified content block union (spec `ContentBlock`). +/// +/// `text | image | audio | resource_link | resource` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum RawContent { - Text(RawTextContent), - Image(RawImageContent), - Resource(RawEmbeddedResource), - Audio(RawAudioContent), - ResourceLink(super::resource::RawResource), +#[non_exhaustive] +pub enum ContentBlock { + Text(TextContent), + Image(ImageContent), + Audio(AudioContent), + Resource(EmbeddedResource), + ResourceLink(super::resource::Resource), } -pub type Content = Annotated; - -impl RawContent { +impl ContentBlock { pub fn json(json: S) -> Result { let json = serde_json::to_string(&json).map_err(|e| { crate::ErrorData::internal_error( @@ -170,129 +264,106 @@ impl RawContent { )), ) })?; - Ok(RawContent::text(json)) + Ok(ContentBlock::text(json)) } - pub fn text>(text: S) -> Self { - RawContent::Text(RawTextContent { - text: text.into(), - meta: None, - }) + pub fn text(text: impl Into) -> Self { + ContentBlock::Text(TextContent::new(text)) } - pub fn image, T: Into>(data: S, mime_type: T) -> Self { - RawContent::Image(RawImageContent { - data: data.into(), - mime_type: mime_type.into(), - meta: None, - }) + pub fn image(data: impl Into, mime_type: impl Into) -> Self { + ContentBlock::Image(ImageContent::new(data, mime_type)) + } + + pub fn audio(data: impl Into, mime_type: impl Into) -> Self { + ContentBlock::Audio(AudioContent::new(data, mime_type)) } pub fn resource(resource: ResourceContents) -> Self { - RawContent::Resource(RawEmbeddedResource { - meta: None, - resource, - }) + ContentBlock::Resource(EmbeddedResource::new(resource)) } - pub fn embedded_text, T: Into>(uri: S, content: T) -> Self { - RawContent::Resource(RawEmbeddedResource { - meta: None, - resource: ResourceContents::TextResourceContents { + pub fn embedded_text(uri: impl Into, content: impl Into) -> Self { + ContentBlock::Resource(EmbeddedResource::new( + ResourceContents::TextResourceContents { uri: uri.into(), mime_type: Some("text".to_string()), text: content.into(), meta: None, }, - }) + )) + } + + pub fn resource_link(resource: super::resource::Resource) -> Self { + ContentBlock::ResourceLink(resource) } - /// Get the text content if this is a TextContent variant - pub fn as_text(&self) -> Option<&RawTextContent> { + pub fn as_text(&self) -> Option<&TextContent> { match self { - RawContent::Text(text) => Some(text), + ContentBlock::Text(text) => Some(text), _ => None, } } - /// Get the image content if this is an ImageContent variant - pub fn as_image(&self) -> Option<&RawImageContent> { + pub fn as_image(&self) -> Option<&ImageContent> { match self { - RawContent::Image(image) => Some(image), + ContentBlock::Image(image) => Some(image), _ => None, } } - /// Get the resource content if this is an ImageContent variant - pub fn as_resource(&self) -> Option<&RawEmbeddedResource> { + pub fn as_resource(&self) -> Option<&EmbeddedResource> { match self { - RawContent::Resource(resource) => Some(resource), + ContentBlock::Resource(resource) => Some(resource), _ => None, } } - /// Get the resource link if this is a ResourceLink variant - pub fn as_resource_link(&self) -> Option<&super::resource::RawResource> { + pub fn as_resource_link(&self) -> Option<&super::resource::Resource> { match self { - RawContent::ResourceLink(link) => Some(link), + ContentBlock::ResourceLink(link) => Some(link), _ => None, } } - /// Create a resource link content - pub fn resource_link(resource: super::resource::RawResource) -> Self { - RawContent::ResourceLink(resource) + pub fn as_audio(&self) -> Option<&AudioContent> { + match self { + ContentBlock::Audio(audio) => Some(audio), + _ => None, + } } } -impl Content { - pub fn text>(text: S) -> Self { - RawContent::text(text).no_annotation() - } - - pub fn image, T: Into>(data: S, mime_type: T) -> Self { - RawContent::image(data, mime_type).no_annotation() - } - - pub fn resource(resource: ResourceContents) -> Self { - RawContent::resource(resource).no_annotation() - } - - pub fn embedded_text, T: Into>(uri: S, content: T) -> Self { - RawContent::embedded_text(uri, content).no_annotation() - } - - pub fn json(json: S) -> Result { - RawContent::json(json).map(|c| c.no_annotation()) - } - - /// Create a resource link content - pub fn resource_link(resource: super::resource::RawResource) -> Self { - RawContent::resource_link(resource).no_annotation() - } -} +// --------------------------------------------------------------------------- +// JsonContent (unchanged) +// --------------------------------------------------------------------------- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct JsonContent(S); -/// Types that can be converted into a list of contents + +// --------------------------------------------------------------------------- +// IntoContents +// --------------------------------------------------------------------------- + +/// Types that can be converted into a list of content blocks. pub trait IntoContents { - fn into_contents(self) -> Vec; + fn into_contents(self) -> Vec; } -impl IntoContents for Content { - fn into_contents(self) -> Vec { +impl IntoContents for ContentBlock { + fn into_contents(self) -> Vec { vec![self] } } impl IntoContents for String { - fn into_contents(self) -> Vec { - vec![Content::text(self)] + fn into_contents(self) -> Vec { + vec![ContentBlock::text(self)] } } impl IntoContents for () { - fn into_contents(self) -> Vec { + fn into_contents(self) -> Vec { vec![] } } @@ -305,40 +376,32 @@ mod tests { #[test] fn test_image_content_serialization() { - let image_content = RawImageContent { - data: "base64data".to_string(), - mime_type: "image/png".to_string(), - meta: None, - }; - - let json = serde_json::to_string(&image_content).unwrap(); - println!("ImageContent JSON: {}", json); - - // Verify it contains mimeType (camelCase) not mime_type (snake_case) + let image = ImageContent::new("base64data", "image/png"); + let json = serde_json::to_string(&image).unwrap(); assert!(json.contains("mimeType")); assert!(!json.contains("mime_type")); } #[test] fn test_audio_content_serialization() { - let audio_content = RawAudioContent { - data: "base64audiodata".to_string(), - mime_type: "audio/wav".to_string(), - }; - - let json = serde_json::to_string(&audio_content).unwrap(); - println!("AudioContent JSON: {}", json); - - // Verify it contains mimeType (camelCase) not mime_type (snake_case) + let audio = AudioContent::new("base64audiodata", "audio/wav"); + let json = serde_json::to_string(&audio).unwrap(); assert!(json.contains("mimeType")); assert!(!json.contains("mime_type")); } + #[test] + fn test_audio_content_has_meta() { + let audio = AudioContent::new("data", "audio/wav").with_meta(Meta::default()); + let json = serde_json::to_value(&audio).unwrap(); + assert!(json.get("_meta").is_some()); + } + #[test] fn test_resource_link_serialization() { - use super::super::resource::RawResource; + use super::super::resource::Resource; - let resource_link = RawContent::ResourceLink(RawResource { + let resource_link = ContentBlock::ResourceLink(Resource { uri: "file:///test.txt".to_string(), name: "test.txt".to_string(), title: None, @@ -347,12 +410,10 @@ mod tests { size: Some(100), icons: None, meta: None, + annotations: None, }); let json = serde_json::to_string(&resource_link).unwrap(); - println!("ResourceLink JSON: {}", json); - - // Verify it contains the correct type tag assert!(json.contains("\"type\":\"resource_link\"")); assert!(json.contains("\"uri\":\"file:///test.txt\"")); assert!(json.contains("\"name\":\"test.txt\"")); @@ -368,9 +429,9 @@ mod tests { "mimeType": "text/plain" }"#; - let content: RawContent = serde_json::from_str(json).unwrap(); + let content: ContentBlock = serde_json::from_str(json).unwrap(); - if let RawContent::ResourceLink(resource) = content { + if let ContentBlock::ResourceLink(resource) = content { assert_eq!(resource.uri, "file:///example.txt"); assert_eq!(resource.name, "example.txt"); assert_eq!(resource.description, Some("Example file".to_string())); @@ -379,4 +440,15 @@ mod tests { panic!("Expected ResourceLink variant"); } } + + #[test] + fn test_content_block_text_with_annotations() { + let block = ContentBlock::Text( + TextContent::new("hello").with_annotations(Annotations::default().with_priority(0.8)), + ); + let json = serde_json::to_value(&block).unwrap(); + assert_eq!(json["type"], "text"); + assert_eq!(json["text"], "hello"); + assert_eq!(json["annotations"]["priority"], 0.8_f32); + } } diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 0e8244c46..d2712f463 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -49,8 +49,8 @@ const_string!(ArrayTypeConst = "array"); #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum PrimitiveSchema { +#[non_exhaustive] +pub enum PrimitiveSchemaDefinition { /// Enum property (explicit enum schema) Enum(EnumSchema), /// String property (with optional enum constraint) @@ -63,6 +63,9 @@ pub enum PrimitiveSchema { Boolean(BooleanSchema), } +#[deprecated(since = "2.0.0", note = "Renamed to PrimitiveSchemaDefinition")] +pub type PrimitiveSchema = PrimitiveSchemaDefinition; + // ============================================================================= // STRING SCHEMA // ============================================================================= @@ -71,7 +74,7 @@ pub enum PrimitiveSchema { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum StringFormat { /// Email address format Email, @@ -346,7 +349,7 @@ impl NumberSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct IntegerSchema { /// Type discriminator #[serde(rename = "type")] @@ -513,7 +516,7 @@ impl BooleanSchema { /// Represent single entry for titled item #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ConstTitle { #[serde(rename = "const")] pub const_: String, @@ -534,7 +537,7 @@ impl ConstTitle { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct LegacyEnumSchema { #[serde(rename = "type")] pub type_: StringTypeConst, @@ -546,6 +549,21 @@ pub struct LegacyEnumSchema { pub enum_: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub enum_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl LegacyEnumSchema { + pub fn new(enum_values: Vec) -> Self { + Self { + type_: StringTypeConst, + title: None, + description: None, + enum_: enum_values, + enum_names: None, + default: None, + } + } } /// Untitled single-select @@ -601,7 +619,7 @@ impl TitledSingleSelectEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum SingleSelectEnumSchema { Untitled(UntitledSingleSelectEnumSchema), Titled(TitledSingleSelectEnumSchema), @@ -610,7 +628,7 @@ pub enum SingleSelectEnumSchema { /// Items for untitled multi-select options #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct UntitledItems { #[serde(rename = "type")] pub type_: StringTypeConst, @@ -618,10 +636,19 @@ pub struct UntitledItems { pub enum_: Vec, } +impl UntitledItems { + pub fn new(enum_values: Vec) -> Self { + Self { + type_: StringTypeConst, + enum_: enum_values, + } + } +} + /// Items for titled multi-select options #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct TitledItems { // MCP spec requires "anyOf" for multi-select enums (allows any combination) // Alias "oneOf" for compatibility with schemars @@ -727,7 +754,7 @@ impl TitledMultiSelectEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum MultiSelectEnumSchema { Untitled(UntitledMultiSelectEnumSchema), Titled(TitledMultiSelectEnumSchema), @@ -751,7 +778,7 @@ pub enum MultiSelectEnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum EnumSchema { Single(SingleSelectEnumSchema), Multi(MultiSelectEnumSchema), @@ -1090,7 +1117,7 @@ impl EnumSchema { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct ElicitationSchema { /// Always "object" for elicitation schemas #[serde(rename = "type")] @@ -1101,7 +1128,7 @@ pub struct ElicitationSchema { pub title: Option>, /// Property definitions (must be primitive types) - pub properties: BTreeMap, + pub properties: BTreeMap, /// List of required property names #[serde(skip_serializing_if = "Option::is_none")] @@ -1114,7 +1141,7 @@ pub struct ElicitationSchema { impl ElicitationSchema { /// Create a new elicitation schema with the given properties - pub fn new(properties: BTreeMap) -> Self { + pub fn new(properties: BTreeMap) -> Self { Self { type_: ObjectTypeConst, title: None, @@ -1237,7 +1264,7 @@ impl ElicitationSchema { #[derive(Debug, Default)] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct ElicitationSchemaBuilder { - pub properties: BTreeMap, + pub properties: BTreeMap, pub required: Vec, pub title: Option>, pub description: Option>, @@ -1250,13 +1277,17 @@ impl ElicitationSchemaBuilder { } /// Add a property to the schema - pub fn property(mut self, name: impl Into, schema: PrimitiveSchema) -> Self { + pub fn property(mut self, name: impl Into, schema: PrimitiveSchemaDefinition) -> Self { self.properties.insert(name.into(), schema); self } /// Add a required property to the schema - pub fn required_property(mut self, name: impl Into, schema: PrimitiveSchema) -> Self { + pub fn required_property( + mut self, + name: impl Into, + schema: PrimitiveSchemaDefinition, + ) -> Self { let name_str = name.into(); self.required.push(name_str.clone()); self.properties.insert(name_str, schema); @@ -1264,7 +1295,7 @@ impl ElicitationSchemaBuilder { } // =========================================================================== - // TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchema wrapper + // TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchemaDefinition wrapper // =========================================================================== /// Add a string property with custom builder (required) @@ -1273,8 +1304,10 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(StringSchema) -> StringSchema, ) -> Self { - self.properties - .insert(name.into(), PrimitiveSchema::String(f(StringSchema::new()))); + self.properties.insert( + name.into(), + PrimitiveSchemaDefinition::String(f(StringSchema::new())), + ); self } @@ -1286,8 +1319,10 @@ impl ElicitationSchemaBuilder { ) -> Self { let name_str = name.into(); self.required.push(name_str.clone()); - self.properties - .insert(name_str, PrimitiveSchema::String(f(StringSchema::new()))); + self.properties.insert( + name_str, + PrimitiveSchemaDefinition::String(f(StringSchema::new())), + ); self } @@ -1297,8 +1332,10 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(NumberSchema) -> NumberSchema, ) -> Self { - self.properties - .insert(name.into(), PrimitiveSchema::Number(f(NumberSchema::new()))); + self.properties.insert( + name.into(), + PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), + ); self } @@ -1310,8 +1347,10 @@ impl ElicitationSchemaBuilder { ) -> Self { let name_str = name.into(); self.required.push(name_str.clone()); - self.properties - .insert(name_str, PrimitiveSchema::Number(f(NumberSchema::new()))); + self.properties.insert( + name_str, + PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), + ); self } @@ -1323,7 +1362,7 @@ impl ElicitationSchemaBuilder { ) -> Self { self.properties.insert( name.into(), - PrimitiveSchema::Integer(f(IntegerSchema::new())), + PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), ); self } @@ -1336,8 +1375,10 @@ impl ElicitationSchemaBuilder { ) -> Self { let name_str = name.into(); self.required.push(name_str.clone()); - self.properties - .insert(name_str, PrimitiveSchema::Integer(f(IntegerSchema::new()))); + self.properties.insert( + name_str, + PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), + ); self } @@ -1349,7 +1390,7 @@ impl ElicitationSchemaBuilder { ) -> Self { self.properties.insert( name.into(), - PrimitiveSchema::Boolean(f(BooleanSchema::new())), + PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), ); self } @@ -1362,8 +1403,10 @@ impl ElicitationSchemaBuilder { ) -> Self { let name_str = name.into(); self.required.push(name_str.clone()); - self.properties - .insert(name_str, PrimitiveSchema::Boolean(f(BooleanSchema::new()))); + self.properties.insert( + name_str, + PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), + ); self } @@ -1373,22 +1416,28 @@ impl ElicitationSchemaBuilder { /// Add a required string property pub fn required_string(self, name: impl Into) -> Self { - self.required_property(name, PrimitiveSchema::String(StringSchema::new())) + self.required_property(name, PrimitiveSchemaDefinition::String(StringSchema::new())) } /// Add an optional string property pub fn optional_string(self, name: impl Into) -> Self { - self.property(name, PrimitiveSchema::String(StringSchema::new())) + self.property(name, PrimitiveSchemaDefinition::String(StringSchema::new())) } /// Add a required email property pub fn required_email(self, name: impl Into) -> Self { - self.required_property(name, PrimitiveSchema::String(StringSchema::email())) + self.required_property( + name, + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) } /// Add an optional email property pub fn optional_email(self, name: impl Into) -> Self { - self.property(name, PrimitiveSchema::String(StringSchema::email())) + self.property( + name, + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) } /// Add a required string property with custom builder @@ -1397,7 +1446,10 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(StringSchema) -> StringSchema, ) -> Self { - self.required_property(name, PrimitiveSchema::String(f(StringSchema::new()))) + self.required_property( + name, + PrimitiveSchemaDefinition::String(f(StringSchema::new())), + ) } /// Add an optional string property with custom builder @@ -1406,7 +1458,10 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(StringSchema) -> StringSchema, ) -> Self { - self.property(name, PrimitiveSchema::String(f(StringSchema::new()))) + self.property( + name, + PrimitiveSchemaDefinition::String(f(StringSchema::new())), + ) } // Convenience methods for numbers @@ -1415,7 +1470,7 @@ impl ElicitationSchemaBuilder { pub fn required_number(self, name: impl Into, min: f64, max: f64) -> Self { self.required_property( name, - PrimitiveSchema::Number(NumberSchema::new().range(min, max)), + PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)), ) } @@ -1423,7 +1478,7 @@ impl ElicitationSchemaBuilder { pub fn optional_number(self, name: impl Into, min: f64, max: f64) -> Self { self.property( name, - PrimitiveSchema::Number(NumberSchema::new().range(min, max)), + PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)), ) } @@ -1433,7 +1488,10 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(NumberSchema) -> NumberSchema, ) -> Self { - self.required_property(name, PrimitiveSchema::Number(f(NumberSchema::new()))) + self.required_property( + name, + PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), + ) } /// Add an optional number property with custom builder @@ -1442,7 +1500,10 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(NumberSchema) -> NumberSchema, ) -> Self { - self.property(name, PrimitiveSchema::Number(f(NumberSchema::new()))) + self.property( + name, + PrimitiveSchemaDefinition::Number(f(NumberSchema::new())), + ) } // Convenience methods for integers @@ -1451,7 +1512,7 @@ impl ElicitationSchemaBuilder { pub fn required_integer(self, name: impl Into, min: i64, max: i64) -> Self { self.required_property( name, - PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)), + PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)), ) } @@ -1459,7 +1520,7 @@ impl ElicitationSchemaBuilder { pub fn optional_integer(self, name: impl Into, min: i64, max: i64) -> Self { self.property( name, - PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)), + PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)), ) } @@ -1469,7 +1530,10 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(IntegerSchema) -> IntegerSchema, ) -> Self { - self.required_property(name, PrimitiveSchema::Integer(f(IntegerSchema::new()))) + self.required_property( + name, + PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), + ) } /// Add an optional integer property with custom builder @@ -1478,21 +1542,27 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(IntegerSchema) -> IntegerSchema, ) -> Self { - self.property(name, PrimitiveSchema::Integer(f(IntegerSchema::new()))) + self.property( + name, + PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())), + ) } // Convenience methods for booleans /// Add a required boolean property pub fn required_bool(self, name: impl Into) -> Self { - self.required_property(name, PrimitiveSchema::Boolean(BooleanSchema::new())) + self.required_property( + name, + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) } /// Add an optional boolean property with default value pub fn optional_bool(self, name: impl Into, default: bool) -> Self { self.property( name, - PrimitiveSchema::Boolean(BooleanSchema::new().with_default(default)), + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new().with_default(default)), ) } @@ -1502,7 +1572,10 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(BooleanSchema) -> BooleanSchema, ) -> Self { - self.required_property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new()))) + self.required_property( + name, + PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), + ) } /// Add an optional boolean property with custom builder @@ -1511,19 +1584,22 @@ impl ElicitationSchemaBuilder { name: impl Into, f: impl FnOnce(BooleanSchema) -> BooleanSchema, ) -> Self { - self.property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new()))) + self.property( + name, + PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())), + ) } // Enum convenience methods /// Add a required enum property using EnumSchema pub fn required_enum_schema(self, name: impl Into, enum_schema: EnumSchema) -> Self { - self.required_property(name, PrimitiveSchema::Enum(enum_schema)) + self.required_property(name, PrimitiveSchemaDefinition::Enum(enum_schema)) } /// Add an optional enum property using EnumSchema pub fn optional_enum_schema(self, name: impl Into, enum_schema: EnumSchema) -> Self { - self.property(name, PrimitiveSchema::Enum(enum_schema)) + self.property(name, PrimitiveSchemaDefinition::Enum(enum_schema)) } /// Add a required enum property using values. Creates an untitled single-select enum. @@ -1534,12 +1610,13 @@ impl ElicitationSchemaBuilder { pub fn required_enum(self, name: impl Into, values: Vec) -> Self { self.required_property( name, - PrimitiveSchema::Enum(EnumSchema::Legacy(LegacyEnumSchema { + PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema { type_: StringTypeConst, title: None, description: None, enum_: values, enum_names: None, + default: None, })), ) } @@ -1552,12 +1629,13 @@ impl ElicitationSchemaBuilder { pub fn optional_enum(self, name: impl Into, values: Vec) -> Self { self.property( name, - PrimitiveSchema::Enum(EnumSchema::Legacy(LegacyEnumSchema { + PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema { type_: StringTypeConst, title: None, description: None, enum_: values, enum_names: None, + default: None, })), ) } @@ -1732,6 +1810,7 @@ mod tests { description: Some("A legacy enum schema".into()), enum_: vec!["A".to_string(), "B".to_string()], enum_names: Some(vec!["Option A".to_string(), "Option B".to_string()]), + default: None, }); let json = serde_json::to_value(&schema)?; @@ -1776,6 +1855,7 @@ mod tests { description: None, enum_: vec!["a".to_string(), "b".to_string()], enum_names: None, + default: None, }); let json = serde_json::to_value(&schema)?; assert!(!json.as_object().unwrap().contains_key("enumNames")); @@ -1970,14 +2050,14 @@ mod tests { "type": "string", "enum": ["a", "b"] }); - let schema: PrimitiveSchema = serde_json::from_value(json).unwrap(); - assert!(matches!(schema, PrimitiveSchema::Enum(_))); + let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap(); + assert!(matches!(schema, PrimitiveSchemaDefinition::Enum(_))); // Test that string schemas deserialize as String variant let json = json!({ "type": "string" }); - let schema: PrimitiveSchema = serde_json::from_value(json).unwrap(); - assert!(matches!(schema, PrimitiveSchema::String(_))); + let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap(); + assert!(matches!(schema, PrimitiveSchemaDefinition::String(_))); } #[test] diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 186db6a24..4c9cd618a 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -5,7 +5,7 @@ use serde_json::Value; use super::{ ClientNotification, ClientRequest, CustomNotification, CustomRequest, Extensions, JsonObject, - JsonRpcMessage, NumberOrString, ProgressToken, ServerNotification, ServerRequest, + JsonRpcMessage, NumberOrString, ProgressToken, ServerNotification, ServerRequest, TaskMetadata, }; pub trait GetMeta { @@ -54,11 +54,11 @@ pub trait RequestParamsMeta { /// can include a `task` field to signal that the caller wants task-augmented execution. pub trait TaskAugmentedRequestParamsMeta: RequestParamsMeta { /// Get a reference to the task field - fn task(&self) -> Option<&JsonObject>; + fn task(&self) -> Option<&TaskMetadata>; /// Get a mutable reference to the task field - fn task_mut(&mut self) -> &mut Option; + fn task_mut(&mut self) -> &mut Option; /// Set the task field - fn set_task(&mut self, task: JsonObject) { + fn set_task(&mut self, task: TaskMetadata) { *self.task_mut() = Some(task); } } @@ -152,9 +152,9 @@ variant_extension! { CallToolRequest ListToolsRequest CustomRequest - GetTaskInfoRequest + GetTaskRequest ListTasksRequest - GetTaskResultRequest + GetTaskPayloadRequest CancelTaskRequest } } @@ -164,7 +164,7 @@ variant_extension! { PingRequest CreateMessageRequest ListRootsRequest - CreateElicitationRequest + ElicitRequest CustomRequest } } @@ -175,6 +175,7 @@ variant_extension! { ProgressNotification InitializedNotification RootsListChangedNotification + TaskStatusNotification CustomNotification } } @@ -188,7 +189,8 @@ variant_extension! { ResourceListChangedNotification ToolListChangedNotification PromptListChangedNotification - ElicitationCompletionNotification + ElicitationCompleteNotification + TaskStatusNotification CustomNotification } } diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index a44183fbd..e438260b5 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -1,37 +1,31 @@ use serde::{Deserialize, Serialize}; use super::{ - AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource, - content::{AudioContent, EmbeddedResource, ImageContent}, + Annotations, ContentBlock, Icon, Meta, Role, + content::{AudioContent, EmbeddedResource, ImageContent, TextContent}, resource::ResourceContents, }; -/// A prompt that can be used to generate text from a model +/// A prompt or prompt template that the server offers (spec `Prompt`). #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct Prompt { - /// The name of the prompt pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, - /// Optional description of what the prompt does #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - /// Optional arguments that can be passed to customize the prompt #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option>, - /// Optional list of icons for the prompt #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, - /// Optional additional metadata for this prompt #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, } impl Prompt { - /// Create a new prompt with the given name, description and arguments pub fn new( name: N, description: Option, @@ -51,7 +45,6 @@ impl Prompt { } } - /// Create a new prompt from raw fields (used by the macro) pub fn from_raw( name: impl Into, description: Option>, @@ -67,45 +60,37 @@ impl Prompt { } } - /// Set the human-readable title pub fn with_title(mut self, title: impl Into) -> Self { self.title = Some(title.into()); self } - /// Set the icons pub fn with_icons(mut self, icons: Vec) -> Self { self.icons = Some(icons); self } - /// Set the metadata pub fn with_meta(mut self, meta: Meta) -> Self { self.meta = Some(meta); self } } -/// Represents a prompt argument that can be passed to customize the prompt +/// Describes an argument that a prompt can accept (spec `PromptArgument`). #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct PromptArgument { - /// The name of the argument pub name: String, - /// A human-readable title for the argument #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, - /// A description of what the argument is used for #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - /// Whether this argument is required #[serde(skip_serializing_if = "Option::is_none")] pub required: Option, } impl PromptArgument { - /// Create a new prompt argument pub fn new>(name: N) -> Self { PromptArgument { name: name.into(), @@ -115,108 +100,51 @@ impl PromptArgument { } } - /// Set the title pub fn with_title>(mut self, title: T) -> Self { self.title = Some(title.into()); self } - /// Set the description pub fn with_description>(mut self, description: D) -> Self { self.description = Some(description.into()); self } - /// Set the required flag pub fn with_required(mut self, required: bool) -> Self { self.required = Some(required); self } } -/// Represents the role of a message sender in a prompt conversation -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum PromptMessageRole { - User, - Assistant, -} - -/// Content types that can be included in prompt messages -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum PromptMessageContent { - /// Plain text content - Text { text: String }, - /// Image content with base64-encoded data - Image { - #[serde(flatten)] - image: ImageContent, - }, - /// Audio content with base64-encoded data - Audio { - #[serde(flatten)] - audio: AudioContent, - }, - /// Embedded server-side resource - Resource { - #[serde(flatten)] - resource: EmbeddedResource, - }, - /// A link to a resource that can be fetched separately - ResourceLink { - #[serde(flatten)] - link: super::resource::Resource, - }, -} - -impl PromptMessageContent { - pub fn text(text: impl Into) -> Self { - Self::Text { text: text.into() } - } - - /// Create a resource link content - pub fn resource_link(resource: super::resource::Resource) -> Self { - Self::ResourceLink { link: resource } - } -} - -/// A message in a prompt conversation +/// A message returned as part of a prompt (spec `PromptMessage`). +/// +/// Uses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct PromptMessage { - /// The role of the message sender - pub role: PromptMessageRole, - /// The content of the message - pub content: PromptMessageContent, + pub role: Role, + pub content: ContentBlock, } impl PromptMessage { - /// Create a new prompt message with the given role and content - pub fn new(role: PromptMessageRole, content: PromptMessageContent) -> Self { + pub fn new(role: Role, content: ContentBlock) -> Self { Self { role, content } } - /// Create a new text message with the given role and text content - pub fn new_text>(role: PromptMessageRole, text: S) -> Self { + pub fn new_text>(role: Role, text: S) -> Self { Self { role, - content: PromptMessageContent::Text { text: text.into() }, + content: ContentBlock::text(text), } } - /// Create a new image message. `meta` and `annotations` are optional. #[cfg(feature = "base64")] pub fn new_image( - role: PromptMessageRole, + role: Role, data: &[u8], mime_type: &str, - meta: Option, + meta: Option, annotations: Option, ) -> Self { use base64::{Engine, prelude::BASE64_STANDARD}; @@ -224,23 +152,21 @@ impl PromptMessage { let base64 = BASE64_STANDARD.encode(data); Self { role, - content: PromptMessageContent::Image { - image: crate::model::RawImageContent { - data: base64, - mime_type: mime_type.into(), - meta, - } - .optional_annotate(annotations), - }, + content: ContentBlock::Image(ImageContent { + data: base64, + mime_type: mime_type.into(), + meta, + annotations, + }), } } - /// Create a new audio message. `annotations` is optional. #[cfg(feature = "base64")] pub fn new_audio( - role: PromptMessageRole, + role: Role, data: &[u8], mime_type: &str, + meta: Option, annotations: Option, ) -> Self { use base64::{Engine, prelude::BASE64_STANDARD}; @@ -248,24 +174,22 @@ impl PromptMessage { let base64 = BASE64_STANDARD.encode(data); Self { role, - content: PromptMessageContent::Audio { - audio: crate::model::RawAudioContent { - data: base64, - mime_type: mime_type.into(), - } - .optional_annotate(annotations), - }, + content: ContentBlock::Audio(AudioContent { + data: base64, + mime_type: mime_type.into(), + meta, + annotations, + }), } } - /// Create a new resource message. `resource_meta`, `resource_content_meta`, and `annotations` are optional. pub fn new_resource( - role: PromptMessageRole, + role: Role, uri: String, mime_type: Option, text: Option, - resource_meta: Option, - resource_content_meta: Option, + resource_meta: Option, + resource_content_meta: Option, annotations: Option, ) -> Self { let resource_contents = match text { @@ -284,31 +208,29 @@ impl PromptMessage { }; Self { role, - content: PromptMessageContent::Resource { - resource: RawEmbeddedResource { - meta: resource_meta, - resource: resource_contents, - } - .optional_annotate(annotations), - }, + content: ContentBlock::Resource(EmbeddedResource { + meta: resource_meta, + resource: resource_contents, + annotations, + }), } } - /// Note: PromptMessage text content does not carry protocol-level _meta per current schema. - /// This function exists for API symmetry but ignores the meta parameter. - pub fn new_text_with_meta>( - role: PromptMessageRole, - text: S, - _meta: Option, - ) -> Self { - Self::new_text(role, text) + pub fn new_text_with_meta>(role: Role, text: S, meta: Option) -> Self { + Self { + role, + content: ContentBlock::Text(TextContent { + text: text.into(), + meta, + annotations: None, + }), + } } - /// Create a new resource link message - pub fn new_resource_link(role: PromptMessageRole, resource: super::resource::Resource) -> Self { + pub fn new_resource_link(role: Role, resource: super::resource::Resource) -> Self { Self { role, - content: PromptMessageContent::ResourceLink { link: resource }, + content: ContentBlock::ResourceLink(resource), } } } @@ -321,35 +243,15 @@ mod tests { #[test] fn test_prompt_message_image_serialization() { - let image_content = crate::model::RawImageContent { - data: "base64data".to_string(), - mime_type: "image/png".to_string(), - meta: None, - }; - - let json = serde_json::to_string(&image_content).unwrap(); - println!("PromptMessage ImageContent JSON: {}", json); - - // Verify it contains mimeType (camelCase) not mime_type (snake_case) + let image = ImageContent::new("base64data", "image/png"); + let json = serde_json::to_string(&image).unwrap(); assert!(json.contains("mimeType")); assert!(!json.contains("mime_type")); } #[test] fn test_prompt_message_audio_serialization_and_deserialization() { - // Audio is part of the spec's ContentBlock union for prompt messages - // (text | image | audio | resource_link | resource). Ensure the Audio - // variant serializes to the flat, spec-compliant shape - // `{ "type": "audio", "data", "mimeType" }` and parses back. - // See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts - let content = PromptMessageContent::Audio { - audio: crate::model::RawAudioContent { - data: "YXVkaW8=".to_string(), - mime_type: "audio/wav".to_string(), - } - .no_annotation(), - }; - + let content = ContentBlock::Audio(AudioContent::new("YXVkaW8=", "audio/wav")); let value = serde_json::to_value(&content).unwrap(); assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("audio")); assert_eq!(value.get("data").and_then(|v| v.as_str()), Some("YXVkaW8=")); @@ -359,18 +261,15 @@ mod tests { "expected camelCase mimeType, got: {value:#?}" ); - // Regression: a spec-valid audio content block must deserialize into - // the Audio variant (previously failed with "unknown variant `audio`"). let json = r#"{"type":"audio","data":"YXVkaW8=","mimeType":"audio/wav"}"#; - let parsed: PromptMessageContent = serde_json::from_str(json).unwrap(); + let parsed: ContentBlock = serde_json::from_str(json).unwrap(); assert_eq!(parsed, content); } #[test] #[cfg(feature = "base64")] fn test_prompt_message_new_audio_constructor() { - let message = - PromptMessage::new_audio(PromptMessageRole::User, b"hello", "audio/wav", None); + let message = PromptMessage::new_audio(Role::User, b"hello", "audio/wav", None, None); let value = serde_json::to_value(&message).unwrap(); let content = value.get("content").expect("content present"); assert_eq!(content.get("type").and_then(|v| v.as_str()), Some("audio")); @@ -378,7 +277,6 @@ mod tests { content.get("mimeType").and_then(|v| v.as_str()), Some("audio/wav") ); - // base64 of "hello" assert_eq!( content.get("data").and_then(|v| v.as_str()), Some("aGVsbG8=") @@ -387,16 +285,12 @@ mod tests { #[test] fn test_prompt_message_resource_link_serialization() { - use super::super::resource::RawResource; + use super::super::resource::Resource; - let resource = RawResource::new("file:///test.txt", "test.txt"); - let message = - PromptMessage::new_resource_link(PromptMessageRole::User, resource.no_annotation()); + let resource = Resource::new("file:///test.txt", "test.txt"); + let message = PromptMessage::new_resource_link(Role::User, resource); let json = serde_json::to_string(&message).unwrap(); - println!("PromptMessage with ResourceLink JSON: {}", json); - - // Verify it contains the correct type tag assert!(json.contains("\"type\":\"resource_link\"")); assert!(json.contains("\"uri\":\"file:///test.txt\"")); assert!(json.contains("\"name\":\"test.txt\"")); @@ -404,12 +298,8 @@ mod tests { #[test] fn test_prompt_message_resource_serialization_is_flat() { - // Regression test: PromptMessageContent::Resource must serialize to - // the spec-compliant flat shape `{ "type": "resource", "resource": { "uri", "mimeType", "text" } }` - // and NOT the double-nested shape `{ "type": "resource", "resource": { "resource": {...} } }`. - // See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts let message = PromptMessage::new_resource( - PromptMessageRole::User, + Role::User, "alc://packages/sc/narrative".to_string(), Some("text/markdown".to_string()), Some("# Hello".to_string()), @@ -419,8 +309,6 @@ mod tests { ); let value: serde_json::Value = serde_json::to_value(&message).unwrap(); - - // Drill into content let content = value.get("content").expect("content present"); assert_eq!( content.get("type").and_then(|v| v.as_str()), @@ -431,7 +319,6 @@ mod tests { .get("resource") .expect("resource field present at content level"); - // Spec-compliant: resource.uri / resource.mimeType / resource.text MUST be flat assert_eq!( resource.get("uri").and_then(|v| v.as_str()), Some("alc://packages/sc/narrative"), @@ -446,7 +333,6 @@ mod tests { Some("# Hello") ); - // Regression guard: content.resource MUST NOT contain a nested `resource` key. assert!( resource.get("resource").is_none(), "double-nested resource detected (regression): {resource:#?}" @@ -463,13 +349,13 @@ mod tests { "mimeType": "text/plain" }"#; - let content: PromptMessageContent = serde_json::from_str(json).unwrap(); + let content: ContentBlock = serde_json::from_str(json).unwrap(); - if let PromptMessageContent::ResourceLink { link } = content { - assert_eq!(link.uri, "file:///example.txt"); - assert_eq!(link.name, "example.txt"); - assert_eq!(link.description, Some("Example file".to_string())); - assert_eq!(link.mime_type, Some("text/plain".to_string())); + if let ContentBlock::ResourceLink(resource) = content { + assert_eq!(resource.uri, "file:///example.txt"); + assert_eq!(resource.name, "example.txt"); + assert_eq!(resource.description, Some("Example file".to_string())); + assert_eq!(resource.mime_type, Some("text/plain".to_string())); } else { panic!("Expected ResourceLink variant"); } diff --git a/crates/rmcp/src/model/resource.rs b/crates/rmcp/src/model/resource.rs index c3c7e8e81..a5ad95061 100644 --- a/crates/rmcp/src/model/resource.rs +++ b/crates/rmcp/src/model/resource.rs @@ -1,66 +1,173 @@ use serde::{Deserialize, Serialize}; -use super::{Annotated, Icon, Meta}; +use super::{Annotations, Icon, Meta}; -/// Represents a resource in the extension with metadata +/// A known resource that the server is capable of reading (spec `Resource`). +/// +/// Also used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`). #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RawResource { - /// URI representing the resource location (e.g., "file:///path/to/file" or "str:///content") +#[non_exhaustive] +pub struct Resource { + /// The URI of this resource (e.g. `file:///path/to/file`). pub uri: String, - /// Name of the resource + /// The programmatic name of the resource. pub name: String, - /// Human-readable title of the resource + /// Optional human-readable display title. #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, - /// Optional description of the resource + /// Optional description of what this resource represents. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - /// MIME type of the resource content ("text" or "blob") + /// The MIME type of this resource, if known. #[serde(skip_serializing_if = "Option::is_none")] pub mime_type: Option, - - /// The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. - /// - /// This can be used by Hosts to display file sizes and estimate context window us + /// The size of the raw resource content in bytes (before base64/tokenization), if known. #[serde(skip_serializing_if = "Option::is_none")] - pub size: Option, - /// Optional list of icons for the resource + pub size: Option, + /// Optional set of icons the client may display for this resource. #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, - /// Optional additional metadata for this resource + /// Optional protocol-level metadata for this resource. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, + /// Optional annotations describing how the client should use this resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, } -pub type Resource = Annotated; +impl Resource { + pub fn new(uri: impl Into, name: impl Into) -> Self { + Self { + uri: uri.into(), + name: name.into(), + title: None, + description: None, + mime_type: None, + size: None, + icons: None, + meta: None, + annotations: None, + } + } + + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + pub fn with_size(mut self, size: u64) -> Self { + self.size = Some(size); + self + } + + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} +/// A template description for resources available on the server (spec `ResourceTemplate`). #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct RawResourceTemplate { +#[non_exhaustive] +pub struct ResourceTemplate { + /// An RFC 6570 URI template for constructing resource URIs. pub uri_template: String, + /// The programmatic name of the resource template. pub name: String, + /// Optional human-readable display title. #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, + /// Optional description of what this template is for. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// The MIME type for resources matching this template, if uniform. #[serde(skip_serializing_if = "Option::is_none")] pub mime_type: Option, - /// Optional list of icons for the resource template + /// Optional set of icons the client may display for this template. #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, + /// Optional protocol-level metadata for this resource template. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Optional annotations describing how the client should use this template. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, } -pub type ResourceTemplate = Annotated; +impl ResourceTemplate { + pub fn new(uri_template: impl Into, name: impl Into) -> Self { + Self { + uri_template: uri_template.into(), + name: name.into(), + title: None, + description: None, + mime_type: None, + icons: None, + meta: None, + annotations: None, + } + } + + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { + self.mime_type = Some(mime_type.into()); + self + } + + pub fn with_icons(mut self, icons: Vec) -> Self { + self.icons = Some(icons); + self + } + + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } + + pub fn with_annotations(mut self, annotations: Annotations) -> Self { + self.annotations = Some(annotations); + self + } +} +/// The contents of a specific resource or sub-resource. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(untagged)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum ResourceContents { #[serde(rename_all = "camelCase")] TextResourceContents { @@ -83,7 +190,6 @@ pub enum ResourceContents { } impl ResourceContents { - /// Create text resource contents. pub fn text(text: impl Into, uri: impl Into) -> Self { Self::TextResourceContents { uri: uri.into(), @@ -93,7 +199,6 @@ impl ResourceContents { } } - /// Create blob resource contents. pub fn blob(blob: impl Into, uri: impl Into) -> Self { Self::BlobResourceContents { uri: uri.into(), @@ -103,7 +208,6 @@ impl ResourceContents { } } - /// Set the MIME type on this resource contents. pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { match &mut self { Self::TextResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()), @@ -112,7 +216,6 @@ impl ResourceContents { self } - /// Set the metadata on this resource contents. pub fn with_meta(mut self, meta: Meta) -> Self { match &mut self { Self::TextResourceContents { meta: m, .. } => *m = Some(meta), @@ -122,96 +225,6 @@ impl ResourceContents { } } -impl RawResource { - /// Creates a new Resource from a URI with explicit mime type - pub fn new(uri: impl Into, name: impl Into) -> Self { - Self { - uri: uri.into(), - name: name.into(), - title: None, - description: None, - mime_type: None, - size: None, - icons: None, - meta: None, - } - } - - /// Set the human-readable title. - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - /// Set the description. - pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - /// Set the MIME type. - pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { - self.mime_type = Some(mime_type.into()); - self - } - - /// Set the size in bytes. - pub fn with_size(mut self, size: u32) -> Self { - self.size = Some(size); - self - } - - /// Set the icons. - pub fn with_icons(mut self, icons: Vec) -> Self { - self.icons = Some(icons); - self - } - - /// Set the metadata. - pub fn with_meta(mut self, meta: Meta) -> Self { - self.meta = Some(meta); - self - } -} - -impl RawResourceTemplate { - /// Creates a new RawResourceTemplate with a URI template and name. - pub fn new(uri_template: impl Into, name: impl Into) -> Self { - Self { - uri_template: uri_template.into(), - name: name.into(), - title: None, - description: None, - mime_type: None, - icons: None, - } - } - - /// Set the human-readable title. - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - /// Set the description. - pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - /// Set the MIME type. - pub fn with_mime_type(mut self, mime_type: impl Into) -> Self { - self.mime_type = Some(mime_type.into()); - self - } - - /// Set the icons. - pub fn with_icons(mut self, icons: Vec) -> Self { - self.icons = Some(icons); - self - } -} - #[cfg(test)] mod tests { use serde_json; @@ -221,21 +234,12 @@ mod tests { #[test] fn test_resource_serialization() { - let resource = RawResource { - uri: "file:///test.txt".to_string(), - title: None, - name: "test".to_string(), - description: Some("Test resource".to_string()), - mime_type: Some("text/plain".to_string()), - size: Some(100), - icons: None, - meta: None, - }; + let resource = Resource::new("file:///test.txt", "test") + .with_description("Test resource") + .with_mime_type("text/plain") + .with_size(100); let json = serde_json::to_string(&resource).unwrap(); - println!("Serialized JSON: {}", json); - - // Verify it contains mimeType (camelCase) not mime_type (snake_case) assert!(json.contains("mimeType")); assert!(!json.contains("mime_type")); } @@ -250,28 +254,22 @@ mod tests { }; let json = serde_json::to_string(&text_contents).unwrap(); - println!("ResourceContents JSON: {}", json); - - // Verify it contains mimeType (camelCase) not mime_type (snake_case) assert!(json.contains("mimeType")); assert!(!json.contains("mime_type")); } #[test] fn test_resource_template_with_icons() { - let resource_template = RawResourceTemplate { - uri_template: "file:///{path}".to_string(), - name: "template".to_string(), - title: Some("Test Template".to_string()), - description: Some("A test resource template".to_string()), - mime_type: Some("text/plain".to_string()), - icons: Some(vec![Icon { + let resource_template = ResourceTemplate::new("file:///{path}", "template") + .with_title("Test Template") + .with_description("A test resource template") + .with_mime_type("text/plain") + .with_icons(vec![Icon { src: "https://example.com/icon.png".to_string(), mime_type: Some("image/png".to_string()), sizes: Some(vec!["48x48".to_string()]), theme: Some(IconTheme::Light), - }]), - }; + }]); let json = serde_json::to_value(&resource_template).unwrap(); assert!(json["icons"].is_array()); @@ -282,16 +280,31 @@ mod tests { #[test] fn test_resource_template_without_icons() { - let resource_template = RawResourceTemplate { - uri_template: "file:///{path}".to_string(), - name: "template".to_string(), - title: None, - description: None, - mime_type: None, - icons: None, - }; - + let resource_template = ResourceTemplate::new("file:///{path}", "template"); let json = serde_json::to_value(&resource_template).unwrap(); assert!(json.get("icons").is_none()); } + + #[test] + fn test_resource_size_u64() { + let resource = Resource::new("file:///big", "big").with_size(5_000_000_000); + let json = serde_json::to_value(&resource).unwrap(); + assert_eq!(json["size"], 5_000_000_000_u64); + } + + #[test] + fn test_resource_with_annotations() { + let resource = Resource::new("file:///test.txt", "test") + .with_annotations(Annotations::default().with_priority(0.9)); + let json = serde_json::to_value(&resource).unwrap(); + assert_eq!(json["annotations"]["priority"], 0.9_f32); + } + + #[test] + fn test_resource_template_with_meta() { + let resource_template = + ResourceTemplate::new("file:///{path}", "template").with_meta(Meta::default()); + let json = serde_json::to_value(&resource_template).unwrap(); + assert!(json.get("_meta").is_some()); + } } diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index dbdc34068..8f4934258 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -3,11 +3,54 @@ use serde_json::Value; use super::Meta; +/// Metadata for augmenting a request with task execution (spec `TaskMetadata`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct TaskMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl: Option, +} + +impl TaskMetadata { + pub fn new() -> Self { + Self::default() + } + + pub fn with_ttl(mut self, ttl: u64) -> Self { + self.ttl = Some(ttl); + self + } +} + +/// Metadata for associating messages with a task (spec `RelatedTaskMetadata`). +/// +/// Carried in `_meta` under the key `"io.modelcontextprotocol/related-task"`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct RelatedTaskMetadata { + pub task_id: String, +} + +impl RelatedTaskMetadata { + pub fn new(task_id: impl Into) -> Self { + Self { + task_id: task_id.into(), + } + } + + /// The well-known `_meta` key for related-task metadata. + pub const META_KEY: &str = "io.modelcontextprotocol/related-task"; +} + /// Canonical task lifecycle status as defined by SEP-1686. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +#[non_exhaustive] pub enum TaskStatus { /// The receiver accepted the request and is currently working on it. #[default] @@ -95,12 +138,20 @@ impl Task { #[non_exhaustive] pub struct CreateTaskResult { pub task: Task, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl CreateTaskResult { /// Create a new CreateTaskResult. pub fn new(task: Task) -> Self { - Self { task } + Self { task, meta: None } + } + + /// Sets the protocol-level metadata for this result. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self } } @@ -111,7 +162,7 @@ impl CreateTaskResult { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct GetTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -119,6 +170,12 @@ pub struct GetTaskResult { pub task: Task, } +impl GetTaskResult { + pub fn new(task: Task) -> Self { + Self { meta: None, task } + } +} + /// Response to a `tasks/result` request. /// /// Per spec, the result structure matches the original request type @@ -162,7 +219,7 @@ impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +#[non_exhaustive] pub struct CancelTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -170,26 +227,8 @@ pub struct CancelTaskResult { pub task: Task, } -/// Paginated list of tasks -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct TaskList { - pub tasks: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub total: Option, -} - -impl TaskList { - /// Create a new TaskList. - pub fn new(tasks: Vec) -> Self { - Self { - tasks, - next_cursor: None, - total: None, - } +impl CancelTaskResult { + pub fn new(task: Task) -> Self { + Self { meta: None, task } } } diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 70045d115..29d822a58 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -400,8 +400,9 @@ impl RequestHandle { async fn send_timeout_cancel_notification(&self, reason: &str) { let notification = CancelledNotification { params: CancelledNotificationParam { - request_id: self.id.clone(), + request_id: Some(self.id.clone()), reason: Some(reason.to_owned()), + meta: None, }, method: crate::model::CancelledNotificationMethod, extensions: Default::default(), @@ -473,8 +474,9 @@ impl RequestHandle { .await; let notification = CancelledNotification { params: CancelledNotificationParam { - request_id: self.id, + request_id: Some(self.id), reason, + meta: None, }, method: crate::model::CancelledNotificationMethod, extensions: Default::default(), @@ -1084,11 +1086,13 @@ where }; let _ = responder.send(response); if let Some(param) = cancellation_param { - if let Some(responder) = local_responder_pool.remove(¶m.request_id) { - tracing::info!(id = %param.request_id, reason = param.reason, "cancelled"); - let _response_result = responder.send(Err(ServiceError::Cancelled { - reason: param.reason.clone(), - })); + if let Some(request_id) = ¶m.request_id { + if let Some(responder) = local_responder_pool.remove(request_id) { + tracing::info!(id = %request_id, reason = param.reason, "cancelled"); + let _response_result = responder.send(Err(ServiceError::Cancelled { + reason: param.reason.clone(), + })); + } } } } @@ -1201,9 +1205,11 @@ where // catch cancelled notification let mut notification = match notification.try_into() { Ok::(cancelled) => { - if let Some(ct) = local_ct_pool.remove(&cancelled.params.request_id) { - tracing::info!(id = %cancelled.params.request_id, reason = cancelled.params.reason, "cancelled"); - ct.cancel(); + if let Some(request_id) = &cancelled.params.request_id { + if let Some(ct) = local_ct_pool.remove(request_id) { + tracing::info!(id = %request_id, reason = cancelled.params.reason, "cancelled"); + ct.cancel(); + } } cancelled.into() } diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index aa51e4704..160d5b324 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -9,8 +9,8 @@ use url::Url; use super::*; #[cfg(feature = "elicitation")] use crate::model::{ - CreateElicitationRequest, CreateElicitationRequestParams, CreateElicitationResult, - ElicitationAction, ElicitationCompletionNotification, ElicitationResponseNotificationParam, + ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction, + ElicitationCompleteNotification, ElicitationResponseNotificationParam, }; use crate::{ model::{ @@ -464,11 +464,11 @@ impl Peer { peer_req list_roots ListRootsRequest() => ListRootsResult ); #[cfg(feature = "elicitation")] - method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult); + method!(peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult); #[cfg(feature = "elicitation")] - method!(peer_req_with_timeout create_elicitation_with_timeout CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult); + method!(peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult); #[cfg(feature = "elicitation")] - method!(peer_not notify_url_elicitation_completed ElicitationCompletionNotification(ElicitationResponseNotificationParam)); + method!(peer_not notify_url_elicitation_completed ElicitationCompleteNotification(ElicitationResponseNotificationParam)); method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); @@ -787,7 +787,7 @@ impl Peer { let response = self .create_elicitation_with_timeout( - CreateElicitationRequestParams::FormElicitationParams { + ElicitRequestParams::FormElicitationParams { meta: None, message: message.into(), requested_schema: schema, @@ -920,7 +920,7 @@ impl Peer { let action = self .create_elicitation_with_timeout( - CreateElicitationRequestParams::UrlElicitationParams { + ElicitRequestParams::UrlElicitationParams { meta: None, message: message.into(), url: url.into().to_string(), diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 54c7b558e..7e2893206 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -429,9 +429,10 @@ impl LocalSessionWorker { notification: &JsonRpcNotification, ) { if let ClientNotification::CancelledNotification(n) = ¬ification.notification { - let request_id = n.params.request_id.clone(); - let resource = ResourceKey::McpRequestId(request_id); - self.unregister_resource(&resource); + if let Some(request_id) = n.params.request_id.clone() { + let resource = ResourceKey::McpRequestId(request_id); + self.unregister_resource(&resource); + } } } fn evict_expired_channels(&mut self) { @@ -496,13 +497,17 @@ impl LocalSessionWorker { }), .. }) => { - if let Some(id) = self - .resource_router - .get(&ResourceKey::McpRequestId(request_id.clone())) - { - OutboundChannel::RequestWise { - id: *id, - close: false, + if let Some(req_id) = request_id { + if let Some(id) = self + .resource_router + .get(&ResourceKey::McpRequestId(req_id.clone())) + { + OutboundChannel::RequestWise { + id: *id, + close: false, + } + } else { + OutboundChannel::Common } } else { OutboundChannel::Common diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index dd2d16ebb..276e1bd23 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -171,10 +171,12 @@ impl ServerHandler for TestServer { }; if let Err(e) = peer - .notify_logging_message(LoggingMessageNotificationParam { - level: request.level, - data, - logger, + .notify_logging_message({ + let mut param = LoggingMessageNotificationParam::new(request.level, data); + if let Some(l) = logger { + param = param.with_logger(l); + } + param }) .await { diff --git a/crates/rmcp/tests/test_completion.rs b/crates/rmcp/tests/test_completion.rs index 694ae4d9a..b155bd91b 100644 --- a/crates/rmcp/tests/test_completion.rs +++ b/crates/rmcp/tests/test_completion.rs @@ -54,10 +54,7 @@ fn test_complete_request_param_serialization() { let request = CompleteRequestParams::new( Reference::for_prompt("weather_prompt"), - ArgumentInfo { - name: "location".to_string(), - value: "San".to_string(), - }, + ArgumentInfo::new("location", "San"), ) .with_context(CompletionContext::with_arguments(args)); @@ -144,11 +141,8 @@ fn test_reference_convenience_methods() { #[test] fn test_completion_serialization_format() { // Test that completion follows MCP 2025-06-18 specification format - let completion = CompletionInfo { - values: vec!["value1".to_string(), "value2".to_string()], - total: Some(2), - has_more: Some(false), - }; + let completion = + CompletionInfo::with_all_values(vec!["value1".to_string(), "value2".to_string()]).unwrap(); let json = serde_json::to_value(&completion).unwrap(); @@ -162,18 +156,18 @@ fn test_completion_serialization_format() { #[test] fn test_resource_reference() { - // Test that ResourceReference works correctly - let resource_ref = ResourceReference { - uri: "test://uri".to_string(), - }; - - // Test that ResourceReference works correctly - let another_ref = ResourceReference { - uri: "test://uri".to_string(), - }; - - // They should be equivalent - assert_eq!(resource_ref.uri, another_ref.uri); + // ResourceTemplateReference가 `ref/resource` 와이어 태그로 직렬화/역직렬화되는지 확인 + let reference = Reference::for_resource("test://uri"); + + let json = serde_json::to_value(&reference).unwrap(); + assert_eq!(json["type"], "ref/resource"); + assert_eq!(json["uri"], "test://uri"); + + let back: Reference = serde_json::from_value(json).unwrap(); + match back { + Reference::Resource(r) => assert_eq!(r.uri, "test://uri"), + other => panic!("expected Reference::Resource, got {other:?}"), + } } #[test] @@ -197,10 +191,7 @@ fn test_mcp_schema_compliance() { // Test that our types serialize correctly according to MCP specification let request = CompleteRequestParams::new( Reference::for_resource("file://{path}"), - ArgumentInfo { - name: "path".to_string(), - value: "src/".to_string(), - }, + ArgumentInfo::new("path", "src/"), ); let json_str = serde_json::to_string(&request).unwrap(); diff --git a/crates/rmcp/tests/test_complex_schema.rs b/crates/rmcp/tests/test_complex_schema.rs index 0e3dc4fed..9372b0152 100644 --- a/crates/rmcp/tests/test_complex_schema.rs +++ b/crates/rmcp/tests/test_complex_schema.rs @@ -40,7 +40,7 @@ impl Demo { &self, chat_request: Parameters, ) -> Result { - let content = Content::json(chat_request.0)?; + let content = ContentBlock::json(chat_request.0)?; Ok(CallToolResult::success(vec![content])) } } diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index ffcb51eff..58e9a58af 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -126,7 +126,8 @@ mod untagged_server_result { #[test] fn round_trip_call_tool_result_preserves_variant() { - let original = CallToolResult::success(vec![rmcp::model::Content::text("hello world")]); + let original = + CallToolResult::success(vec![rmcp::model::ContentBlock::text("hello world")]); let json = serde_json::to_value(&original).unwrap(); let result = parse_result(wrap_response(json)); assert!(matches!(result, ServerResult::CallToolResult(_))); diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 04a112f5b..b4a163380 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -36,15 +36,18 @@ async fn test_elicitation_serialization() { ); } -/// Test CreateElicitationRequestParams structure serialization/deserialization +/// Test ElicitRequestParams structure serialization/deserialization #[tokio::test] async fn test_elicitation_request_param_serialization() { let schema = ElicitationSchema::builder() - .required_property("email", PrimitiveSchema::String(StringSchema::email())) + .required_property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) .build() .unwrap(); - let request_param = CreateElicitationRequestParams::FormElicitationParams { + let request_param = ElicitRequestParams::FormElicitationParams { meta: None, message: "Please provide your email address".to_string(), requested_schema: schema, @@ -70,15 +73,15 @@ async fn test_elicitation_request_param_serialization() { assert_eq!(json, expected); // Test deserialization - let deserialized: CreateElicitationRequestParams = serde_json::from_value(expected).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(expected).unwrap(); match (&deserialized, &request_param) { ( - CreateElicitationRequestParams::FormElicitationParams { + ElicitRequestParams::FormElicitationParams { meta: None, message: msg1, requested_schema: schema1, }, - CreateElicitationRequestParams::FormElicitationParams { + ElicitRequestParams::FormElicitationParams { meta: None, message: msg2, requested_schema: schema2, @@ -91,15 +94,12 @@ async fn test_elicitation_request_param_serialization() { } } -/// Test CreateElicitationResult structure with different action types +/// Test ElicitResult structure with different action types #[tokio::test] async fn test_elicitation_result_serialization() { // Test Accept with content - let accept_result = CreateElicitationResult { - action: ElicitationAction::Accept, - content: Some(json!({"email": "user@example.com"})), - meta: None, - }; + let accept_result = ElicitResult::new(ElicitationAction::Accept) + .with_content(json!({"email": "user@example.com"})); let json = serde_json::to_value(&accept_result).unwrap(); let expected = json!({ @@ -109,11 +109,7 @@ async fn test_elicitation_result_serialization() { assert_eq!(json, expected); // Test Decline without content - let decline_result = CreateElicitationResult { - action: ElicitationAction::Decline, - content: None, - meta: None, - }; + let decline_result = ElicitResult::new(ElicitationAction::Decline); let json = serde_json::to_value(&decline_result).unwrap(); let expected = json!({ @@ -123,16 +119,15 @@ async fn test_elicitation_result_serialization() { assert_eq!(json, expected); // Test deserialization - let deserialized: CreateElicitationResult = serde_json::from_value(expected).unwrap(); + let deserialized: ElicitResult = serde_json::from_value(expected).unwrap(); assert_eq!(deserialized.action, ElicitationAction::Decline); assert_eq!(deserialized.content, None); assert_eq!(deserialized.meta, None); // Test protocol-level metadata round-trips as _meta. - let meta_result = - CreateElicitationResult::new(ElicitationAction::Accept).with_meta(Meta(object!({ - "traceId": "elicitation-123" - }))); + let meta_result = ElicitResult::new(ElicitationAction::Accept).with_meta(Meta(object!({ + "traceId": "elicitation-123" + }))); let json = serde_json::to_value(&meta_result).unwrap(); let expected = json!({ @@ -141,7 +136,7 @@ async fn test_elicitation_result_serialization() { }); assert_eq!(json, expected); - let deserialized: CreateElicitationResult = serde_json::from_value(expected).unwrap(); + let deserialized: ElicitResult = serde_json::from_value(expected).unwrap(); assert_eq!( deserialized.meta, Some(Meta(object!({ "traceId": "elicitation-123" }))) @@ -154,7 +149,7 @@ async fn test_elicitation_json_rpc_protocol() { let schema = ElicitationSchema::builder() .required_property( "confirmation", - PrimitiveSchema::Boolean(BooleanSchema::new()), + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), ) .build() .unwrap(); @@ -163,13 +158,11 @@ async fn test_elicitation_json_rpc_protocol() { let request = JsonRpcRequest { jsonrpc: JsonRpcVersion2_0, id: RequestId::Number(1), - request: CreateElicitationRequest::new( - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "Do you want to continue?".to_string(), - requested_schema: schema, - }, - ), + request: ElicitRequest::new(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Do you want to continue?".to_string(), + requested_schema: schema, + }), }; // Test serialization of complete request @@ -180,11 +173,10 @@ async fn test_elicitation_json_rpc_protocol() { assert_eq!(json["params"]["message"], "Do you want to continue?"); // Test deserialization - let deserialized: JsonRpcRequest = - serde_json::from_value(json).unwrap(); + let deserialized: JsonRpcRequest = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.id, RequestId::Number(1)); match &deserialized.request.params { - CreateElicitationRequestParams::FormElicitationParams { message, .. } => { + ElicitRequestParams::FormElicitationParams { message, .. } => { assert_eq!(message, "Do you want to continue?"); } _ => panic!("Expected FormElicitationParam variant"), @@ -250,7 +242,7 @@ async fn test_elicitation_spec_compliance() { #[tokio::test] async fn test_elicitation_error_handling() { // Test minimal schema handling (empty properties is technically valid) - let minimal_schema_request = CreateElicitationRequestParams::FormElicitationParams { + let minimal_schema_request = ElicitRequestParams::FormElicitationParams { meta: None, message: "Test message".to_string(), requested_schema: ElicitationSchema::builder().build().unwrap(), @@ -260,11 +252,14 @@ async fn test_elicitation_error_handling() { let _json = serde_json::to_value(&minimal_schema_request).unwrap(); // Test empty message - let empty_message_request = CreateElicitationRequestParams::FormElicitationParams { + let empty_message_request = ElicitRequestParams::FormElicitationParams { meta: None, message: "".to_string(), requested_schema: ElicitationSchema::builder() - .property("value", PrimitiveSchema::String(StringSchema::new())) + .property( + "value", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) .build() .unwrap(), }; @@ -282,11 +277,14 @@ async fn test_elicitation_error_handling() { #[tokio::test] async fn test_elicitation_performance() { let schema = ElicitationSchema::builder() - .property("data", PrimitiveSchema::String(StringSchema::new())) + .property( + "data", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) .build() .unwrap(); - let request = CreateElicitationRequestParams::FormElicitationParams { + let request = ElicitRequestParams::FormElicitationParams { meta: None, message: "Performance test message".to_string(), requested_schema: schema, @@ -297,7 +295,7 @@ async fn test_elicitation_performance() { // Serialize/deserialize 1000 times for _ in 0..1000 { let json = serde_json::to_value(&request).unwrap(); - let _deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); + let _deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); } let duration = start.elapsed(); @@ -326,9 +324,7 @@ async fn test_elicitation_capabilities() { assert_eq!(elicitation_cap.url, None); // Test with schema validation enabled - elicitation_cap.form = Some(FormElicitationCapability { - schema_validation: Some(true), - }); + elicitation_cap.form = Some(FormElicitationCapability::new().with_schema_validation(true)); // Test serialization let json = serde_json::to_value(&elicitation_cap).unwrap(); @@ -423,14 +419,14 @@ async fn test_elicitation_convenience_methods() { .contains("Option A") ); - // Test that CreateElicitationRequestParam can be created with type-safe schemas - let confirmation_request = CreateElicitationRequestParams::FormElicitationParams { + // Test that ElicitRequestParams can be created with type-safe schemas + let confirmation_request = ElicitRequestParams::FormElicitationParams { meta: None, message: "Test confirmation".to_string(), requested_schema: ElicitationSchema::builder() .property( "confirmed", - PrimitiveSchema::Boolean( + PrimitiveSchemaDefinition::Boolean( BooleanSchema::new() .description("User confirmation (true for yes, false for no)"), ), @@ -467,7 +463,7 @@ async fn test_elicitation_structured_schemas() { .build() .unwrap(); - let request = CreateElicitationRequestParams::FormElicitationParams { + let request = ElicitRequestParams::FormElicitationParams { meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, @@ -475,10 +471,10 @@ async fn test_elicitation_structured_schemas() { // Test that complex schemas serialize/deserialize correctly let json = serde_json::to_value(&request).unwrap(); - let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); match deserialized { - CreateElicitationRequestParams::FormElicitationParams { + ElicitRequestParams::FormElicitationParams { message, requested_schema, .. @@ -699,7 +695,7 @@ async fn test_elicitation_multi_select_enum() { .build() .unwrap(); - let request = CreateElicitationRequestParams::FormElicitationParams { + let request = ElicitRequestParams::FormElicitationParams { meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, @@ -707,10 +703,10 @@ async fn test_elicitation_multi_select_enum() { // Test that complex schemas serialize/deserialize correctly let json = serde_json::to_value(&request).unwrap(); - let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); match deserialized { - CreateElicitationRequestParams::FormElicitationParams { + ElicitRequestParams::FormElicitationParams { message, requested_schema, .. @@ -722,30 +718,20 @@ async fn test_elicitation_multi_select_enum() { assert!(matches!( requested_schema.properties.get("choices").unwrap(), - PrimitiveSchema::Enum(EnumSchema::Multi(_)) + PrimitiveSchemaDefinition::Enum(EnumSchema::Multi(_)) )); - if let Some(PrimitiveSchema::Enum(schema)) = requested_schema.properties.get("choices") + if let Some(PrimitiveSchemaDefinition::Enum(schema)) = + requested_schema.properties.get("choices") { assert_eq!( schema, &EnumSchema::Multi(MultiSelectEnumSchema::Titled( - TitledMultiSelectEnumSchema::new(TitledItems { - any_of: vec![ - ConstTitle { - const_: "A".to_string(), - title: "A name".to_string() - }, - ConstTitle { - const_: "B".to_string(), - title: "B name".to_string() - }, - ConstTitle { - const_: "C".to_string(), - title: "C name".to_string() - }, - ], - }) + TitledMultiSelectEnumSchema::new(TitledItems::new(vec![ + ConstTitle::new("A", "A name"), + ConstTitle::new("B", "B name"), + ConstTitle::new("C", "C name"), + ])) .with_min_items(1) .with_max_items(2) )) @@ -773,7 +759,7 @@ async fn test_elicitation_single_select_enum() { .build() .unwrap(); - let request = CreateElicitationRequestParams::FormElicitationParams { + let request = ElicitRequestParams::FormElicitationParams { meta: None, message: "Please provide your user information".to_string(), requested_schema: schema, @@ -781,10 +767,10 @@ async fn test_elicitation_single_select_enum() { // Test that complex schemas serialize/deserialize correctly let json = serde_json::to_value(&request).unwrap(); - let deserialized: CreateElicitationRequestParams = serde_json::from_value(json).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(json).unwrap(); match deserialized { - CreateElicitationRequestParams::FormElicitationParams { + ElicitRequestParams::FormElicitationParams { message, requested_schema, .. @@ -795,27 +781,19 @@ async fn test_elicitation_single_select_enum() { assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); assert!(matches!( requested_schema.properties.get("choices").unwrap(), - PrimitiveSchema::Enum(EnumSchema::Single(_)) + PrimitiveSchemaDefinition::Enum(EnumSchema::Single(_)) )); - if let Some(PrimitiveSchema::Enum(schema)) = requested_schema.properties.get("choices") + if let Some(PrimitiveSchemaDefinition::Enum(schema)) = + requested_schema.properties.get("choices") { assert_eq!( schema, &EnumSchema::Single(SingleSelectEnumSchema::Titled( TitledSingleSelectEnumSchema::new(vec![ - ConstTitle { - const_: "A".to_string(), - title: "A name".to_string() - }, - ConstTitle { - const_: "B".to_string(), - title: "B name".to_string() - }, - ConstTitle { - const_: "C".to_string(), - title: "C name".to_string() - } + ConstTitle::new("A", "A name"), + ConstTitle::new("B", "B name"), + ConstTitle::new("C", "C name"), ]) )) ) @@ -841,12 +819,12 @@ async fn test_elicitation_direction_server_to_client() { let schema = ElicitationSchema::builder() .property( "name", - PrimitiveSchema::String(StringSchema::new().description("Enter your name")), + PrimitiveSchemaDefinition::String(StringSchema::new().description("Enter your name")), ) .build() .unwrap(); - let elicitation_request = CreateElicitationRequestParams::FormElicitationParams { + let elicitation_request = ElicitRequestParams::FormElicitationParams { meta: None, message: "Please enter your name".to_string(), requested_schema: schema, @@ -858,23 +836,20 @@ async fn test_elicitation_direction_server_to_client() { assert_eq!(serialized["requestedSchema"]["type"], "object"); // Test that elicitation requests are part of ServerRequest - let _server_request = - ServerRequest::CreateElicitationRequest(CreateElicitationRequest::new(elicitation_request)); + let _server_request = ServerRequest::ElicitRequest(ElicitRequest::new(elicitation_request)); // Test that client can respond with elicitation results - let client_result = ClientResult::CreateElicitationResult(CreateElicitationResult { - action: ElicitationAction::Accept, - content: Some(json!("John Doe")), - meta: None, - }); + let client_result = ClientResult::ElicitResult( + ElicitResult::new(ElicitationAction::Accept).with_content(json!("John Doe")), + ); // Verify client result can be serialized match client_result { - ClientResult::CreateElicitationResult(result) => { + ClientResult::ElicitResult(result) => { assert_eq!(result.action, ElicitationAction::Accept); assert_eq!(result.content, Some(json!("John Doe"))); } - _ => panic!("CreateElicitationResult should be part of ClientResult"), + _ => panic!("ElicitResult should be part of ClientResult"), } } @@ -888,15 +863,17 @@ async fn test_elicitation_json_rpc_direction() { let schema = ElicitationSchema::builder() .property( "continue", - PrimitiveSchema::Boolean(BooleanSchema::new().description("Do you want to continue?")), + PrimitiveSchemaDefinition::Boolean( + BooleanSchema::new().description("Do you want to continue?"), + ), ) .build() .unwrap(); // 1. Server creates elicitation request let server_request = ServerJsonRpcMessage::request( - ServerRequest::CreateElicitationRequest(CreateElicitationRequest::new( - CreateElicitationRequestParams::FormElicitationParams { + ServerRequest::ElicitRequest(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { meta: None, message: "Do you want to continue?".to_string(), requested_schema: schema, @@ -913,11 +890,9 @@ async fn test_elicitation_json_rpc_direction() { // 2. Client responds with elicitation result let client_response = ClientJsonRpcMessage::response( - ClientResult::CreateElicitationResult(CreateElicitationResult { - action: ElicitationAction::Accept, - content: Some(json!(true)), - meta: None, - }), + ClientResult::ElicitResult( + ElicitResult::new(ElicitationAction::Accept).with_content(json!(true)), + ), RequestId::Number(1), ); @@ -946,13 +921,12 @@ async fn test_elicitation_actions_compliance() { ]; for action in actions { - let result = CreateElicitationResult { - action: action.clone(), - content: match action { - ElicitationAction::Accept => Some(serde_json::json!("some data")), - _ => None, - }, - meta: None, + let result = { + let r = ElicitResult::new(action.clone()); + match action { + ElicitationAction::Accept => r.with_content(serde_json::json!("some data")), + _ => r, + } }; let json = serde_json::to_value(&result).unwrap(); @@ -970,28 +944,25 @@ async fn test_elicitation_actions_compliance() { assert_eq!(json["action"], "cancel"); assert!(json.get("content").is_none() || json["content"].is_null()); } + _ => {} } } } -/// Test that CreateElicitationResult IS in ClientResult (response compliance) +/// Test that ElicitResult IS in ClientResult (response compliance) #[tokio::test] async fn test_elicitation_result_in_client_result() { use rmcp::model::*; // Test that clients can return elicitation results - let result = ClientResult::CreateElicitationResult(CreateElicitationResult { - action: ElicitationAction::Decline, - content: None, - meta: None, - }); + let result = ClientResult::ElicitResult(ElicitResult::new(ElicitationAction::Decline)); match result { - ClientResult::CreateElicitationResult(elicit_result) => { + ClientResult::ElicitResult(elicit_result) => { assert_eq!(elicit_result.action, ElicitationAction::Decline); assert_eq!(elicit_result.content, None); } - _ => panic!("CreateElicitationResult should be part of ClientResult"), + _ => panic!("ElicitResult should be part of ClientResult"), } } @@ -1008,24 +979,16 @@ async fn test_elicitation_capability_structure() { assert!(default_cap.url.is_none()); // Test ElicitationCapability with schema validation enabled - let cap_with_validation = ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: None, - }; + let cap_with_validation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)); assert_eq!( cap_with_validation.form.as_ref().unwrap().schema_validation, Some(true) ); // Test ElicitationCapability with schema validation disabled - let cap_without_validation = ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(false), - }), - url: None, - }; + let cap_without_validation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(false)); assert_eq!( cap_without_validation .form @@ -1059,12 +1022,10 @@ async fn test_elicitation_capability_structure() { async fn test_client_capabilities_with_elicitation() { // Test ClientCapabilities with elicitation capability let capabilities = ClientCapabilities::builder() - .enable_elicitation_with(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: None, - }) + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ) .build(); // Verify elicitation capability is present @@ -1101,12 +1062,10 @@ async fn test_initialize_request_with_elicitation() { // Test InitializeRequestParam with elicitation capability let init_param = InitializeRequestParams::new( ClientCapabilities::builder() - .enable_elicitation_with(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: None, - }) + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ) .build(), Implementation::new("test-client", "1.0.0"), ); @@ -1143,12 +1102,10 @@ async fn test_capability_checking_logic() { // Case 1: Client with elicitation capability let client_with_capability = InitializeRequestParams::new( ClientCapabilities::builder() - .enable_elicitation_with(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: None, - }) + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ) .build(), Implementation::new("test-client", "1.0.0"), ); @@ -1257,12 +1214,8 @@ async fn test_elicitation_capability_serialization() { assert_eq!(json, serde_json::json!({})); // Test capability with schema validation enabled - let cap_with_validation = ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: None, - }; + let cap_with_validation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)); let json = serde_json::to_value(&cap_with_validation).unwrap(); assert_eq!( @@ -1275,12 +1228,8 @@ async fn test_elicitation_capability_serialization() { ); // Test capability with schema validation disabled - let cap_without_validation = ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(false), - }), - url: None, - }; + let cap_without_validation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(false)); let json = serde_json::to_value(&cap_without_validation).unwrap(); assert_eq!( @@ -1332,12 +1281,8 @@ async fn test_client_capabilities_elicitation_builder() { ); // Test enabling elicitation with custom capability - let custom_elicitation = ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(false), - }), - url: None, - }; + let custom_elicitation = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(false)); let caps_custom = ClientCapabilities::builder() .enable_elicitation_with(custom_elicitation.clone()) @@ -1361,12 +1306,18 @@ async fn test_create_elicitation_with_timeout_basic() { // This test verifies that the method accepts timeout parameter let schema = ElicitationSchema::builder() - .required_property("name", PrimitiveSchema::String(StringSchema::new())) - .required_property("email", PrimitiveSchema::String(StringSchema::new())) + .required_property( + "name", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) + .required_property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) .build() .unwrap(); - let _params = CreateElicitationRequestParams::FormElicitationParams { + let _params = ElicitRequestParams::FormElicitationParams { meta: None, message: "Enter your details".to_string(), requested_schema: schema, @@ -1547,6 +1498,7 @@ async fn test_elicitation_action_error_mapping() { let error = ElicitationError::UserCancelled; assert!(format!("{}", error).contains("cancelled/dismissed")); } + _ => {} } } } @@ -1690,7 +1642,10 @@ async fn test_elicitation_examples_compile() { async fn test_build_validation_required_field_not_in_properties() { // Try to mark a field as required that doesn't exist in properties let result = ElicitationSchema::builder() - .property("email", PrimitiveSchema::String(StringSchema::email())) + .property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) .mark_required("nonexistent_field") .build(); @@ -1706,8 +1661,14 @@ async fn test_build_validation_required_field_not_in_properties() { #[tokio::test] async fn test_build_validation_required_field_exists() { let result = ElicitationSchema::builder() - .property("email", PrimitiveSchema::String(StringSchema::email())) - .property("name", PrimitiveSchema::String(StringSchema::new())) + .property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) + .property( + "name", + PrimitiveSchemaDefinition::String(StringSchema::new()), + ) .mark_required("email") .mark_required("name") .build(); @@ -1728,7 +1689,10 @@ async fn test_build_validation_required_field_exists() { async fn test_build_unchecked_panics_on_invalid() { // build_unchecked validates but panics instead of returning Result let _schema = ElicitationSchema::builder() - .property("email", PrimitiveSchema::String(StringSchema::email())) + .property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) .mark_required("nonexistent_field") .build_unchecked(); } @@ -1776,25 +1740,25 @@ async fn test_typed_property_methods() { assert_eq!(schema.properties.len(), 4); // Verify types are correct - if let Some(PrimitiveSchema::String(_)) = schema.properties.get("name") { + if let Some(PrimitiveSchemaDefinition::String(_)) = schema.properties.get("name") { // Expected } else { panic!("name should be StringSchema"); } - if let Some(PrimitiveSchema::Number(_)) = schema.properties.get("price") { + if let Some(PrimitiveSchemaDefinition::Number(_)) = schema.properties.get("price") { // Expected } else { panic!("price should be NumberSchema"); } - if let Some(PrimitiveSchema::Integer(_)) = schema.properties.get("quantity") { + if let Some(PrimitiveSchemaDefinition::Integer(_)) = schema.properties.get("quantity") { // Expected } else { panic!("quantity should be IntegerSchema"); } - if let Some(PrimitiveSchema::Boolean(_)) = schema.properties.get("in_stock") { + if let Some(PrimitiveSchemaDefinition::Boolean(_)) = schema.properties.get("in_stock") { // Expected } else { panic!("in_stock should be BooleanSchema"); @@ -1831,7 +1795,7 @@ async fn test_required_typed_property_methods() { /// Test URL elicitation request parameter serialization/deserialization #[tokio::test] async fn test_url_elicitation_request_param_serialization() { - let request_param = CreateElicitationRequestParams::UrlElicitationParams { + let request_param = ElicitRequestParams::UrlElicitationParams { meta: None, message: "Please visit the following URL to complete verification".to_string(), url: "https://example.com/verify".to_string(), @@ -1850,9 +1814,9 @@ async fn test_url_elicitation_request_param_serialization() { assert_eq!(json, expected); // Test deserialization - let deserialized: CreateElicitationRequestParams = serde_json::from_value(expected).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(expected).unwrap(); match deserialized { - CreateElicitationRequestParams::UrlElicitationParams { + ElicitRequestParams::UrlElicitationParams { message, url, elicitation_id, @@ -1876,14 +1840,12 @@ async fn test_url_elicitation_json_rpc_protocol() { let request = JsonRpcRequest { jsonrpc: JsonRpcVersion2_0, id: RequestId::Number(1), - request: CreateElicitationRequest::new( - CreateElicitationRequestParams::UrlElicitationParams { - meta: None, - message: "Please authorize this action at the following URL".to_string(), - url: "https://auth.example.com/authorize/abc123".to_string(), - elicitation_id: "auth-request-456".to_string(), - }, - ), + request: ElicitRequest::new(ElicitRequestParams::UrlElicitationParams { + meta: None, + message: "Please authorize this action at the following URL".to_string(), + url: "https://auth.example.com/authorize/abc123".to_string(), + elicitation_id: "auth-request-456".to_string(), + }), }; // Test serialization of complete request @@ -1903,11 +1865,10 @@ async fn test_url_elicitation_json_rpc_protocol() { assert_eq!(json["params"]["elicitationId"], "auth-request-456"); // Test deserialization - let deserialized: JsonRpcRequest = - serde_json::from_value(json).unwrap(); + let deserialized: JsonRpcRequest = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.id, RequestId::Number(1)); match &deserialized.request.params { - CreateElicitationRequestParams::UrlElicitationParams { + ElicitRequestParams::UrlElicitationParams { message, url, elicitation_id, @@ -1921,12 +1882,10 @@ async fn test_url_elicitation_json_rpc_protocol() { } } -/// Test ElicitationCompletionNotification serialization/deserialization +/// Test ElicitationCompleteNotification serialization/deserialization #[tokio::test] async fn test_elicitation_completion_notification() { - let notification_params = ElicitationResponseNotificationParam { - elicitation_id: "elicit-789".to_string(), - }; + let notification_params = ElicitationResponseNotificationParam::new("elicit-789"); // Test serialization let json = serde_json::to_value(¬ification_params).unwrap(); @@ -1941,7 +1900,7 @@ async fn test_elicitation_completion_notification() { assert_eq!(deserialized.elicitation_id, "elicit-789"); // Test complete notification structure - let notification = ElicitationCompletionNotification::new(notification_params); + let notification = ElicitationCompleteNotification::new(notification_params); let json = serde_json::to_value(¬ification).unwrap(); assert_eq!(json["method"], "notifications/elicitation/complete"); @@ -1963,10 +1922,7 @@ async fn test_url_elicitation_capability() { assert_eq!(deserialized, url_cap); // Test ElicitationCapability with URL mode enabled - let elicitation_cap = ElicitationCapability { - form: None, - url: Some(UrlElicitationCapability::default()), - }; + let elicitation_cap = ElicitationCapability::new().with_url(UrlElicitationCapability::new()); let json = serde_json::to_value(&elicitation_cap).unwrap(); assert_eq!( @@ -1977,12 +1933,9 @@ async fn test_url_elicitation_capability() { ); // Test ElicitationCapability with both form and URL modes - let both_cap = ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: Some(UrlElicitationCapability::default()), - }; + let both_cap = ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)) + .with_url(UrlElicitationCapability::new()); let json = serde_json::to_value(&both_cap).unwrap(); assert_eq!( @@ -1996,7 +1949,7 @@ async fn test_url_elicitation_capability() { ); } -/// Test backward compatibility: CreateElicitationRequestParam without mode tag +/// Test backward compatibility: ElicitRequestParams without mode tag #[tokio::test] async fn test_elicitation_backward_compatibility_no_mode() { // JSON without "mode" field should deserialize as FormElicitationParam @@ -2013,11 +1966,10 @@ async fn test_elicitation_backward_compatibility_no_mode() { } }); - let deserialized: CreateElicitationRequestParams = - serde_json::from_value(json_without_mode).unwrap(); + let deserialized: ElicitRequestParams = serde_json::from_value(json_without_mode).unwrap(); match deserialized { - CreateElicitationRequestParams::FormElicitationParams { + ElicitRequestParams::FormElicitationParams { message, requested_schema, .. @@ -2035,11 +1987,14 @@ async fn test_elicitation_backward_compatibility_no_mode() { async fn test_elicitation_both_modes() { // Form mode let form_schema = ElicitationSchema::builder() - .required_property("email", PrimitiveSchema::String(StringSchema::email())) + .required_property( + "email", + PrimitiveSchemaDefinition::String(StringSchema::email()), + ) .build() .unwrap(); - let form_request = CreateElicitationRequestParams::FormElicitationParams { + let form_request = ElicitRequestParams::FormElicitationParams { meta: None, message: "Enter email".to_string(), requested_schema: form_schema, @@ -2051,7 +2006,7 @@ async fn test_elicitation_both_modes() { assert!(form_json.get("url").is_none()); // URL mode - let url_request = CreateElicitationRequestParams::UrlElicitationParams { + let url_request = ElicitRequestParams::UrlElicitationParams { meta: None, message: "Visit URL".to_string(), url: "https://example.com".to_string(), @@ -2103,12 +2058,10 @@ async fn test_url_elicitation_required_error_code() { async fn test_client_capabilities_elicitation_modes() { // Test with form-only capability let form_only_caps = ClientCapabilities::builder() - .enable_elicitation_with(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(true), - }), - url: None, - }) + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(true)), + ) .build(); let json = serde_json::to_value(&form_only_caps).unwrap(); @@ -2120,10 +2073,9 @@ async fn test_client_capabilities_elicitation_modes() { // Test with URL-only capability let url_only_caps = ClientCapabilities::builder() - .enable_elicitation_with(ElicitationCapability { - form: None, - url: Some(UrlElicitationCapability::default()), - }) + .enable_elicitation_with( + ElicitationCapability::new().with_url(UrlElicitationCapability::new()), + ) .build(); let json = serde_json::to_value(&url_only_caps).unwrap(); @@ -2138,12 +2090,11 @@ async fn test_client_capabilities_elicitation_modes() { // Test with both capabilities let both_caps = ClientCapabilities::builder() - .enable_elicitation_with(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: Some(false), - }), - url: Some(UrlElicitationCapability::default()), - }) + .enable_elicitation_with( + ElicitationCapability::new() + .with_form(FormElicitationCapability::new().with_schema_validation(false)) + .with_url(UrlElicitationCapability::new()), + ) .build(); let json = serde_json::to_value(&both_caps).unwrap(); @@ -2151,19 +2102,16 @@ async fn test_client_capabilities_elicitation_modes() { assert!(json["elicitation"]["url"].is_object()); } -/// Test ElicitationCompletionNotification in ServerNotification enum +/// Test ElicitationCompleteNotification in ServerNotification enum #[tokio::test] async fn test_elicitation_completion_in_server_notification() { - let notification_param = ElicitationResponseNotificationParam { - elicitation_id: "notify-123".to_string(), - }; + let notification_param = ElicitationResponseNotificationParam::new("notify-123"); - let completion_notification = - ElicitationCompletionNotification::new(notification_param.clone()); + let completion_notification = ElicitationCompleteNotification::new(notification_param.clone()); // Test that it's part of ServerNotification let server_notification = - ServerNotification::ElicitationCompletionNotification(completion_notification); + ServerNotification::ElicitationCompleteNotification(completion_notification); // Test serialization let json = serde_json::to_value(&server_notification).unwrap(); @@ -2173,10 +2121,10 @@ async fn test_elicitation_completion_in_server_notification() { // Test deserialization let deserialized: ServerNotification = serde_json::from_value(json).unwrap(); match deserialized { - ServerNotification::ElicitationCompletionNotification(notif) => { + ServerNotification::ElicitationCompleteNotification(notif) => { assert_eq!(notif.params.elicitation_id, "notify-123"); } - _ => panic!("Expected ElicitationCompletionNotification variant"), + _ => panic!("Expected ElicitationCompleteNotification variant"), } } @@ -2184,11 +2132,7 @@ async fn test_elicitation_completion_in_server_notification() { #[tokio::test] async fn test_url_elicitation_action_workflow() { // Test Accept action for URL elicitation (user visited URL and confirmed) - let accept_result = CreateElicitationResult { - action: ElicitationAction::Accept, - content: None, // URL elicitation doesn't return content, just confirmation - meta: None, - }; + let accept_result = ElicitResult::new(ElicitationAction::Accept); let json = serde_json::to_value(&accept_result).unwrap(); assert_eq!(json["action"], "accept"); @@ -2196,21 +2140,13 @@ async fn test_url_elicitation_action_workflow() { assert!(json.get("content").is_none() || json["content"].is_null()); // Test Decline action for URL elicitation - let decline_result = CreateElicitationResult { - action: ElicitationAction::Decline, - content: None, - meta: None, - }; + let decline_result = ElicitResult::new(ElicitationAction::Decline); let json = serde_json::to_value(&decline_result).unwrap(); assert_eq!(json["action"], "decline"); // Test Cancel action for URL elicitation - let cancel_result = CreateElicitationResult { - action: ElicitationAction::Cancel, - content: None, - meta: None, - }; + let cancel_result = ElicitResult::new(ElicitationAction::Cancel); let json = serde_json::to_value(&cancel_result).unwrap(); assert_eq!(json["action"], "cancel"); diff --git a/crates/rmcp/tests/test_embedded_resource_meta.rs b/crates/rmcp/tests/test_embedded_resource_meta.rs index 7535e358f..167108e8e 100644 --- a/crates/rmcp/tests/test_embedded_resource_meta.rs +++ b/crates/rmcp/tests/test_embedded_resource_meta.rs @@ -1,26 +1,23 @@ -use rmcp::model::{AnnotateAble, Content, Meta, RawContent, ResourceContents}; +use rmcp::model::{ContentBlock, EmbeddedResource, Meta, ResourceContents}; use serde_json::json; #[test] fn serialize_embedded_text_resource_with_meta() { - // Inner contents meta let mut resource_content_meta = Meta::new(); resource_content_meta.insert("inner".to_string(), json!(2)); - // Top-level embedded resource meta let mut resource_meta = Meta::new(); resource_meta.insert("top".to_string(), json!(1)); - let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource { - meta: Some(resource_meta), - resource: ResourceContents::TextResourceContents { + let content = ContentBlock::Resource( + EmbeddedResource::new(ResourceContents::TextResourceContents { uri: "str://example".to_string(), mime_type: Some("text/plain".to_string()), text: "hello".to_string(), meta: Some(resource_content_meta), - }, - }) - .no_annotation(); + }) + .with_meta(resource_meta), + ); let v = serde_json::to_value(&content).unwrap(); @@ -40,16 +37,14 @@ fn serialize_embedded_text_resource_with_meta() { #[test] fn serialize_embedded_text_resource_without_meta_omits_fields() { - let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource { - meta: None, - resource: ResourceContents::TextResourceContents { + let content = ContentBlock::Resource(EmbeddedResource::new( + ResourceContents::TextResourceContents { uri: "str://no-meta".to_string(), mime_type: Some("text/plain".to_string()), text: "hi".to_string(), meta: None, }, - }) - .no_annotation(); + )); let v = serde_json::to_value(&content).unwrap(); @@ -70,19 +65,17 @@ fn deserialize_embedded_text_resource_with_meta() { } }); - let content: Content = serde_json::from_value(raw).unwrap(); + let content: ContentBlock = serde_json::from_value(raw).unwrap(); - let raw = match &content.raw { - RawContent::Resource(er) => er, + let er = match &content { + ContentBlock::Resource(er) => er, _ => panic!("expected resource"), }; - // top-level _meta - let top = raw.meta.as_ref().expect("top-level meta missing"); + let top = er.meta.as_ref().expect("top-level meta missing"); assert_eq!(top.get("x").unwrap(), &json!(true)); - // inner contents _meta - match &raw.resource { + match &er.resource { ResourceContents::TextResourceContents { meta, uri, text, .. } => { @@ -103,16 +96,15 @@ fn serialize_embedded_blob_resource_with_meta() { let mut resource_meta = Meta::new(); resource_meta.insert("blob_top".to_string(), json!("t")); - let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource { - meta: Some(resource_meta), - resource: ResourceContents::BlobResourceContents { + let content = ContentBlock::Resource( + EmbeddedResource::new(ResourceContents::BlobResourceContents { uri: "str://blob".to_string(), mime_type: Some("application/octet-stream".to_string()), blob: "Zm9v".to_string(), meta: Some(resource_content_meta), - }, - }) - .no_annotation(); + }) + .with_meta(resource_meta), + ); let v = serde_json::to_value(&content).unwrap(); diff --git a/crates/rmcp/tests/test_inflight_response_drain.rs b/crates/rmcp/tests/test_inflight_response_drain.rs index 2381644d9..8af62ba53 100644 --- a/crates/rmcp/tests/test_inflight_response_drain.rs +++ b/crates/rmcp/tests/test_inflight_response_drain.rs @@ -148,7 +148,7 @@ async fn test_inflight_response_drain_on_eof() -> anyhow::Result<()> { let text = result .content .first() - .and_then(|c| c.raw.as_text()) + .and_then(|c| c.as_text()) .map(|t| t.text.as_str()) .expect("expected text content in tool result"); assert_eq!(text, "done after 200ms"); diff --git a/crates/rmcp/tests/test_logging.rs b/crates/rmcp/tests/test_logging.rs index 467cf7134..bf00352bc 100644 --- a/crates/rmcp/tests/test_logging.rs +++ b/crates/rmcp/tests/test_logging.rs @@ -26,14 +26,16 @@ async fn test_logging_spec_compliance() -> anyhow::Result<()> { // Test server can send messages before level is set server .peer() - .notify_logging_message(LoggingMessageNotificationParam { - level: LoggingLevel::Info, - data: serde_json::json!({ - "message": "Server initiated message", - "timestamp": chrono::Utc::now().to_rfc3339(), - }), - logger: Some("test_server".to_string()), - }) + .notify_logging_message( + LoggingMessageNotificationParam::new( + LoggingLevel::Info, + serde_json::json!({ + "message": "Server initiated message", + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + ) + .with_logger("test_server"), + ) .await?; server.waiting().await?; @@ -277,14 +279,9 @@ async fn test_logging_optional_fields() -> anyhow::Result<()> { // Test message with and without optional logger field for (level, has_logger) in [(LoggingLevel::Info, true), (LoggingLevel::Debug, false)] { - server - .peer() - .notify_logging_message(LoggingMessageNotificationParam { - level, - data: json!({"test": "data"}), - logger: has_logger.then(|| "test_logger".to_string()), - }) - .await?; + let mut param = LoggingMessageNotificationParam::new(level, json!({"test": "data"})); + param.logger = has_logger.then(|| "test_logger".to_string()); + server.peer().notify_logging_message(param).await?; } server.waiting().await?; diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 952aff8e4..46378bc9c 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -37,109 +37,8 @@ } ], "definitions": { - "Annotated": { - "type": "object", - "properties": { - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - } - }, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawTextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawEmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawAudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawResource" - } - ], - "required": [ - "type" - ] - } - ] - }, "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", "type": "object", "properties": { "audience": { @@ -182,6 +81,43 @@ "value" ] }, + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded audio data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, "CallToolRequestMethod": { "type": "string", "format": "const", @@ -213,11 +149,14 @@ }, "task": { "description": "Task metadata for async task management (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/TaskMetadata" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -256,6 +195,13 @@ "CancelledNotificationParam": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "reason": { "type": [ "string", @@ -263,12 +209,16 @@ ] }, "requestId": { - "$ref": "#/definitions/NumberOrString" + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] } - }, - "required": [ - "requestId" - ] + } }, "ClientCapabilities": { "title": "Builder", @@ -350,7 +300,7 @@ "$ref": "#/definitions/ListRootsResult" }, { - "$ref": "#/definitions/CreateElicitationResult" + "$ref": "#/definitions/ElicitResult" }, { "$ref": "#/definitions/EmptyObject" @@ -415,32 +365,94 @@ } } }, - "CreateElicitationResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "type": [ - "object", - "null" + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } ], - "additionalProperties": true + "required": [ + "type" + ] }, - "action": { - "description": "The user's decision on how to handle the elicitation request", + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, "allOf": [ { - "$ref": "#/definitions/ElicitationAction" + "$ref": "#/definitions/ImageContent" } + ], + "required": [ + "type" ] }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], + "required": [ + "type" + ] } - }, - "required": [ - "action" ] }, "CreateMessageResult": { @@ -517,6 +529,34 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, "ElicitationAction": { "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", "oneOf": [ @@ -577,6 +617,42 @@ } } }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object", @@ -657,12 +733,12 @@ "name" ] }, - "GetTaskInfoMethod": { + "GetTaskMethod": { "type": "string", "format": "const", "const": "tasks/get" }, - "GetTaskInfoParams": { + "GetTaskParams": { "type": "object", "properties": { "_meta": { @@ -681,12 +757,12 @@ "taskId" ] }, - "GetTaskResultMethod": { + "GetTaskPayloadMethod": { "type": "string", "format": "const", "const": "tasks/result" }, - "GetTaskResultParams": { + "GetTaskPayloadParams": { "type": "object", "properties": { "_meta": { @@ -761,6 +837,43 @@ } ] }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, "Implementation": { "type": "object", "properties": { @@ -901,6 +1014,9 @@ { "$ref": "#/definitions/NotificationNoParam2" }, + { + "$ref": "#/definitions/Notification3" + }, { "$ref": "#/definitions/CustomNotification" } @@ -1022,6 +1138,13 @@ "ListRootsResult": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "roots": { "type": "array", "items": { @@ -1087,6 +1210,21 @@ "params" ] }, + "Notification3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/TaskStatusNotificationMethod" + }, + "params": { + "$ref": "#/definitions/TaskStatusNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, "NotificationNoParam": { "type": "object", "properties": { @@ -1138,220 +1276,84 @@ } } }, - "PingRequestMethod": { - "type": "string", - "format": "const", - "const": "ping" - }, - "ProgressNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/progress" - }, - "ProgressNotificationParam": { - "type": "object", - "properties": { - "message": { - "description": "An optional message describing the current progress.", - "type": [ - "string", - "null" - ] - }, - "progress": { - "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", - "type": "number", - "format": "double" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - }, - "total": { - "description": "Total number of items to process (or total progress required), if known", - "type": [ - "number", - "null" - ], - "format": "double" - } - }, - "required": [ - "progressToken", - "progress" - ] - }, - "ProgressToken": { - "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", - "allOf": [ - { - "$ref": "#/definitions/NumberOrString" - } - ] - }, - "PromptReference": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "ProtocolVersion": { - "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", - "type": "string" - }, - "RawAudioContent": { - "type": "object", - "properties": { - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "RawEmbeddedResource": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "resource": { - "$ref": "#/definitions/ResourceContents" - } - }, - "required": [ - "resource" - ] - }, - "RawImageContent": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "data": { - "description": "The base64-encoded image", - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] + "PingRequestMethod": { + "type": "string", + "format": "const", + "const": "ping" + }, + "ProgressNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/progress" }, - "RawResource": { - "description": "Represents a resource in the extension with metadata", + "ProgressNotificationParam": { "type": "object", "properties": { "_meta": { - "description": "Optional additional metadata for this resource", "type": [ "object", "null" ], "additionalProperties": true }, - "description": { - "description": "Optional description of the resource", + "message": { + "description": "An optional message describing the current progress.", "type": [ "string", "null" ] }, - "icons": { - "description": "Optional list of icons for the resource", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "MIME type of the resource content (\"text\" or \"blob\")", - "type": [ - "string", - "null" - ] + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number", + "format": "double" }, - "name": { - "description": "Name of the resource", - "type": "string" + "progressToken": { + "$ref": "#/definitions/ProgressToken" }, - "size": { - "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window us", + "total": { + "description": "Total number of items to process (or total progress required), if known", "type": [ - "integer", + "number", "null" ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Human-readable title of the resource", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "URI representing the resource location (e.g., \"file:///path/to/file\" or \"str:///content\")", - "type": "string" + "format": "double" } }, "required": [ - "uri", - "name" + "progressToken", + "progress" + ] + }, + "ProgressToken": { + "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", + "allOf": [ + { + "$ref": "#/definitions/NumberOrString" + } ] }, - "RawTextContent": { + "PromptReference": { "type": "object", "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", + "name": { + "type": "string" + }, + "title": { "type": [ - "object", + "string", "null" - ], - "additionalProperties": true - }, - "text": { - "type": "string" + ] } }, "required": [ - "text" + "name" ] }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" + }, "ReadResourceRequestMethod": { "type": "string", "format": "const", @@ -1390,7 +1392,7 @@ }, "allOf": [ { - "$ref": "#/definitions/ResourceReference" + "$ref": "#/definitions/ResourceTemplateReference" } ], "required": [ @@ -1437,10 +1439,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskResultMethod" + "$ref": "#/definitions/GetTaskPayloadMethod" }, "params": { - "$ref": "#/definitions/GetTaskResultParams" + "$ref": "#/definitions/GetTaskPayloadParams" } }, "required": [ @@ -1581,10 +1583,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskInfoMethod" + "$ref": "#/definitions/GetTaskMethod" }, "params": { - "$ref": "#/definitions/GetTaskInfoParams" + "$ref": "#/definitions/GetTaskParams" } }, "required": [ @@ -1708,7 +1710,85 @@ "method" ] }, + "Resource": { + "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", "anyOf": [ { "type": "object", @@ -1768,7 +1848,7 @@ } ] }, - "ResourceReference": { + "ResourceTemplateReference": { "type": "object", "properties": { "uri": { @@ -1797,6 +1877,13 @@ "Root": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "type": [ "string", @@ -1854,17 +1941,17 @@ "description": "Single or array content wrapper (SEP-1577).", "anyOf": [ { - "$ref": "#/definitions/SamplingMessageContent" + "$ref": "#/definitions/SamplingMessageContentBlock" }, { "type": "array", "items": { - "$ref": "#/definitions/SamplingMessageContent" + "$ref": "#/definitions/SamplingMessageContentBlock" } } ] }, - "SamplingMessageContent": { + "SamplingMessageContentBlock": { "description": "Content types for sampling messages (SEP-1577).", "oneOf": [ { @@ -1877,7 +1964,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawTextContent" + "$ref": "#/definitions/TextContent" } ], "required": [ @@ -1894,7 +1981,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawImageContent" + "$ref": "#/definitions/ImageContent" } ], "required": [ @@ -1911,7 +1998,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawAudioContent" + "$ref": "#/definitions/AudioContent" } ], "required": [ @@ -2025,6 +2112,20 @@ "uri" ] }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", + "type": "object", + "properties": { + "ttl": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + } + }, "TaskRequestsCapability": { "description": "Request types that support task-augmented execution.", "type": "object", @@ -2061,6 +2162,105 @@ } } }, + "TaskStatus": { + "description": "Canonical task lifecycle status as defined by SEP-1686.", + "oneOf": [ + { + "description": "The receiver accepted the request and is currently working on it.", + "type": "string", + "const": "working" + }, + { + "description": "The receiver requires additional input before work can continue.", + "type": "string", + "const": "input_required" + }, + { + "description": "The underlying operation completed successfully and the result is ready.", + "type": "string", + "const": "completed" + }, + { + "description": "The underlying operation failed and will not continue.", + "type": "string", + "const": "failed" + }, + { + "description": "The task was cancelled and will not continue processing.", + "type": "string", + "const": "cancelled" + } + ] + }, + "TaskStatusNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tasks/status" + }, + "TaskStatusNotificationParam": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "TasksCapability": { "description": "Task capabilities shared by client and server.", "type": "object", @@ -2091,12 +2291,43 @@ } } }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, "ToolResultContent": { "description": "Tool execution result in user message (SEP-1577).", "type": "object", "properties": { "_meta": { - "description": "Optional metadata", "type": [ "object", "null" @@ -2104,21 +2335,18 @@ "additionalProperties": true }, "content": { - "description": "Content blocks returned by the tool", "type": "array", "items": { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/ContentBlock" } }, "isError": { - "description": "Whether tool execution failed", "type": [ "boolean", "null" ] }, "structuredContent": { - "description": "Optional structured result", "type": [ "object", "null" @@ -2126,12 +2354,12 @@ "additionalProperties": true }, "toolUseId": { - "description": "ID of the corresponding tool use", "type": "string" } }, "required": [ - "toolUseId" + "toolUseId", + "content" ] }, "ToolUseContent": { @@ -2139,7 +2367,6 @@ "type": "object", "properties": { "_meta": { - "description": "Optional metadata (preserved for caching)", "type": [ "object", "null" @@ -2147,16 +2374,13 @@ "additionalProperties": true }, "id": { - "description": "Unique identifier for this tool call", "type": "string" }, "input": { - "description": "Input arguments for the tool", "type": "object", "additionalProperties": true }, "name": { - "description": "Name of the tool to call", "type": "string" } }, diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 952aff8e4..46378bc9c 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -37,109 +37,8 @@ } ], "definitions": { - "Annotated": { - "type": "object", - "properties": { - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - } - }, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawTextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawEmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawAudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawResource" - } - ], - "required": [ - "type" - ] - } - ] - }, "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", "type": "object", "properties": { "audience": { @@ -182,6 +81,43 @@ "value" ] }, + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded audio data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, "CallToolRequestMethod": { "type": "string", "format": "const", @@ -213,11 +149,14 @@ }, "task": { "description": "Task metadata for async task management (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/TaskMetadata" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -256,6 +195,13 @@ "CancelledNotificationParam": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "reason": { "type": [ "string", @@ -263,12 +209,16 @@ ] }, "requestId": { - "$ref": "#/definitions/NumberOrString" + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] } - }, - "required": [ - "requestId" - ] + } }, "ClientCapabilities": { "title": "Builder", @@ -350,7 +300,7 @@ "$ref": "#/definitions/ListRootsResult" }, { - "$ref": "#/definitions/CreateElicitationResult" + "$ref": "#/definitions/ElicitResult" }, { "$ref": "#/definitions/EmptyObject" @@ -415,32 +365,94 @@ } } }, - "CreateElicitationResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "type": [ - "object", - "null" + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } ], - "additionalProperties": true + "required": [ + "type" + ] }, - "action": { - "description": "The user's decision on how to handle the elicitation request", + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, "allOf": [ { - "$ref": "#/definitions/ElicitationAction" + "$ref": "#/definitions/ImageContent" } + ], + "required": [ + "type" ] }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], + "required": [ + "type" + ] } - }, - "required": [ - "action" ] }, "CreateMessageResult": { @@ -517,6 +529,34 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, "ElicitationAction": { "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", "oneOf": [ @@ -577,6 +617,42 @@ } } }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object", @@ -657,12 +733,12 @@ "name" ] }, - "GetTaskInfoMethod": { + "GetTaskMethod": { "type": "string", "format": "const", "const": "tasks/get" }, - "GetTaskInfoParams": { + "GetTaskParams": { "type": "object", "properties": { "_meta": { @@ -681,12 +757,12 @@ "taskId" ] }, - "GetTaskResultMethod": { + "GetTaskPayloadMethod": { "type": "string", "format": "const", "const": "tasks/result" }, - "GetTaskResultParams": { + "GetTaskPayloadParams": { "type": "object", "properties": { "_meta": { @@ -761,6 +837,43 @@ } ] }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, "Implementation": { "type": "object", "properties": { @@ -901,6 +1014,9 @@ { "$ref": "#/definitions/NotificationNoParam2" }, + { + "$ref": "#/definitions/Notification3" + }, { "$ref": "#/definitions/CustomNotification" } @@ -1022,6 +1138,13 @@ "ListRootsResult": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "roots": { "type": "array", "items": { @@ -1087,6 +1210,21 @@ "params" ] }, + "Notification3": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/TaskStatusNotificationMethod" + }, + "params": { + "$ref": "#/definitions/TaskStatusNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, "NotificationNoParam": { "type": "object", "properties": { @@ -1138,220 +1276,84 @@ } } }, - "PingRequestMethod": { - "type": "string", - "format": "const", - "const": "ping" - }, - "ProgressNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/progress" - }, - "ProgressNotificationParam": { - "type": "object", - "properties": { - "message": { - "description": "An optional message describing the current progress.", - "type": [ - "string", - "null" - ] - }, - "progress": { - "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", - "type": "number", - "format": "double" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken" - }, - "total": { - "description": "Total number of items to process (or total progress required), if known", - "type": [ - "number", - "null" - ], - "format": "double" - } - }, - "required": [ - "progressToken", - "progress" - ] - }, - "ProgressToken": { - "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", - "allOf": [ - { - "$ref": "#/definitions/NumberOrString" - } - ] - }, - "PromptReference": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "name" - ] - }, - "ProtocolVersion": { - "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", - "type": "string" - }, - "RawAudioContent": { - "type": "object", - "properties": { - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "RawEmbeddedResource": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "resource": { - "$ref": "#/definitions/ResourceContents" - } - }, - "required": [ - "resource" - ] - }, - "RawImageContent": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "data": { - "description": "The base64-encoded image", - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] + "PingRequestMethod": { + "type": "string", + "format": "const", + "const": "ping" + }, + "ProgressNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/progress" }, - "RawResource": { - "description": "Represents a resource in the extension with metadata", + "ProgressNotificationParam": { "type": "object", "properties": { "_meta": { - "description": "Optional additional metadata for this resource", "type": [ "object", "null" ], "additionalProperties": true }, - "description": { - "description": "Optional description of the resource", + "message": { + "description": "An optional message describing the current progress.", "type": [ "string", "null" ] }, - "icons": { - "description": "Optional list of icons for the resource", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "MIME type of the resource content (\"text\" or \"blob\")", - "type": [ - "string", - "null" - ] + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number", + "format": "double" }, - "name": { - "description": "Name of the resource", - "type": "string" + "progressToken": { + "$ref": "#/definitions/ProgressToken" }, - "size": { - "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window us", + "total": { + "description": "Total number of items to process (or total progress required), if known", "type": [ - "integer", + "number", "null" ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Human-readable title of the resource", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "URI representing the resource location (e.g., \"file:///path/to/file\" or \"str:///content\")", - "type": "string" + "format": "double" } }, "required": [ - "uri", - "name" + "progressToken", + "progress" + ] + }, + "ProgressToken": { + "description": "A token used to track the progress of long-running operations.\n\nProgress tokens allow clients and servers to associate progress notifications\nwith specific requests, enabling real-time updates on operation status.", + "allOf": [ + { + "$ref": "#/definitions/NumberOrString" + } ] }, - "RawTextContent": { + "PromptReference": { "type": "object", "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", + "name": { + "type": "string" + }, + "title": { "type": [ - "object", + "string", "null" - ], - "additionalProperties": true - }, - "text": { - "type": "string" + ] } }, "required": [ - "text" + "name" ] }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" + }, "ReadResourceRequestMethod": { "type": "string", "format": "const", @@ -1390,7 +1392,7 @@ }, "allOf": [ { - "$ref": "#/definitions/ResourceReference" + "$ref": "#/definitions/ResourceTemplateReference" } ], "required": [ @@ -1437,10 +1439,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskResultMethod" + "$ref": "#/definitions/GetTaskPayloadMethod" }, "params": { - "$ref": "#/definitions/GetTaskResultParams" + "$ref": "#/definitions/GetTaskPayloadParams" } }, "required": [ @@ -1581,10 +1583,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskInfoMethod" + "$ref": "#/definitions/GetTaskMethod" }, "params": { - "$ref": "#/definitions/GetTaskInfoParams" + "$ref": "#/definitions/GetTaskParams" } }, "required": [ @@ -1708,7 +1710,85 @@ "method" ] }, + "Resource": { + "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", "anyOf": [ { "type": "object", @@ -1768,7 +1848,7 @@ } ] }, - "ResourceReference": { + "ResourceTemplateReference": { "type": "object", "properties": { "uri": { @@ -1797,6 +1877,13 @@ "Root": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "type": [ "string", @@ -1854,17 +1941,17 @@ "description": "Single or array content wrapper (SEP-1577).", "anyOf": [ { - "$ref": "#/definitions/SamplingMessageContent" + "$ref": "#/definitions/SamplingMessageContentBlock" }, { "type": "array", "items": { - "$ref": "#/definitions/SamplingMessageContent" + "$ref": "#/definitions/SamplingMessageContentBlock" } } ] }, - "SamplingMessageContent": { + "SamplingMessageContentBlock": { "description": "Content types for sampling messages (SEP-1577).", "oneOf": [ { @@ -1877,7 +1964,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawTextContent" + "$ref": "#/definitions/TextContent" } ], "required": [ @@ -1894,7 +1981,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawImageContent" + "$ref": "#/definitions/ImageContent" } ], "required": [ @@ -1911,7 +1998,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawAudioContent" + "$ref": "#/definitions/AudioContent" } ], "required": [ @@ -2025,6 +2112,20 @@ "uri" ] }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", + "type": "object", + "properties": { + "ttl": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + } + }, "TaskRequestsCapability": { "description": "Request types that support task-augmented execution.", "type": "object", @@ -2061,6 +2162,105 @@ } } }, + "TaskStatus": { + "description": "Canonical task lifecycle status as defined by SEP-1686.", + "oneOf": [ + { + "description": "The receiver accepted the request and is currently working on it.", + "type": "string", + "const": "working" + }, + { + "description": "The receiver requires additional input before work can continue.", + "type": "string", + "const": "input_required" + }, + { + "description": "The underlying operation completed successfully and the result is ready.", + "type": "string", + "const": "completed" + }, + { + "description": "The underlying operation failed and will not continue.", + "type": "string", + "const": "failed" + }, + { + "description": "The task was cancelled and will not continue processing.", + "type": "string", + "const": "cancelled" + } + ] + }, + "TaskStatusNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tasks/status" + }, + "TaskStatusNotificationParam": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "TasksCapability": { "description": "Task capabilities shared by client and server.", "type": "object", @@ -2091,12 +2291,43 @@ } } }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, "ToolResultContent": { "description": "Tool execution result in user message (SEP-1577).", "type": "object", "properties": { "_meta": { - "description": "Optional metadata", "type": [ "object", "null" @@ -2104,21 +2335,18 @@ "additionalProperties": true }, "content": { - "description": "Content blocks returned by the tool", "type": "array", "items": { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/ContentBlock" } }, "isError": { - "description": "Whether tool execution failed", "type": [ "boolean", "null" ] }, "structuredContent": { - "description": "Optional structured result", "type": [ "object", "null" @@ -2126,12 +2354,12 @@ "additionalProperties": true }, "toolUseId": { - "description": "ID of the corresponding tool use", "type": "string" } }, "required": [ - "toolUseId" + "toolUseId", + "content" ] }, "ToolUseContent": { @@ -2139,7 +2367,6 @@ "type": "object", "properties": { "_meta": { - "description": "Optional metadata (preserved for caching)", "type": [ "object", "null" @@ -2147,16 +2374,13 @@ "additionalProperties": true }, "id": { - "description": "Unique identifier for this tool call", "type": "string" }, "input": { - "description": "Input arguments for the tool", "type": "object", "additionalProperties": true }, "name": { - "description": "Name of the tool to call", "type": "string" } }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 5cb0cc8f1..2b3b41732 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -37,188 +37,54 @@ } ], "definitions": { - "Annotated": { - "type": "object", - "properties": { - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - } - }, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawTextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawEmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawAudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawResource" - } - ], - "required": [ - "type" - ] - } - ] - }, - "Annotated2": { - "description": "Represents a resource in the extension with metadata", + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", "type": "object", "properties": { - "_meta": { - "description": "Optional additional metadata for this resource", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of the resource", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the resource", + "audience": { "type": [ "array", "null" ], "items": { - "$ref": "#/definitions/Icon" + "$ref": "#/definitions/Role" } }, - "mimeType": { - "description": "MIME type of the resource content (\"text\" or \"blob\")", + "lastModified": { "type": [ "string", "null" - ] - }, - "name": { - "description": "Name of the resource", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window us", - "type": [ - "integer", - "null" ], - "format": "uint32", - "minimum": 0 + "format": "date-time" }, - "title": { - "description": "Human-readable title of the resource", + "priority": { "type": [ - "string", + "number", "null" - ] - }, - "uri": { - "description": "URI representing the resource location (e.g., \"file:///path/to/file\" or \"str:///content\")", - "type": "string" + ], + "format": "float" } - }, - "required": [ - "uri", - "name" - ] + } + }, + "ArrayTypeConst": { + "type": "string", + "format": "const", + "const": "array" }, - "Annotated3": { + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "annotations": { + "description": "Optional annotations describing how the client should use this content.", "anyOf": [ { "$ref": "#/definitions/Annotations" @@ -228,79 +94,20 @@ } ] }, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the resource template", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { + "data": { + "description": "The base64-encoded audio data.", "type": "string" }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", "type": "string" } }, "required": [ - "uriTemplate", - "name" + "data", + "mimeType" ] }, - "Annotations": { - "type": "object", - "properties": { - "audience": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Role" - } - }, - "lastModified": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "priority": { - "type": [ - "number", - "null" - ], - "format": "float" - } - } - }, - "ArrayTypeConst": { - "type": "string", - "format": "const", - "const": "array" - }, "BooleanSchema": { "description": "Schema definition for boolean properties.", "type": "object", @@ -361,7 +168,7 @@ "type": "array", "default": [], "items": { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/ContentBlock" } }, "isError": { @@ -448,6 +255,13 @@ "CancelledNotificationParam": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "reason": { "type": [ "string", @@ -455,18 +269,29 @@ ] }, "requestId": { - "$ref": "#/definitions/NumberOrString" + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] } - }, - "required": [ - "requestId" - ] + } }, "CompleteResult": { "type": "object", "properties": { - "completion": { - "$ref": "#/definitions/CompletionInfo" + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "completion": { + "$ref": "#/definitions/CompletionInfo" } }, "required": [ @@ -517,137 +342,114 @@ "title" ] }, - "ContextInclusion": { - "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", "oneOf": [ { - "description": "Include context from all connected MCP servers", - "type": "string", - "const": "allServers" + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] }, { - "description": "Include no additional context", - "type": "string", - "const": "none" + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] }, - { - "description": "Include context only from the requesting server", - "type": "string", - "const": "thisServer" - } - ] - }, - "CreateElicitationRequestParams": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = CreateElicitationRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", - "anyOf": [ { "type": "object", "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "message": { - "type": "string" - }, - "mode": { + "type": { "type": "string", - "const": "form" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" + "const": "audio" } }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], "required": [ - "mode", - "message", - "requestedSchema" + "type" ] }, { "type": "object", "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "elicitationId": { - "type": "string" - }, - "message": { - "type": "string" - }, - "mode": { + "type": { "type": "string", - "const": "url" - }, - "url": { - "type": "string" + "const": "resource" } }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], "required": [ - "mode", - "message", - "url", - "elicitationId" + "type" ] }, { "type": "object", "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "message": { - "type": "string" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" + "type": { + "type": "string", + "const": "resource_link" } }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], "required": [ - "message", - "requestedSchema" + "type" ] } ] }, - "CreateElicitationResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "ContextInclusion": { + "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", + "oneOf": [ + { + "description": "Include context from all connected MCP servers", + "type": "string", + "const": "allServers" }, - "action": { - "description": "The user's decision on how to handle the elicitation request", - "allOf": [ - { - "$ref": "#/definitions/ElicitationAction" - } - ] + { + "description": "Include no additional context", + "type": "string", + "const": "none" }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + { + "description": "Include context only from the requesting server", + "type": "string", + "const": "thisServer" } - }, - "required": [ - "action" ] }, "CreateMessageRequestMethod": { @@ -724,11 +526,14 @@ }, "task": { "description": "Task metadata for async task management (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/TaskMetadata" + }, + { + "type": "null" + } + ] }, "temperature": { "description": "Temperature for controlling randomness (0.0 to 1.0)", @@ -769,6 +574,13 @@ "description": "Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "task": { "$ref": "#/definitions/Task" } @@ -806,27 +618,140 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, - "ElicitationAction": { - "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", - "oneOf": [ + "ElicitRequestParams": { + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ { - "description": "User accepts the request and provides the requested information", - "type": "string", - "const": "accept" + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] }, { - "description": "User declines to provide the information but allows the operation to continue", - "type": "string", - "const": "decline" + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] }, { - "description": "User cancels the entire operation", - "type": "string", - "const": "cancel" - } - ] - }, - "ElicitationCompletionNotificationMethod": { + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "message", + "requestedSchema" + ] + } + ] + }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, + "ElicitationAction": { + "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", + "oneOf": [ + { + "description": "User accepts the request and provides the requested information", + "type": "string", + "const": "accept" + }, + { + "description": "User declines to provide the information but allows the operation to continue", + "type": "string", + "const": "decline" + }, + { + "description": "User cancels the entire operation", + "type": "string", + "const": "cancel" + } + ] + }, + "ElicitationCompletionNotificationMethod": { "type": "string", "format": "const", "const": "notifications/elicitation/complete" @@ -840,6 +765,13 @@ "description": "Notification parameters for an url elicitation completion notification.", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "elicitationId": { "type": "string" } @@ -863,7 +795,7 @@ "description": "Property definitions (must be primitive types)", "type": "object", "additionalProperties": { - "$ref": "#/definitions/PrimitiveSchema" + "$ref": "#/definitions/PrimitiveSchemaDefinition" } }, "required": { @@ -909,6 +841,42 @@ } } }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object", @@ -961,6 +929,13 @@ "GetPromptResult": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "description": { "type": [ "string", @@ -1101,6 +1076,43 @@ } ] }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, "Implementation": { "type": "object", "properties": { @@ -1147,6 +1159,13 @@ "description": "The server's response to an initialization request.\n\nContains the server's protocol version, capabilities, and implementation\ninformation, along with optional instructions for the client.", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "capabilities": { "description": "The capabilities this server provides (tools, resources, prompts, etc.)", "allOf": [ @@ -1302,6 +1321,9 @@ { "$ref": "#/definitions/Notification5" }, + { + "$ref": "#/definitions/Notification6" + }, { "$ref": "#/definitions/CustomNotification" } @@ -1370,6 +1392,12 @@ "description": "Legacy enum schema, keep for backward compatibility", "type": "object", "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, "description": { "type": [ "string", @@ -1452,7 +1480,7 @@ "resourceTemplates": { "type": "array", "items": { - "$ref": "#/definitions/Annotated3" + "$ref": "#/definitions/ResourceTemplate" } } }, @@ -1479,7 +1507,7 @@ "resources": { "type": "array", "items": { - "$ref": "#/definitions/Annotated2" + "$ref": "#/definitions/Resource" } } }, @@ -1495,6 +1523,13 @@ "ListTasksResult": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "nextCursor": { "type": [ "string", @@ -1506,14 +1541,6 @@ "items": { "$ref": "#/definitions/Task" } - }, - "total": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 } }, "required": [ @@ -1570,6 +1597,13 @@ "description": "Parameters for a logging message notification", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "data": { "description": "The actual log data" }, @@ -1733,6 +1767,21 @@ "params" ] }, + "Notification6": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/TaskStatusNotificationMethod" + }, + "params": { + "$ref": "#/definitions/TaskStatusNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, "NotificationNoParam": { "type": "object", "properties": { @@ -1846,7 +1895,7 @@ "format": "const", "const": "ping" }, - "PrimitiveSchema": { + "PrimitiveSchemaDefinition": { "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", "anyOf": [ { @@ -1899,6 +1948,13 @@ "ProgressNotificationParam": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "message": { "description": "An optional message describing the current progress.", "type": [ @@ -1937,11 +1993,10 @@ ] }, "Prompt": { - "description": "A prompt that can be used to generate text from a model", + "description": "A prompt or prompt template that the server offers (spec `Prompt`).", "type": "object", "properties": { "_meta": { - "description": "Optional additional metadata for this prompt", "type": [ "object", "null" @@ -1949,7 +2004,6 @@ "additionalProperties": true }, "arguments": { - "description": "Optional arguments that can be passed to customize the prompt", "type": [ "array", "null" @@ -1959,14 +2013,12 @@ } }, "description": { - "description": "Optional description of what the prompt does", "type": [ "string", "null" ] }, "icons": { - "description": "Optional list of icons for the prompt", "type": [ "array", "null" @@ -1976,7 +2028,6 @@ } }, "name": { - "description": "The name of the prompt", "type": "string" }, "title": { @@ -1991,29 +2042,25 @@ ] }, "PromptArgument": { - "description": "Represents a prompt argument that can be passed to customize the prompt", + "description": "Describes an argument that a prompt can accept (spec `PromptArgument`).", "type": "object", "properties": { "description": { - "description": "A description of what the argument is used for", "type": [ "string", "null" ] }, "name": { - "description": "The name of the argument", "type": "string" }, "required": { - "description": "Whether this argument is required", "type": [ "boolean", "null" ] }, "title": { - "description": "A human-readable title for the argument", "type": [ "string", "null" @@ -2030,24 +2077,14 @@ "const": "notifications/prompts/list_changed" }, "PromptMessage": { - "description": "A message in a prompt conversation", + "description": "A message returned as part of a prompt (spec `PromptMessage`).\n\nUses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).", "type": "object", "properties": { "content": { - "description": "The content of the message", - "allOf": [ - { - "$ref": "#/definitions/PromptMessageContent" - } - ] + "$ref": "#/definitions/ContentBlock" }, "role": { - "description": "The role of the message sender", - "allOf": [ - { - "$ref": "#/definitions/PromptMessageRole" - } - ] + "$ref": "#/definitions/Role" } }, "required": [ @@ -2055,223 +2092,6 @@ "content" ] }, - "PromptMessageContent": { - "description": "Content types that can be included in prompt messages", - "oneOf": [ - { - "description": "Plain text content", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type", - "text" - ] - }, - { - "description": "Image content with base64-encoded data", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded image", - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "type": { - "type": "string", - "const": "image" - } - }, - "required": [ - "type", - "data", - "mimeType" - ] - }, - { - "description": "Audio content with base64-encoded data", - "type": "object", - "properties": { - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "type": { - "type": "string", - "const": "audio" - } - }, - "required": [ - "type", - "data", - "mimeType" - ] - }, - { - "description": "Embedded server-side resource", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "$ref": "#/definitions/ResourceContents" - }, - "type": { - "type": "string", - "const": "resource" - } - }, - "required": [ - "type", - "resource" - ] - }, - { - "description": "A link to a resource that can be fetched separately", - "type": "object", - "properties": { - "_meta": { - "description": "Optional additional metadata for this resource", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of the resource", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the resource", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "MIME type of the resource content (\"text\" or \"blob\")", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "Name of the resource", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window us", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Human-readable title of the resource", - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string", - "const": "resource_link" - }, - "uri": { - "description": "URI representing the resource location (e.g., \"file:///path/to/file\" or \"str:///content\")", - "type": "string" - } - }, - "required": [ - "type", - "uri", - "name" - ] - } - ] - }, - "PromptMessageRole": { - "description": "Represents the role of a message sender in a prompt conversation", - "type": "string", - "enum": [ - "user", - "assistant" - ] - }, "PromptsCapability": { "type": "object", "properties": { @@ -2280,160 +2100,24 @@ "boolean", "null" ] - } - } - }, - "ProtocolVersion": { - "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", - "type": "string" - }, - "RawAudioContent": { - "type": "object", - "properties": { - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "RawEmbeddedResource": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "resource": { - "$ref": "#/definitions/ResourceContents" - } - }, - "required": [ - "resource" - ] - }, - "RawImageContent": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "data": { - "description": "The base64-encoded image", - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "RawResource": { - "description": "Represents a resource in the extension with metadata", - "type": "object", - "properties": { - "_meta": { - "description": "Optional additional metadata for this resource", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "description": { - "description": "Optional description of the resource", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the resource", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "MIME type of the resource content (\"text\" or \"blob\")", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "Name of the resource", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window us", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Human-readable title of the resource", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "URI representing the resource location (e.g., \"file:///path/to/file\" or \"str:///content\")", - "type": "string" - } - }, - "required": [ - "uri", - "name" - ] + } + } + }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" }, - "RawTextContent": { + "ReadResourceResult": { + "description": "Result containing the contents of a read resource", "type": "object", "properties": { "_meta": { - "description": "Optional protocol-level metadata for this content block", "type": [ "object", "null" ], "additionalProperties": true }, - "text": { - "type": "string" - } - }, - "required": [ - "text" - ] - }, - "ReadResourceResult": { - "description": "Result containing the contents of a read resource", - "type": "object", - "properties": { "contents": { "description": "The actual content of the resource", "type": "array", @@ -2470,7 +2154,7 @@ "$ref": "#/definitions/ElicitationCreateRequestMethod" }, "params": { - "$ref": "#/definitions/CreateElicitationRequestParams" + "$ref": "#/definitions/ElicitRequestParams" } }, "required": [ @@ -2500,7 +2184,85 @@ "method" ] }, + "Resource": { + "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", "anyOf": [ { "type": "object", @@ -2565,6 +2327,74 @@ "format": "const", "const": "notifications/resources/list_changed" }, + "ResourceTemplate": { + "description": "A template description for resources available on the server (spec `ResourceTemplate`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource template.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this template.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this template is for.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this template.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type for resources matching this template, if uniform.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource template.", + "type": "string" + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uriTemplate": { + "description": "An RFC 6570 URI template for constructing resource URIs.", + "type": "string" + } + }, + "required": [ + "uriTemplate", + "name" + ] + }, "ResourceUpdatedNotificationMethod": { "type": "string", "format": "const", @@ -2574,6 +2404,13 @@ "description": "Parameters for a resource update notification", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "uri": { "description": "The URI of the resource that was updated", "type": "string" @@ -2619,12 +2456,12 @@ "description": "Single or array content wrapper (SEP-1577).", "anyOf": [ { - "$ref": "#/definitions/SamplingMessageContent" + "$ref": "#/definitions/SamplingMessageContentBlock" }, { "type": "array", "items": { - "$ref": "#/definitions/SamplingMessageContent" + "$ref": "#/definitions/SamplingMessageContentBlock" } } ] @@ -2662,7 +2499,7 @@ "content" ] }, - "SamplingMessageContent": { + "SamplingMessageContentBlock": { "description": "Content types for sampling messages (SEP-1577).", "oneOf": [ { @@ -2675,7 +2512,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawTextContent" + "$ref": "#/definitions/TextContent" } ], "required": [ @@ -2692,7 +2529,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawImageContent" + "$ref": "#/definitions/ImageContent" } ], "required": [ @@ -2709,7 +2546,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawAudioContent" + "$ref": "#/definitions/AudioContent" } ], "required": [ @@ -2877,7 +2714,7 @@ "$ref": "#/definitions/ListToolsResult" }, { - "$ref": "#/definitions/CreateElicitationResult" + "$ref": "#/definitions/ElicitResult" }, { "$ref": "#/definitions/CreateTaskResult" @@ -3070,6 +2907,20 @@ "lastUpdatedAt" ] }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", + "type": "object", + "properties": { + "ttl": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + } + }, "TaskRequestsCapability": { "description": "Request types that support task-augmented execution.", "type": "object", @@ -3136,6 +2987,75 @@ } ] }, + "TaskStatusNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tasks/status" + }, + "TaskStatusNotificationParam": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "TaskSupport": { "description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).", "oneOf": [ @@ -3186,6 +3106,38 @@ } } }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, "TitledItems": { "description": "Items for titled multi-select options", "type": "object", @@ -3476,7 +3428,6 @@ "type": "object", "properties": { "_meta": { - "description": "Optional metadata", "type": [ "object", "null" @@ -3484,21 +3435,18 @@ "additionalProperties": true }, "content": { - "description": "Content blocks returned by the tool", "type": "array", "items": { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/ContentBlock" } }, "isError": { - "description": "Whether tool execution failed", "type": [ "boolean", "null" ] }, "structuredContent": { - "description": "Optional structured result", "type": [ "object", "null" @@ -3506,12 +3454,12 @@ "additionalProperties": true }, "toolUseId": { - "description": "ID of the corresponding tool use", "type": "string" } }, "required": [ - "toolUseId" + "toolUseId", + "content" ] }, "ToolUseContent": { @@ -3519,7 +3467,6 @@ "type": "object", "properties": { "_meta": { - "description": "Optional metadata (preserved for caching)", "type": [ "object", "null" @@ -3527,16 +3474,13 @@ "additionalProperties": true }, "id": { - "description": "Unique identifier for this tool call", "type": "string" }, "input": { - "description": "Input arguments for the tool", "type": "object", "additionalProperties": true }, "name": { - "description": "Name of the tool to call", "type": "string" } }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 5cb0cc8f1..2b3b41732 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -37,188 +37,54 @@ } ], "definitions": { - "Annotated": { - "type": "object", - "properties": { - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - } - }, - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawTextContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "image" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawImageContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawEmbeddedResource" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "audio" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawAudioContent" - } - ], - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "resource_link" - } - }, - "allOf": [ - { - "$ref": "#/definitions/RawResource" - } - ], - "required": [ - "type" - ] - } - ] - }, - "Annotated2": { - "description": "Represents a resource in the extension with metadata", + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are\nused or displayed.", "type": "object", "properties": { - "_meta": { - "description": "Optional additional metadata for this resource", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of the resource", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the resource", + "audience": { "type": [ "array", "null" ], "items": { - "$ref": "#/definitions/Icon" + "$ref": "#/definitions/Role" } }, - "mimeType": { - "description": "MIME type of the resource content (\"text\" or \"blob\")", + "lastModified": { "type": [ "string", "null" - ] - }, - "name": { - "description": "Name of the resource", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window us", - "type": [ - "integer", - "null" ], - "format": "uint32", - "minimum": 0 + "format": "date-time" }, - "title": { - "description": "Human-readable title of the resource", + "priority": { "type": [ - "string", + "number", "null" - ] - }, - "uri": { - "description": "URI representing the resource location (e.g., \"file:///path/to/file\" or \"str:///content\")", - "type": "string" + ], + "format": "float" } - }, - "required": [ - "uri", - "name" - ] + } + }, + "ArrayTypeConst": { + "type": "string", + "format": "const", + "const": "array" }, - "Annotated3": { + "AudioContent": { + "description": "Audio content with base64-encoded data (spec `AudioContent`).", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "annotations": { + "description": "Optional annotations describing how the client should use this content.", "anyOf": [ { "$ref": "#/definitions/Annotations" @@ -228,79 +94,20 @@ } ] }, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the resource template", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { + "data": { + "description": "The base64-encoded audio data.", "type": "string" }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { + "mimeType": { + "description": "The MIME type of the audio (e.g. `audio/wav`).", "type": "string" } }, "required": [ - "uriTemplate", - "name" + "data", + "mimeType" ] }, - "Annotations": { - "type": "object", - "properties": { - "audience": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Role" - } - }, - "lastModified": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "priority": { - "type": [ - "number", - "null" - ], - "format": "float" - } - } - }, - "ArrayTypeConst": { - "type": "string", - "format": "const", - "const": "array" - }, "BooleanSchema": { "description": "Schema definition for boolean properties.", "type": "object", @@ -361,7 +168,7 @@ "type": "array", "default": [], "items": { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/ContentBlock" } }, "isError": { @@ -448,6 +255,13 @@ "CancelledNotificationParam": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "reason": { "type": [ "string", @@ -455,18 +269,29 @@ ] }, "requestId": { - "$ref": "#/definitions/NumberOrString" + "anyOf": [ + { + "$ref": "#/definitions/NumberOrString" + }, + { + "type": "null" + } + ] } - }, - "required": [ - "requestId" - ] + } }, "CompleteResult": { "type": "object", "properties": { - "completion": { - "$ref": "#/definitions/CompletionInfo" + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "completion": { + "$ref": "#/definitions/CompletionInfo" } }, "required": [ @@ -517,137 +342,114 @@ "title" ] }, - "ContextInclusion": { - "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", + "ContentBlock": { + "description": "Unified content block union (spec `ContentBlock`).\n\n`text | image | audio | resource_link | resource`", "oneOf": [ { - "description": "Include context from all connected MCP servers", - "type": "string", - "const": "allServers" + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "allOf": [ + { + "$ref": "#/definitions/TextContent" + } + ], + "required": [ + "type" + ] }, { - "description": "Include no additional context", - "type": "string", - "const": "none" + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "allOf": [ + { + "$ref": "#/definitions/ImageContent" + } + ], + "required": [ + "type" + ] }, - { - "description": "Include context only from the requesting server", - "type": "string", - "const": "thisServer" - } - ] - }, - "CreateElicitationRequestParams": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = CreateElicitationRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", - "anyOf": [ { "type": "object", "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "message": { - "type": "string" - }, - "mode": { + "type": { "type": "string", - "const": "form" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" + "const": "audio" } }, + "allOf": [ + { + "$ref": "#/definitions/AudioContent" + } + ], "required": [ - "mode", - "message", - "requestedSchema" + "type" ] }, { "type": "object", "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "elicitationId": { - "type": "string" - }, - "message": { - "type": "string" - }, - "mode": { + "type": { "type": "string", - "const": "url" - }, - "url": { - "type": "string" + "const": "resource" } }, + "allOf": [ + { + "$ref": "#/definitions/EmbeddedResource" + } + ], "required": [ - "mode", - "message", - "url", - "elicitationId" + "type" ] }, { "type": "object", "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "message": { - "type": "string" - }, - "requestedSchema": { - "$ref": "#/definitions/ElicitationSchema" + "type": { + "type": "string", + "const": "resource_link" } }, + "allOf": [ + { + "$ref": "#/definitions/Resource" + } + ], "required": [ - "message", - "requestedSchema" + "type" ] } ] }, - "CreateElicitationResult": { - "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this result.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "ContextInclusion": { + "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", + "oneOf": [ + { + "description": "Include context from all connected MCP servers", + "type": "string", + "const": "allServers" }, - "action": { - "description": "The user's decision on how to handle the elicitation request", - "allOf": [ - { - "$ref": "#/definitions/ElicitationAction" - } - ] + { + "description": "Include no additional context", + "type": "string", + "const": "none" }, - "content": { - "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + { + "description": "Include context only from the requesting server", + "type": "string", + "const": "thisServer" } - }, - "required": [ - "action" ] }, "CreateMessageRequestMethod": { @@ -724,11 +526,14 @@ }, "task": { "description": "Task metadata for async task management (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/TaskMetadata" + }, + { + "type": "null" + } + ] }, "temperature": { "description": "Temperature for controlling randomness (0.0 to 1.0)", @@ -769,6 +574,13 @@ "description": "Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "task": { "$ref": "#/definitions/Task" } @@ -806,27 +618,140 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, - "ElicitationAction": { - "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", - "oneOf": [ + "ElicitRequestParams": { + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ { - "description": "User accepts the request and provides the requested information", - "type": "string", - "const": "accept" + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] }, { - "description": "User declines to provide the information but allows the operation to continue", - "type": "string", - "const": "decline" + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] }, { - "description": "User cancels the entire operation", - "type": "string", - "const": "cancel" - } - ] - }, - "ElicitationCompletionNotificationMethod": { + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "message": { + "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "message", + "requestedSchema" + ] + } + ] + }, + "ElicitResult": { + "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "action": { + "description": "The user's decision on how to handle the elicitation request", + "allOf": [ + { + "$ref": "#/definitions/ElicitationAction" + } + ] + }, + "content": { + "description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept." + } + }, + "required": [ + "action" + ] + }, + "ElicitationAction": { + "description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation", + "oneOf": [ + { + "description": "User accepts the request and provides the requested information", + "type": "string", + "const": "accept" + }, + { + "description": "User declines to provide the information but allows the operation to continue", + "type": "string", + "const": "decline" + }, + { + "description": "User cancels the entire operation", + "type": "string", + "const": "cancel" + } + ] + }, + "ElicitationCompletionNotificationMethod": { "type": "string", "format": "const", "const": "notifications/elicitation/complete" @@ -840,6 +765,13 @@ "description": "Notification parameters for an url elicitation completion notification.", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "elicitationId": { "type": "string" } @@ -863,7 +795,7 @@ "description": "Property definitions (must be primitive types)", "type": "object", "additionalProperties": { - "$ref": "#/definitions/PrimitiveSchema" + "$ref": "#/definitions/PrimitiveSchemaDefinition" } }, "required": { @@ -909,6 +841,42 @@ } } }, + "EmbeddedResource": { + "description": "Embedded resource content (spec `EmbeddedResource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "description": "The embedded resource contents (text or blob).", + "allOf": [ + { + "$ref": "#/definitions/ResourceContents" + } + ] + } + }, + "required": [ + "resource" + ] + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object", @@ -961,6 +929,13 @@ "GetPromptResult": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "description": { "type": [ "string", @@ -1101,6 +1076,43 @@ } ] }, + "ImageContent": { + "description": "Image content with base64-encoded data (spec `ImageContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "description": "The base64-encoded image data.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image (e.g. `image/png`).", + "type": "string" + } + }, + "required": [ + "data", + "mimeType" + ] + }, "Implementation": { "type": "object", "properties": { @@ -1147,6 +1159,13 @@ "description": "The server's response to an initialization request.\n\nContains the server's protocol version, capabilities, and implementation\ninformation, along with optional instructions for the client.", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "capabilities": { "description": "The capabilities this server provides (tools, resources, prompts, etc.)", "allOf": [ @@ -1302,6 +1321,9 @@ { "$ref": "#/definitions/Notification5" }, + { + "$ref": "#/definitions/Notification6" + }, { "$ref": "#/definitions/CustomNotification" } @@ -1370,6 +1392,12 @@ "description": "Legacy enum schema, keep for backward compatibility", "type": "object", "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, "description": { "type": [ "string", @@ -1452,7 +1480,7 @@ "resourceTemplates": { "type": "array", "items": { - "$ref": "#/definitions/Annotated3" + "$ref": "#/definitions/ResourceTemplate" } } }, @@ -1479,7 +1507,7 @@ "resources": { "type": "array", "items": { - "$ref": "#/definitions/Annotated2" + "$ref": "#/definitions/Resource" } } }, @@ -1495,6 +1523,13 @@ "ListTasksResult": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "nextCursor": { "type": [ "string", @@ -1506,14 +1541,6 @@ "items": { "$ref": "#/definitions/Task" } - }, - "total": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 } }, "required": [ @@ -1570,6 +1597,13 @@ "description": "Parameters for a logging message notification", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "data": { "description": "The actual log data" }, @@ -1733,6 +1767,21 @@ "params" ] }, + "Notification6": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/TaskStatusNotificationMethod" + }, + "params": { + "$ref": "#/definitions/TaskStatusNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, "NotificationNoParam": { "type": "object", "properties": { @@ -1846,7 +1895,7 @@ "format": "const", "const": "ping" }, - "PrimitiveSchema": { + "PrimitiveSchemaDefinition": { "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", "anyOf": [ { @@ -1899,6 +1948,13 @@ "ProgressNotificationParam": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "message": { "description": "An optional message describing the current progress.", "type": [ @@ -1937,11 +1993,10 @@ ] }, "Prompt": { - "description": "A prompt that can be used to generate text from a model", + "description": "A prompt or prompt template that the server offers (spec `Prompt`).", "type": "object", "properties": { "_meta": { - "description": "Optional additional metadata for this prompt", "type": [ "object", "null" @@ -1949,7 +2004,6 @@ "additionalProperties": true }, "arguments": { - "description": "Optional arguments that can be passed to customize the prompt", "type": [ "array", "null" @@ -1959,14 +2013,12 @@ } }, "description": { - "description": "Optional description of what the prompt does", "type": [ "string", "null" ] }, "icons": { - "description": "Optional list of icons for the prompt", "type": [ "array", "null" @@ -1976,7 +2028,6 @@ } }, "name": { - "description": "The name of the prompt", "type": "string" }, "title": { @@ -1991,29 +2042,25 @@ ] }, "PromptArgument": { - "description": "Represents a prompt argument that can be passed to customize the prompt", + "description": "Describes an argument that a prompt can accept (spec `PromptArgument`).", "type": "object", "properties": { "description": { - "description": "A description of what the argument is used for", "type": [ "string", "null" ] }, "name": { - "description": "The name of the argument", "type": "string" }, "required": { - "description": "Whether this argument is required", "type": [ "boolean", "null" ] }, "title": { - "description": "A human-readable title for the argument", "type": [ "string", "null" @@ -2030,24 +2077,14 @@ "const": "notifications/prompts/list_changed" }, "PromptMessage": { - "description": "A message in a prompt conversation", + "description": "A message returned as part of a prompt (spec `PromptMessage`).\n\nUses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).", "type": "object", "properties": { "content": { - "description": "The content of the message", - "allOf": [ - { - "$ref": "#/definitions/PromptMessageContent" - } - ] + "$ref": "#/definitions/ContentBlock" }, "role": { - "description": "The role of the message sender", - "allOf": [ - { - "$ref": "#/definitions/PromptMessageRole" - } - ] + "$ref": "#/definitions/Role" } }, "required": [ @@ -2055,223 +2092,6 @@ "content" ] }, - "PromptMessageContent": { - "description": "Content types that can be included in prompt messages", - "oneOf": [ - { - "description": "Plain text content", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type", - "text" - ] - }, - { - "description": "Image content with base64-encoded data", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "description": "The base64-encoded image", - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "type": { - "type": "string", - "const": "image" - } - }, - "required": [ - "type", - "data", - "mimeType" - ] - }, - { - "description": "Audio content with base64-encoded data", - "type": "object", - "properties": { - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "type": { - "type": "string", - "const": "audio" - } - }, - "required": [ - "type", - "data", - "mimeType" - ] - }, - { - "description": "Embedded server-side resource", - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "$ref": "#/definitions/ResourceContents" - }, - "type": { - "type": "string", - "const": "resource" - } - }, - "required": [ - "type", - "resource" - ] - }, - { - "description": "A link to a resource that can be fetched separately", - "type": "object", - "properties": { - "_meta": { - "description": "Optional additional metadata for this resource", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/definitions/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional description of the resource", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the resource", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "MIME type of the resource content (\"text\" or \"blob\")", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "Name of the resource", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window us", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Human-readable title of the resource", - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string", - "const": "resource_link" - }, - "uri": { - "description": "URI representing the resource location (e.g., \"file:///path/to/file\" or \"str:///content\")", - "type": "string" - } - }, - "required": [ - "type", - "uri", - "name" - ] - } - ] - }, - "PromptMessageRole": { - "description": "Represents the role of a message sender in a prompt conversation", - "type": "string", - "enum": [ - "user", - "assistant" - ] - }, "PromptsCapability": { "type": "object", "properties": { @@ -2280,160 +2100,24 @@ "boolean", "null" ] - } - } - }, - "ProtocolVersion": { - "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", - "type": "string" - }, - "RawAudioContent": { - "type": "object", - "properties": { - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "RawEmbeddedResource": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "resource": { - "$ref": "#/definitions/ResourceContents" - } - }, - "required": [ - "resource" - ] - }, - "RawImageContent": { - "type": "object", - "properties": { - "_meta": { - "description": "Optional protocol-level metadata for this content block", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "data": { - "description": "The base64-encoded image", - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ] - }, - "RawResource": { - "description": "Represents a resource in the extension with metadata", - "type": "object", - "properties": { - "_meta": { - "description": "Optional additional metadata for this resource", - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "description": { - "description": "Optional description of the resource", - "type": [ - "string", - "null" - ] - }, - "icons": { - "description": "Optional list of icons for the resource", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Icon" - } - }, - "mimeType": { - "description": "MIME type of the resource content (\"text\" or \"blob\")", - "type": [ - "string", - "null" - ] - }, - "name": { - "description": "Name of the resource", - "type": "string" - }, - "size": { - "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window us", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "title": { - "description": "Human-readable title of the resource", - "type": [ - "string", - "null" - ] - }, - "uri": { - "description": "URI representing the resource location (e.g., \"file:///path/to/file\" or \"str:///content\")", - "type": "string" - } - }, - "required": [ - "uri", - "name" - ] + } + } + }, + "ProtocolVersion": { + "description": "Represents the MCP protocol version used for communication.\n\nThis ensures compatibility between clients and servers by specifying\nwhich version of the Model Context Protocol is being used.", + "type": "string" }, - "RawTextContent": { + "ReadResourceResult": { + "description": "Result containing the contents of a read resource", "type": "object", "properties": { "_meta": { - "description": "Optional protocol-level metadata for this content block", "type": [ "object", "null" ], "additionalProperties": true }, - "text": { - "type": "string" - } - }, - "required": [ - "text" - ] - }, - "ReadResourceResult": { - "description": "Result containing the contents of a read resource", - "type": "object", - "properties": { "contents": { "description": "The actual content of the resource", "type": "array", @@ -2470,7 +2154,7 @@ "$ref": "#/definitions/ElicitationCreateRequestMethod" }, "params": { - "$ref": "#/definitions/CreateElicitationRequestParams" + "$ref": "#/definitions/ElicitRequestParams" } }, "required": [ @@ -2500,7 +2184,85 @@ "method" ] }, + "Resource": { + "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this resource.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this resource represents.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this resource.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content in bytes (before base64/tokenization), if known.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource (e.g. `file:///path/to/file`).", + "type": "string" + } + }, + "required": [ + "uri", + "name" + ] + }, "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", "anyOf": [ { "type": "object", @@ -2565,6 +2327,74 @@ "format": "const", "const": "notifications/resources/list_changed" }, + "ResourceTemplate": { + "description": "A template description for resources available on the server (spec `ResourceTemplate`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this resource template.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this template.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional description of what this template is for.", + "type": [ + "string", + "null" + ] + }, + "icons": { + "description": "Optional set of icons the client may display for this template.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Icon" + } + }, + "mimeType": { + "description": "The MIME type for resources matching this template, if uniform.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The programmatic name of the resource template.", + "type": "string" + }, + "title": { + "description": "Optional human-readable display title.", + "type": [ + "string", + "null" + ] + }, + "uriTemplate": { + "description": "An RFC 6570 URI template for constructing resource URIs.", + "type": "string" + } + }, + "required": [ + "uriTemplate", + "name" + ] + }, "ResourceUpdatedNotificationMethod": { "type": "string", "format": "const", @@ -2574,6 +2404,13 @@ "description": "Parameters for a resource update notification", "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "uri": { "description": "The URI of the resource that was updated", "type": "string" @@ -2619,12 +2456,12 @@ "description": "Single or array content wrapper (SEP-1577).", "anyOf": [ { - "$ref": "#/definitions/SamplingMessageContent" + "$ref": "#/definitions/SamplingMessageContentBlock" }, { "type": "array", "items": { - "$ref": "#/definitions/SamplingMessageContent" + "$ref": "#/definitions/SamplingMessageContentBlock" } } ] @@ -2662,7 +2499,7 @@ "content" ] }, - "SamplingMessageContent": { + "SamplingMessageContentBlock": { "description": "Content types for sampling messages (SEP-1577).", "oneOf": [ { @@ -2675,7 +2512,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawTextContent" + "$ref": "#/definitions/TextContent" } ], "required": [ @@ -2692,7 +2529,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawImageContent" + "$ref": "#/definitions/ImageContent" } ], "required": [ @@ -2709,7 +2546,7 @@ }, "allOf": [ { - "$ref": "#/definitions/RawAudioContent" + "$ref": "#/definitions/AudioContent" } ], "required": [ @@ -2877,7 +2714,7 @@ "$ref": "#/definitions/ListToolsResult" }, { - "$ref": "#/definitions/CreateElicitationResult" + "$ref": "#/definitions/ElicitResult" }, { "$ref": "#/definitions/CreateTaskResult" @@ -3070,6 +2907,20 @@ "lastUpdatedAt" ] }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", + "type": "object", + "properties": { + "ttl": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + } + }, "TaskRequestsCapability": { "description": "Request types that support task-augmented execution.", "type": "object", @@ -3136,6 +2987,75 @@ } ] }, + "TaskStatusNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/tasks/status" + }, + "TaskStatusNotificationParam": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "TaskSupport": { "description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).", "oneOf": [ @@ -3186,6 +3106,38 @@ } } }, + "TextContent": { + "description": "Text content block (spec `TextContent`).", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this content block.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "annotations": { + "description": "Optional annotations describing how the client should use this content.", + "anyOf": [ + { + "$ref": "#/definitions/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "description": "The text content of the message.", + "type": "string" + } + }, + "required": [ + "text" + ] + }, "TitledItems": { "description": "Items for titled multi-select options", "type": "object", @@ -3476,7 +3428,6 @@ "type": "object", "properties": { "_meta": { - "description": "Optional metadata", "type": [ "object", "null" @@ -3484,21 +3435,18 @@ "additionalProperties": true }, "content": { - "description": "Content blocks returned by the tool", "type": "array", "items": { - "$ref": "#/definitions/Annotated" + "$ref": "#/definitions/ContentBlock" } }, "isError": { - "description": "Whether tool execution failed", "type": [ "boolean", "null" ] }, "structuredContent": { - "description": "Optional structured result", "type": [ "object", "null" @@ -3506,12 +3454,12 @@ "additionalProperties": true }, "toolUseId": { - "description": "ID of the corresponding tool use", "type": "string" } }, "required": [ - "toolUseId" + "toolUseId", + "content" ] }, "ToolUseContent": { @@ -3519,7 +3467,6 @@ "type": "object", "properties": { "_meta": { - "description": "Optional metadata (preserved for caching)", "type": [ "object", "null" @@ -3527,16 +3474,13 @@ "additionalProperties": true }, "id": { - "description": "Unique identifier for this tool call", "type": "string" }, "input": { - "description": "Input arguments for the tool", "type": "object", "additionalProperties": true }, "name": { - "description": "Name of the tool to call", "type": "string" } }, diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index 662c4bd58..db4cf33f8 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -38,7 +38,7 @@ impl ServerHandler for Server { let _enter = span.enter(); if let Err(e) = peer - .notify_resource_updated(ResourceUpdatedNotificationParam { uri: uri.clone() }) + .notify_resource_updated(ResourceUpdatedNotificationParam::new(uri.clone())) .await { panic!("Failed to send notification: {}", e); diff --git a/crates/rmcp/tests/test_progress_subscriber.rs b/crates/rmcp/tests/test_progress_subscriber.rs index 7bed457f4..5df8a7b71 100644 --- a/crates/rmcp/tests/test_progress_subscriber.rs +++ b/crates/rmcp/tests/test_progress_subscriber.rs @@ -71,12 +71,11 @@ impl MyServer { ))?; for step in 0..10 { let _ = client - .notify_progress(ProgressNotificationParam { - progress_token: progress_token.clone(), - progress: (step as f64), - total: Some(10.0), - message: Some("Some message".into()), - }) + .notify_progress( + ProgressNotificationParam::new(progress_token.clone(), step as f64) + .with_total(10.0) + .with_message("Some message"), + ) .await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } diff --git a/crates/rmcp/tests/test_prompt_macro_annotations.rs b/crates/rmcp/tests/test_prompt_macro_annotations.rs index caa017936..b5b6ef6af 100644 --- a/crates/rmcp/tests/test_prompt_macro_annotations.rs +++ b/crates/rmcp/tests/test_prompt_macro_annotations.rs @@ -4,7 +4,7 @@ use rmcp::{ ServerHandler, handler::server::wrapper::Parameters, - model::{GetPromptResult, Prompt, PromptMessage, PromptMessageRole}, + model::{GetPromptResult, Prompt, PromptMessage, Role}, prompt, }; use schemars::JsonSchema; @@ -40,26 +40,20 @@ struct ComplexArgs { // Test basic prompt attribute generation #[prompt] async fn basic_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - "Basic response", - )] + vec![PromptMessage::new_text(Role::Assistant, "Basic response")] } // Test prompt with custom name #[prompt(name = "custom_name")] async fn named_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - "Named response", - )] + vec![PromptMessage::new_text(Role::Assistant, "Named response")] } // Test prompt with custom description #[prompt(description = "This is a custom description")] async fn described_prompt(_server: &TestServer) -> Vec { vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Described response", )] } @@ -68,7 +62,7 @@ async fn described_prompt(_server: &TestServer) -> Vec { #[prompt(name = "full_custom", description = "Fully customized prompt")] async fn fully_custom_prompt(_server: &TestServer) -> Vec { vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Fully custom response", )] } @@ -79,7 +73,7 @@ async fn fully_custom_prompt(_server: &TestServer) -> Vec { #[prompt] async fn doc_comment_prompt(_server: &TestServer) -> Vec { vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Doc comment response", )] } @@ -89,7 +83,7 @@ async fn doc_comment_prompt(_server: &TestServer) -> Vec { #[prompt(description = "This overrides the doc comment")] async fn override_doc_prompt(_server: &TestServer) -> Vec { vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Override response", )] } @@ -97,10 +91,7 @@ async fn override_doc_prompt(_server: &TestServer) -> Vec { // Test prompt with arguments #[prompt] async fn args_prompt(_server: &TestServer, _args: Parameters) -> Vec { - vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - "Args response", - )] + vec![PromptMessage::new_text(Role::Assistant, "Args response")] } // Test prompt with complex arguments @@ -110,7 +101,7 @@ async fn complex_args_prompt( _args: Parameters, ) -> GetPromptResult { GetPromptResult::new(vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Complex response", )]) .with_description("Complex args result") @@ -119,10 +110,7 @@ async fn complex_args_prompt( // Test sync prompt #[prompt] fn sync_prompt(_server: &TestServer) -> Vec { - vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - "Sync response", - )] + vec![PromptMessage::new_text(Role::Assistant, "Sync response")] } #[test] @@ -275,10 +263,7 @@ impl ServerHandler for GenericServer {} async fn generic_prompt( _server: &GenericServer, ) -> Vec { - vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - "Generic response", - )] + vec![PromptMessage::new_text(Role::Assistant, "Generic response")] } #[test] diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs index b7c0c442f..e8d9d7dc0 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -7,8 +7,8 @@ use rmcp::{ ClientHandler, RoleServer, ServerHandler, ServiceExt, handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, model::{ - ClientInfo, GetPromptRequestParams, GetPromptResult, ListPromptsResult, - PaginatedRequestParams, PromptMessage, PromptMessageRole, + ClientInfo, ContentBlock, GetPromptRequestParams, GetPromptResult, ListPromptsResult, + PaginatedRequestParams, PromptMessage, Role, }, prompt, prompt_handler, prompt_router, service::RequestContext, @@ -53,14 +53,14 @@ impl Server { pub async fn code_review(&self, params: Parameters) -> Vec { vec![ PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!( "Please review the {} code in: {}", params.0.language, params.0.file_path ), ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "I'll review this code for best practices and potential issues.".to_string(), ), ] @@ -69,7 +69,7 @@ impl Server { #[prompt] async fn empty_param(&self) -> Vec { vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "This is a prompt with no parameters.".to_string(), )] } @@ -110,11 +110,11 @@ impl GenericServer { let context = self.data_service.get_context(); GetPromptResult::new(vec![ PromptMessage::new_text( - PromptMessageRole::User, + Role::User, "I need help with the current context.".to_string(), ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "Based on the context '{}', here's how I can help...", context @@ -141,8 +141,8 @@ async fn test_prompt_macros() { })) .await; assert_eq!(result.len(), 2); - assert_eq!(result[0].role, PromptMessageRole::User); - assert_eq!(result[1].role, PromptMessageRole::Assistant); + assert_eq!(result[0].role, Role::User); + assert_eq!(result[1].role, Role::Assistant); } #[tokio::test] @@ -166,8 +166,8 @@ async fn test_prompt_macros_with_generics() { assert!(result.description.is_some()); assert_eq!(result.messages.len(), 2); match &result.messages[1].content { - rmcp::model::PromptMessageContent::Text { text } => { - assert!(text.contains("mock context data")); + ContentBlock::Text(text_content) => { + assert!(text_content.text.contains("mock context data")); } _ => panic!("Expected text content"), } @@ -233,7 +233,7 @@ impl OptionalSchemaTester { #[prompt(description = "A prompt to test optional schema generation")] async fn test_optional(&self, _req: Parameters) -> Vec { vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Testing optional fields".to_string(), )] } @@ -249,11 +249,8 @@ impl OptionalSchemaTester { None => "Received null count".to_string(), }; - GetPromptResult::new(vec![PromptMessage::new_text( - PromptMessageRole::Assistant, - message, - )]) - .with_description("Test result for optional i64") + GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, message)]) + .with_description("Test result for optional i64") } } @@ -338,7 +335,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { .await?; let result_text = match &result.messages.first().unwrap().content { - rmcp::model::PromptMessageContent::Text { text } => text.as_str(), + ContentBlock::Text(text_content) => text_content.text.as_str(), _ => panic!("Expected text content"), }; @@ -363,7 +360,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { .await?; let some_result_text = match &some_result.messages.first().unwrap().content { - rmcp::model::PromptMessageContent::Text { text } => text.as_str(), + ContentBlock::Text(text_content) => text_content.text.as_str(), _ => panic!("Expected text content"), }; diff --git a/crates/rmcp/tests/test_prompt_routers.rs b/crates/rmcp/tests/test_prompt_routers.rs index 23674bd96..68b265db2 100644 --- a/crates/rmcp/tests/test_prompt_routers.rs +++ b/crates/rmcp/tests/test_prompt_routers.rs @@ -5,7 +5,7 @@ use futures::future::BoxFuture; use rmcp::{ ServerHandler, handler::server::wrapper::Parameters, - model::{GetPromptResult, PromptMessage, PromptMessageRole}, + model::{GetPromptResult, PromptMessage, Role}, }; #[derive(Debug, Default)] @@ -35,7 +35,7 @@ impl TestHandler { ) -> Vec { drop(fields); vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Async method response", )] } @@ -47,7 +47,7 @@ impl TestHandler { ) -> Vec { drop(fields); vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Sync method response", )] } @@ -57,7 +57,7 @@ impl TestHandler { async fn async_function(Parameters(Request { fields }): Parameters) -> Vec { drop(fields); vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Async function response", )] } @@ -66,7 +66,7 @@ async fn async_function(Parameters(Request { fields }): Parameters) -> fn async_function2(_callee: &TestHandler) -> BoxFuture<'_, GetPromptResult> { Box::pin(async move { GetPromptResult::new(vec![PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Async function 2 response", )]) .with_description("Async function 2") diff --git a/crates/rmcp/tests/test_request_timeout_progress.rs b/crates/rmcp/tests/test_request_timeout_progress.rs index af62a466b..6eeac42e9 100644 --- a/crates/rmcp/tests/test_request_timeout_progress.rs +++ b/crates/rmcp/tests/test_request_timeout_progress.rs @@ -59,12 +59,11 @@ impl ProgressTimeoutServer { for step in 0..4 { tokio::time::sleep(Duration::from_millis(50)).await; let _ = client - .notify_progress(ProgressNotificationParam { - progress_token: progress_token.clone(), - progress: step as f64, - total: Some(4.0), - message: Some("working".into()), - }) + .notify_progress( + ProgressNotificationParam::new(progress_token.clone(), step as f64) + .with_total(4.0) + .with_message("working"), + ) .await; } @@ -79,12 +78,14 @@ impl ProgressTimeoutServer { for step in 0..4 { tokio::time::sleep(Duration::from_millis(50)).await; let _ = client - .notify_progress(ProgressNotificationParam { - progress_token: ProgressToken(NumberOrString::Number(999_999)), - progress: step as f64, - total: Some(4.0), - message: Some("unrelated".into()), - }) + .notify_progress( + ProgressNotificationParam::new( + ProgressToken(NumberOrString::Number(999_999)), + step as f64, + ) + .with_total(4.0) + .with_message("unrelated"), + ) .await; } diff --git a/crates/rmcp/tests/test_resource_link.rs b/crates/rmcp/tests/test_resource_link.rs index 685a645c4..7db5b06b7 100644 --- a/crates/rmcp/tests/test_resource_link.rs +++ b/crates/rmcp/tests/test_resource_link.rs @@ -1,14 +1,14 @@ -use rmcp::model::{CallToolResult, Content, RawResource}; +use rmcp::model::{CallToolResult, ContentBlock, Resource}; #[test] fn test_resource_link_in_tool_result() { // Test creating a tool result with resource links - let resource = RawResource::new("file:///test/file.txt", "test.txt"); + let resource = Resource::new("file:///test/file.txt", "test.txt"); // Create a tool result with a resource link let result = CallToolResult::success(vec![ - Content::text("Found a file"), - Content::resource_link(resource), + ContentBlock::text("Found a file"), + ContentBlock::resource_link(resource), ]); // Serialize to JSON to verify format @@ -42,12 +42,12 @@ fn test_resource_link_in_tool_result() { #[test] fn test_resource_link_with_full_metadata() { - let mut resource = RawResource::new("https://example.com/data.json", "API Data"); - resource.description = Some("JSON data from external API".to_string()); - resource.mime_type = Some("application/json".to_string()); - resource.size = Some(1024); + let resource = Resource::new("https://example.com/data.json", "API Data") + .with_description("JSON data from external API") + .with_mime_type("application/json") + .with_size(1024); - let result = CallToolResult::success(vec![Content::resource_link(resource)]); + let result = CallToolResult::success(vec![ContentBlock::resource_link(resource)]); let json = serde_json::to_string(&result).unwrap(); let deserialized: CallToolResult = serde_json::from_str(&json).unwrap(); @@ -71,12 +71,12 @@ fn test_resource_link_with_full_metadata() { #[test] fn test_mixed_content_types() { // Test that resource links can be mixed with other content types - let resource = RawResource::new("file:///doc.pdf", "Document"); + let resource = Resource::new("file:///doc.pdf", "Document"); let result = CallToolResult::success(vec![ - Content::text("Processing complete"), - Content::resource_link(resource), - Content::embedded_text("memo://result", "Analysis results here"), + ContentBlock::text("Processing complete"), + ContentBlock::resource_link(resource), + ContentBlock::embedded_text("memo://result", "Analysis results here"), ]); assert_eq!(result.content.len(), 3); diff --git a/crates/rmcp/tests/test_resource_link_integration.rs b/crates/rmcp/tests/test_resource_link_integration.rs index 7507d71e0..2cc687285 100644 --- a/crates/rmcp/tests/test_resource_link_integration.rs +++ b/crates/rmcp/tests/test_resource_link_integration.rs @@ -1,27 +1,21 @@ /// Integration tests for resource_link support in both tools and prompts -use rmcp::model::{ - AnnotateAble, CallToolResult, Content, PromptMessage, PromptMessageContent, PromptMessageRole, - RawResource, Resource, -}; +use rmcp::model::{CallToolResult, ContentBlock, PromptMessage, Resource, Role}; #[test] fn test_tool_and_prompt_resource_link_compatibility() { - // Create a resource that can be used in both tools and prompts - let resource = RawResource::new("file:///shared/data.json", "Shared Data"); - let resource_annotated: Resource = resource.clone().no_annotation(); + let resource = Resource::new("file:///shared/data.json", "Shared Data"); // Test 1: Tool returning a resource link let tool_result = CallToolResult::success(vec![ - Content::text("Found shared data"), - Content::resource_link(resource.clone()), + ContentBlock::text("Found shared data"), + ContentBlock::resource_link(resource.clone()), ]); let tool_json = serde_json::to_string(&tool_result).unwrap(); assert!(tool_json.contains("\"type\":\"resource_link\"")); // Test 2: Prompt returning a resource link - let prompt_message = - PromptMessage::new_resource_link(PromptMessageRole::Assistant, resource_annotated.clone()); + let prompt_message = PromptMessage::new_resource_link(Role::Assistant, resource.clone()); let prompt_json = serde_json::to_string(&prompt_message).unwrap(); assert!(prompt_json.contains("\"type\":\"resource_link\"")); @@ -30,11 +24,9 @@ fn test_tool_and_prompt_resource_link_compatibility() { let tool_content = &tool_result.content[1]; let prompt_content = &prompt_message.content; - // Extract just the resource link parts let tool_resource_json = serde_json::to_value(tool_content).unwrap(); let prompt_resource_json = serde_json::to_value(prompt_content).unwrap(); - // Both should have the same structure assert_eq!( tool_resource_json.get("type").unwrap(), prompt_resource_json.get("type").unwrap() @@ -51,16 +43,13 @@ fn test_tool_and_prompt_resource_link_compatibility() { #[test] fn test_resource_link_roundtrip() { - // Test that resource links can be serialized and deserialized correctly - // in both tool results and prompt messages - - let mut resource = RawResource::new("https://api.example.com/resource", "API Resource"); - resource.description = Some("External API resource".to_string()); - resource.mime_type = Some("application/json".to_string()); - resource.size = Some(2048); + let resource = Resource::new("https://api.example.com/resource", "API Resource") + .with_description("External API resource") + .with_mime_type("application/json") + .with_size(2048); // Test with tool result - let tool_result = CallToolResult::success(vec![Content::resource_link(resource.clone())]); + let tool_result = CallToolResult::success(vec![ContentBlock::resource_link(resource.clone())]); let tool_json = serde_json::to_string(&tool_result).unwrap(); let tool_deserialized: CallToolResult = serde_json::from_str(&tool_json).unwrap(); @@ -82,15 +71,12 @@ fn test_resource_link_roundtrip() { } // Test with prompt message - let prompt_message = PromptMessage::new( - PromptMessageRole::User, - PromptMessageContent::resource_link(resource.no_annotation()), - ); + let prompt_message = PromptMessage::new(Role::User, ContentBlock::resource_link(resource)); let prompt_json = serde_json::to_string(&prompt_message).unwrap(); let prompt_deserialized: PromptMessage = serde_json::from_str(&prompt_json).unwrap(); - if let PromptMessageContent::ResourceLink { link } = prompt_deserialized.content { + if let ContentBlock::ResourceLink(link) = &prompt_deserialized.content { assert_eq!(link.uri, "https://api.example.com/resource"); assert_eq!(link.name, "API Resource"); assert_eq!(link.description, Some("External API resource".to_string())); @@ -103,18 +89,15 @@ fn test_resource_link_roundtrip() { #[test] fn test_mixed_content_in_prompts_and_tools() { - // Test that resource links can be mixed with other content types - // in both prompts and tools - - let resource1 = RawResource::new("file:///doc1.md", "Document 1"); - let resource2 = RawResource::new("file:///doc2.md", "Document 2"); + let resource1 = Resource::new("file:///doc1.md", "Document 1"); + let resource2 = Resource::new("file:///doc2.md", "Document 2"); // Tool with mixed content let tool_result = CallToolResult::success(vec![ - Content::text("Processing complete. Found documents:"), - Content::resource_link(resource1.clone()), - Content::resource_link(resource2.clone()), - Content::embedded_text("summary://result", "Both documents processed successfully"), + ContentBlock::text("Processing complete. Found documents:"), + ContentBlock::resource_link(resource1), + ContentBlock::resource_link(resource2), + ContentBlock::embedded_text("summary://result", "Both documents processed successfully"), ]); assert_eq!(tool_result.content.len(), 4); diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 74e904ff5..83d3f6fb1 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -345,7 +345,7 @@ async fn test_tool_use_content_serialization() -> Result<()> { async fn test_tool_result_content_serialization() -> Result<()> { let tool_result = ToolResultContent::new( "call_123", - vec![Content::text( + vec![ContentBlock::text( "The weather in San Francisco is 72°F and sunny.", )], ); @@ -359,6 +359,17 @@ async fn test_tool_result_content_serialization() -> Result<()> { Ok(()) } +#[test] +fn test_tool_result_content_requires_content() { + let raw = serde_json::json!({ + "toolUseId": "call_123" + }); + + let err = serde_json::from_value::(raw).unwrap_err(); + + assert!(err.to_string().contains("missing field `content`")); +} + #[tokio::test] async fn test_sampling_message_with_tool_use() -> Result<()> { let message = SamplingMessage::assistant_tool_use( @@ -386,7 +397,7 @@ async fn test_sampling_message_with_tool_use() -> Result<()> { #[tokio::test] async fn test_sampling_message_with_tool_result() -> Result<()> { let message = - SamplingMessage::user_tool_result("call_123", vec![Content::text("72°F and sunny")]); + SamplingMessage::user_tool_result("call_123", vec![ContentBlock::text("72°F and sunny")]); let json = serde_json::to_string(&message)?; let deserialized: SamplingMessage = serde_json::from_str(&json)?; @@ -431,10 +442,8 @@ async fn test_create_message_result_tool_use_stop_reason() -> Result<()> { #[tokio::test] async fn test_sampling_capability() -> Result<()> { - let cap = SamplingCapability { - tools: Some(JsonObject::default()), - context: None, - }; + let mut cap = SamplingCapability::default(); + cap.tools = Some(JsonObject::default()); let json = serde_json::to_string(&cap)?; let deserialized: SamplingCapability = serde_json::from_str(&json)?; @@ -507,16 +516,19 @@ async fn test_backward_compat_sampling_capability_empty_object() -> Result<()> { async fn test_content_to_sampling_message_content_conversion() -> Result<()> { use std::convert::TryInto; - let content = Content::text("Hello"); - let sampling_content: SamplingMessageContent = + let content = ContentBlock::text("Hello"); + let sampling_content: SamplingMessageContentBlock = content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; assert!(sampling_content.as_text().is_some()); assert_eq!(sampling_content.as_text().unwrap().text, "Hello"); - let content = Content::image("base64data", "image/png"); - let sampling_content: SamplingMessageContent = + let content = ContentBlock::image("base64data", "image/png"); + let sampling_content: SamplingMessageContentBlock = content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; - assert!(matches!(sampling_content, SamplingMessageContent::Image(_))); + assert!(matches!( + sampling_content, + SamplingMessageContentBlock::Image(_) + )); Ok(()) } @@ -525,8 +537,8 @@ async fn test_content_to_sampling_message_content_conversion() -> Result<()> { async fn test_content_to_sampling_content_conversion() -> Result<()> { use std::convert::TryInto; - let content = Content::text("Hello"); - let sampling_content: SamplingContent = + let content = ContentBlock::text("Hello"); + let sampling_content: SamplingContent = content.try_into().map_err(|e: &str| anyhow::anyhow!(e))?; assert_eq!(sampling_content.len(), 1); assert!(sampling_content.first().unwrap().as_text().is_some()); @@ -540,14 +552,14 @@ async fn test_content_conversion_unsupported_variants() { use rmcp::model::ResourceContents; - let resource_content = Content::resource(ResourceContents::TextResourceContents { + let resource_content = ContentBlock::resource(ResourceContents::TextResourceContents { uri: "file:///test.txt".to_string(), mime_type: Some("text/plain".to_string()), text: "test".to_string(), meta: None, }); - let result: Result = resource_content.try_into(); + let result: Result = resource_content.try_into(); assert!(result.is_err()); assert_eq!( result.unwrap_err(), @@ -560,7 +572,7 @@ async fn test_validate_rejects_tool_use_in_user_message() { let params = CreateMessageRequestParams::new( vec![SamplingMessage::new( Role::User, - SamplingMessageContent::tool_use("call_1", "some_tool", Default::default()), + SamplingMessageContentBlock::tool_use("call_1", "some_tool", Default::default()), )], 100, ); @@ -577,7 +589,7 @@ async fn test_validate_rejects_tool_result_in_assistant_message() { let params = CreateMessageRequestParams::new( vec![SamplingMessage::new( Role::Assistant, - SamplingMessageContent::tool_result("call_1", vec![Content::text("result")]), + SamplingMessageContentBlock::tool_result("call_1", vec![ContentBlock::text("result")]), )], 100, ); @@ -595,8 +607,11 @@ async fn test_validate_rejects_mixed_content_with_tool_result() { vec![SamplingMessage::new_multiple( Role::User, vec![ - SamplingMessageContent::tool_result("call_1", vec![Content::text("result")]), - SamplingMessageContent::text("some extra text"), + SamplingMessageContentBlock::tool_result( + "call_1", + vec![ContentBlock::text("result")], + ), + SamplingMessageContentBlock::text("some extra text"), ], )], 100, @@ -631,7 +646,10 @@ async fn test_validate_rejects_tool_result_without_matching_use() { let params = CreateMessageRequestParams::new( vec![ SamplingMessage::user_text("Hello"), - SamplingMessage::user_tool_result("nonexistent_call", vec![Content::text("result")]), + SamplingMessage::user_tool_result( + "nonexistent_call", + vec![ContentBlock::text("result")], + ), ], 100, ); @@ -656,7 +674,7 @@ async fn test_validate_accepts_valid_tool_conversation() { .unwrap() .clone(), ), - SamplingMessage::user_tool_result("call_1", vec![Content::text("72°F and sunny")]), + SamplingMessage::user_tool_result("call_1", vec![ContentBlock::text("72°F and sunny")]), SamplingMessage::assistant_text("It's 72°F and sunny in SF."), ], 100, diff --git a/crates/rmcp/tests/test_sse_concurrent_streams.rs b/crates/rmcp/tests/test_sse_concurrent_streams.rs index 37bbfd721..e1e885282 100644 --- a/crates/rmcp/tests/test_sse_concurrent_streams.rs +++ b/crates/rmcp/tests/test_sse_concurrent_streams.rs @@ -48,8 +48,10 @@ impl ServerHandler for TestServer { fn get_info(&self) -> ServerInfo { ServerInfo::new( ServerCapabilities::builder() - .enable_tools_with(ToolsCapability { - list_changed: Some(true), + .enable_tools_with({ + let mut tools = ToolsCapability::default(); + tools.list_changed = Some(true); + tools }) .build(), ) diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index adbdfec5e..bb0d5e029 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -3,7 +3,7 @@ use rmcp::{ Json, ServerHandler, handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters}, - model::{CallToolResult, Content, ServerResult, Tool}, + model::{CallToolResult, ContentBlock, ServerResult, Tool}, tool, tool_handler, tool_router, }; use schemars::JsonSchema; @@ -192,7 +192,8 @@ async fn test_mutual_exclusivity_validation() { message: "Hello".into(), }; // Test that content and structured_content can both be passed separately - let content_result = CallToolResult::success(vec![Content::json(response.clone()).unwrap()]); + let content_result = + CallToolResult::success(vec![ContentBlock::json(response.clone()).unwrap()]); let structured_result = CallToolResult::structured(json!({"message": "Hello"})); // Verify the validation diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 9ad0b2006..ca0f4af50 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -1,8 +1,12 @@ use std::{any::Any, time::Duration}; -use rmcp::task_manager::{ - OperationDescriptor, OperationMessage, OperationProcessor, OperationResultTransport, +use rmcp::{ + model::TaskStatusNotificationParam, + task_manager::{ + OperationDescriptor, OperationMessage, OperationProcessor, OperationResultTransport, + }, }; +use serde_json::json; struct DummyTransport { id: String, @@ -75,3 +79,28 @@ async fn rejects_duplicate_operation_ids() { .expect_err("duplicate should fail"); assert!(format!("{err}").contains("already running")); } + +#[test] +fn task_status_notification_param_preserves_meta() { + let raw = json!({ + "_meta": { + "traceId": "trace-1" + }, + "taskId": "task-1", + "status": "working", + "createdAt": "2026-06-24T00:00:00Z", + "lastUpdatedAt": "2026-06-24T00:00:01Z", + "ttl": null + }); + + let params: TaskStatusNotificationParam = serde_json::from_value(raw).unwrap(); + + assert_eq!(params.task.task_id, "task-1"); + assert_eq!(params.task_id, "task-1"); + assert_eq!(params.meta.as_ref().unwrap().0["traceId"], json!("trace-1")); + + let serialized = serde_json::to_value(¶ms).unwrap(); + + assert_eq!(serialized["_meta"]["traceId"], json!("trace-1")); + assert_eq!(serialized["taskId"], json!("task-1")); +} diff --git a/crates/rmcp/tests/test_task_support_validation.rs b/crates/rmcp/tests/test_task_support_validation.rs index c0a65a9e0..773f759f9 100644 --- a/crates/rmcp/tests/test_task_support_validation.rs +++ b/crates/rmcp/tests/test_task_support_validation.rs @@ -11,7 +11,7 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceError, ServiceExt, handler::server::router::tool::ToolRouter, - model::{CallToolRequestParams, ClientInfo, ErrorCode, JsonObject}, + model::{CallToolRequestParams, ClientInfo, ErrorCode, TaskMetadata}, tool, tool_handler, tool_router, }; @@ -75,8 +75,8 @@ impl ClientHandler for DummyClientHandler { } /// Helper to create a task object for tool calls -fn make_task() -> JsonObject { - serde_json::Map::new() +fn make_task() -> TaskMetadata { + TaskMetadata::new() } #[tokio::test] @@ -203,7 +203,7 @@ async fn test_forbidden_task_tool_without_task_succeeds() -> anyhow::Result<()> let text = result .content .first() - .and_then(|c| c.raw.as_text()) + .and_then(|c| c.as_text()) .map(|t| t.text.as_str()) .unwrap_or(""); assert_eq!(text, "forbidden task executed"); @@ -239,7 +239,7 @@ async fn test_optional_task_tool_without_task_succeeds() -> anyhow::Result<()> { let text = result .content .first() - .and_then(|c| c.raw.as_text()) + .and_then(|c| c.as_text()) .map(|t| t.text.as_str()) .unwrap_or(""); assert_eq!(text, "optional task executed"); diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index 902b314f1..ed2e697a2 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -325,7 +325,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { let result_text = result .content .first() - .and_then(|content| content.raw.as_text()) + .and_then(|content| content.as_text()) .map(|text| text.text.as_str()) .expect("Expected text content"); @@ -352,7 +352,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { let some_result_text = some_result .content .first() - .and_then(|content| content.raw.as_text()) + .and_then(|content| content.as_text()) .map(|text| text.text.as_str()) .expect("Expected text content"); @@ -438,7 +438,7 @@ async fn test_minimal_server_tool_call() -> anyhow::Result<()> { let text = result .content .first() - .and_then(|c| c.raw.as_text()) + .and_then(|c| c.as_text()) .map(|t| t.text.as_str()) .expect("Expected text content"); @@ -496,7 +496,7 @@ async fn test_tool_router_server_handler_flag_end_to_end_tool_call() -> anyhow:: let text = result .content .first() - .and_then(|c| c.raw.as_text()) + .and_then(|c| c.as_text()) .map(|t| t.text.as_str()) .expect("Expected text content"); diff --git a/crates/rmcp/tests/test_tool_result_meta.rs b/crates/rmcp/tests/test_tool_result_meta.rs index f64d8e3f1..a1d3d3af3 100644 --- a/crates/rmcp/tests/test_tool_result_meta.rs +++ b/crates/rmcp/tests/test_tool_result_meta.rs @@ -1,9 +1,9 @@ -use rmcp::model::{CallToolResult, Content, Meta}; +use rmcp::model::{CallToolResult, ContentBlock, Meta}; use serde_json::{Value, json}; #[test] fn serialize_tool_result_with_meta() { - let content = vec![Content::text("ok")]; + let content = vec![ContentBlock::text("ok")]; let mut meta = Meta::new(); meta.insert("foo".to_string(), json!("bar")); let result = CallToolResult::success(content).with_meta(Some(meta)); @@ -33,7 +33,7 @@ fn deserialize_tool_result_with_meta() { #[test] fn serialize_tool_result_without_meta_omits_field() { - let result = CallToolResult::success(vec![Content::text("no meta")]); + let result = CallToolResult::success(vec![ContentBlock::text("no meta")]); let v = serde_json::to_value(&result).unwrap(); // Ensure _meta is omitted assert!(v.get("_meta").is_none()); diff --git a/examples/clients/src/task_stdio.rs b/examples/clients/src/task_stdio.rs index 472a9c370..ffcc0dc22 100644 --- a/examples/clients/src/task_stdio.rs +++ b/examples/clients/src/task_stdio.rs @@ -11,8 +11,8 @@ use anyhow::{Result, anyhow}; use rmcp::{ ServiceExt, model::{ - CallToolRequestParams, CallToolResult, ClientRequest, GetTaskInfoParams, - GetTaskResultParams, JsonObject, Request, ServerResult, TaskStatus, + CallToolRequestParams, CallToolResult, ClientRequest, GetTaskParams, GetTaskPayloadParams, + Request, ServerResult, TaskMetadata, TaskStatus, }, object, transport::{ConfigureCommandExt, TokioChildProcess}, @@ -53,14 +53,14 @@ async fn main() -> Result<()> { .await?; tracing::info!("quick_echo -> {echo:#?}"); - // 2) Task call. `slow_sum` is task_support = required, so we MUST attach a - // `task` object. An empty object is fine — clients can stash arbitrary - // metadata here that the server-side `OperationDescriptor` will keep. + // 2) Task call. `slow_sum` is task_support = required, so we MUST attach + // `task` metadata. An empty `TaskMetadata` is fine; use `.with_ttl(...)` + // to set a retention window. let create = client .send_request(ClientRequest::CallToolRequest(Request::new( CallToolRequestParams::new("slow_sum") .with_arguments(object!({ "a": 40, "b": 2 })) - .with_task(JsonObject::new()), + .with_task(TaskMetadata::new()), ))) .await?; let ServerResult::CreateTaskResult(create) = create else { @@ -77,11 +77,8 @@ async fn main() -> Result<()> { tokio::time::sleep(std::time::Duration::from_millis(250)).await; let info = client - .send_request(ClientRequest::GetTaskInfoRequest(Request::new( - GetTaskInfoParams { - meta: None, - task_id: task_id.clone(), - }, + .send_request(ClientRequest::GetTaskRequest(Request::new( + GetTaskParams::new(task_id.clone()), ))) .await?; let ServerResult::GetTaskResult(info) = info else { @@ -108,11 +105,8 @@ async fn main() -> Result<()> { // here. (For a non-tool task the same value would surface as // `ServerResult::CustomResult` and need manual `serde_json::from_value`.) let payload = client - .send_request(ClientRequest::GetTaskResultRequest(Request::new( - GetTaskResultParams { - meta: None, - task_id: task_id.clone(), - }, + .send_request(ClientRequest::GetTaskPayloadRequest(Request::new( + GetTaskPayloadParams::new(task_id.clone()), ))) .await?; let call_result: CallToolResult = match payload { diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index d78acd59f..f618fee0d 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -94,14 +94,14 @@ impl Counter { } fn _create_resource_text(&self, uri: &str, name: &str) -> Resource { - RawResource::new(uri, name.to_string()).no_annotation() + Resource::new(uri, name.to_string()) } #[tool(description = "Increment the counter by 1")] async fn increment(&self) -> Result { let mut counter = self.counter.lock().await; *counter += 1; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( counter.to_string(), )])) } @@ -110,7 +110,7 @@ impl Counter { async fn decrement(&self) -> Result { let mut counter = self.counter.lock().await; *counter -= 1; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( counter.to_string(), )])) } @@ -118,7 +118,7 @@ impl Counter { #[tool(description = "Get the current counter value")] async fn get_value(&self) -> Result { let counter = self.counter.lock().await; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( counter.to_string(), )])) } @@ -129,19 +129,19 @@ impl Counter { )] async fn long_task(&self) -> Result { tokio::time::sleep(std::time::Duration::from_secs(10)).await; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( "Long task completed", )])) } #[tool(description = "Say hello to the client")] fn say_hello(&self) -> Result { - Ok(CallToolResult::success(vec![Content::text("hello")])) + Ok(CallToolResult::success(vec![ContentBlock::text("hello")])) } #[tool(description = "Repeat what you say")] fn echo(&self, Parameters(object): Parameters) -> Result { - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( serde_json::Value::Object(object).to_string(), )])) } @@ -151,7 +151,7 @@ impl Counter { &self, Parameters(StructRequest { a, b }): Parameters, ) -> Result { - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( (a + b).to_string(), )])) } @@ -166,8 +166,8 @@ impl Counter { .map(|v| v.to_str().unwrap_or("(non-ascii)").to_owned()); match session_id { - Some(id) => Ok(CallToolResult::success(vec![Content::text(id)])), - None => Ok(CallToolResult::success(vec![Content::text( + Some(id) => Ok(CallToolResult::success(vec![ContentBlock::text(id)])), + None => Ok(CallToolResult::success(vec![ContentBlock::text( "no session (not running over streamable HTTP?)", )])), } @@ -190,10 +190,7 @@ impl Counter { "This is an example prompt with your message here: '{}'", args.message ); - Ok(vec![PromptMessage::new_text( - PromptMessageRole::User, - prompt, - )]) + Ok(vec![PromptMessage::new_text(Role::User, prompt)]) } /// Analyze the current counter value and suggest next steps @@ -209,11 +206,11 @@ impl Counter { let messages = vec![ PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "I'll analyze the counter situation and suggest the best approach.", ), PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!( "Current counter value: {}\nGoal value: {}\nDifference: {}\nStrategy preference: {}\n\nPlease analyze the situation and suggest the best approach to reach the goal.", current_value, args.goal, difference, strategy @@ -406,12 +403,7 @@ mod tests { }); let client_service = client.serve(client_transport).await?; - let mut task_meta = serde_json::Map::new(); - task_meta.insert( - "source".into(), - serde_json::Value::String("integration-test".into()), - ); - let params = CallToolRequestParams::new("long_task").with_task(task_meta); + let params = CallToolRequestParams::new("long_task").with_task(TaskMetadata::new()); let response = client_service .send_request(ClientRequest::CallToolRequest(Request::new(params.clone()))) .await?; diff --git a/examples/servers/src/common/progress_demo.rs b/examples/servers/src/common/progress_demo.rs index ba6fa7d13..253dbc28e 100644 --- a/examples/servers/src/common/progress_demo.rs +++ b/examples/servers/src/common/progress_demo.rs @@ -97,12 +97,12 @@ impl ProgressDemo { let chunk_str = String::from_utf8_lossy(&chunk); counter += 1; // create progress notification param - let progress_param = ProgressNotificationParam { - progress_token: ProgressToken(progress_token.clone()), - progress: counter as f64, - total: Some(5.0), - message: Some(chunk_str.to_string()), - }; + let progress_param = ProgressNotificationParam::new( + ProgressToken(progress_token.clone()), + counter as f64, + ) + .with_total(5.0) + .with_message(chunk_str.to_string()); match ctx.peer.notify_progress(progress_param).await { Ok(_) => { @@ -122,7 +122,7 @@ impl ProgressDemo { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } - Ok(CallToolResult::success(vec![Content::text(format!( + Ok(CallToolResult::success(vec![ContentBlock::text(format!( "Processed {} records successfully", counter ))])) diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs index 27bff2195..275047a55 100644 --- a/examples/servers/src/common/task_demo.rs +++ b/examples/servers/src/common/task_demo.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use rmcp::{ ErrorData as McpError, ServerHandler, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolResult, Content}, + model::{CallToolResult, ContentBlock}, schemars, task_handler, task_manager::OperationProcessor, tool, tool_handler, tool_router, @@ -70,7 +70,7 @@ impl TaskDemo { Parameters(SumArgs { a, b }): Parameters, ) -> Result { tokio::time::sleep(std::time::Duration::from_secs(2)).await; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( (a + b).to_string(), )])) } @@ -81,7 +81,7 @@ impl TaskDemo { &self, Parameters(EchoArgs { message }): Parameters, ) -> Result { - Ok(CallToolResult::success(vec![Content::text(message)])) + Ok(CallToolResult::success(vec![ContentBlock::text(message)])) } } diff --git a/examples/servers/src/completion_stdio.rs b/examples/servers/src/completion_stdio.rs index 7caa8e8c3..812ed31a8 100644 --- a/examples/servers/src/completion_stdio.rs +++ b/examples/servers/src/completion_stdio.rs @@ -203,11 +203,11 @@ impl SqlQueryServer { let messages = if args.operation.is_empty() { vec![ PromptMessage::new_text( - PromptMessageRole::User, + Role::User, "I need help building a SQL query. Where should I start?", ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "I'll help you build a SQL query step by step. First, what type of operation do you want to perform? \ Choose from: SELECT (to read data), INSERT (to add data), UPDATE (to modify data), or DELETE (to remove data).", ), @@ -215,11 +215,11 @@ impl SqlQueryServer { } else if args.table.is_empty() { vec![ PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!("I want to {} data. What's next?", args.operation), ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "Great! For a {} operation, I need to know which table you want to work with. \ What's the name of your database table?", @@ -277,11 +277,11 @@ impl SqlQueryServer { vec![ PromptMessage::new_text( - PromptMessageRole::User, + Role::User, "Generate the SQL query based on my parameters and explain what it does.", ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "Here's your SQL query:\n\n```sql\n{}\n```\n\nThis query will {} the {} table.", query, @@ -405,11 +405,8 @@ impl ServerHandler for SqlQueryServer { let suggestions = self.fuzzy_match(&request.argument.value, &candidates); - let completion = CompletionInfo { - values: suggestions, - total: None, - has_more: Some(false), - }; + let completion = CompletionInfo::with_pagination(suggestions, None, false) + .map_err(|e| McpError::internal_error(e, None))?; Ok(CompleteResult::new(completion)) } diff --git a/examples/servers/src/elicitation_enum_inference.rs b/examples/servers/src/elicitation_enum_inference.rs index 328fb8c88..bed5c1db6 100644 --- a/examples/servers/src/elicitation_enum_inference.rs +++ b/examples/servers/src/elicitation_enum_inference.rs @@ -114,7 +114,7 @@ impl ElicitationEnumFormServer { #[tool(description = "Get current enum selection form")] async fn get_enum_form(&self) -> Result { let guard = self.selection.lock().await; - Ok(CallToolResult::success(vec![Content::text(format!( + Ok(CallToolResult::success(vec![ContentBlock::text(format!( "{}", *guard ))])) @@ -133,13 +133,13 @@ impl ElicitationEnumFormServer { Ok(Some(form)) => { let mut guard = self.selection.lock().await; *guard = form; - Ok(CallToolResult::success(vec![Content::text(format!( + Ok(CallToolResult::success(vec![ContentBlock::text(format!( "Updated Selection:\n{}", *guard ))])) } Ok(None) => { - return Ok(CallToolResult::success(vec![Content::text( + return Ok(CallToolResult::success(vec![ContentBlock::text( "Elicitation cancelled by user.", )])); } diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 3bf38056e..e465e4b4d 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -94,7 +94,7 @@ impl ElicitationServer { } }; - Ok(CallToolResult::success(vec![Content::text(format!( + Ok(CallToolResult::success(vec![ContentBlock::text(format!( "{} {}!", request.greeting, user_name ))])) @@ -103,7 +103,7 @@ impl ElicitationServer { #[tool(description = "Reset stored user name")] async fn reset_name(&self) -> Result { *self.user_name.lock().await = None; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( "User name reset. Next greeting will ask for name again.".to_string(), )])) } @@ -133,20 +133,23 @@ impl ElicitationServer { // Mock notifying completion let _ = context .peer - .notify_url_elicitation_completed(ElicitationResponseNotificationParam { - elicitation_id: "elicit_123".to_string(), - }) + .notify_url_elicitation_completed(ElicitationResponseNotificationParam::new( + "elicit_123", + )) .await; - Ok(CallToolResult::success(vec![Content::text( + Ok(CallToolResult::success(vec![ContentBlock::text( "Elicitation via URL successful".to_string(), )])) } - ElicitationAction::Cancel => Ok(CallToolResult::success(vec![Content::text( + ElicitationAction::Cancel => Ok(CallToolResult::success(vec![ContentBlock::text( "Elicitation via URL cancelled by user".to_string(), )])), - ElicitationAction::Decline => Ok(CallToolResult::error(vec![Content::text( + ElicitationAction::Decline => Ok(CallToolResult::error(vec![ContentBlock::text( "Elicitation via URL declined by user".to_string(), )])), + _ => Ok(CallToolResult::error(vec![ContentBlock::text( + "Unknown elicitation action".to_string(), + )])), } } } diff --git a/examples/servers/src/prompt_stdio.rs b/examples/servers/src/prompt_stdio.rs index 812ce0e1b..7b6e28532 100644 --- a/examples/servers/src/prompt_stdio.rs +++ b/examples/servers/src/prompt_stdio.rs @@ -121,12 +121,9 @@ impl PromptServer { )] async fn greeting(&self) -> Vec { vec![ + PromptMessage::new_text(Role::User, "Hello! I'd like to start our conversation."), PromptMessage::new_text( - PromptMessageRole::User, - "Hello! I'd like to start our conversation.", - ), - PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Hello! I'm here to help. What would you like to discuss today?", ), ] @@ -148,14 +145,14 @@ impl PromptServer { let messages = vec![ PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "You are an expert {} code reviewer. The user's expertise level is {}.", args.language, prefs.expertise_level ), ), PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!( "Please review the {} code at '{}'. Focus on: {}", args.language, @@ -164,7 +161,7 @@ impl PromptServer { ), ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "I'll review your {} code focusing on {}. Let me analyze the code at '{}'...", args.language, @@ -203,14 +200,14 @@ impl PromptServer { Ok(vec![ PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!( "I have {} data that needs {} analysis. Context: {}", args.data_type, args.analysis_type, context ), ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "I'll help you analyze your {} data using {} techniques. Based on your context, \ I'll focus on providing actionable insights.", @@ -233,7 +230,7 @@ impl PromptServer { let mut messages = vec![ PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "You are a writing assistant helping create {} content for {}. \ Use a {} tone.", @@ -241,7 +238,7 @@ impl PromptServer { ), ), PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!( "I need help writing {} for {}. Key points to cover: {}", args.content_type, @@ -250,7 +247,7 @@ impl PromptServer { ), ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "I'll help you create that content. Let me structure it based on your key points.", ), ]; @@ -258,11 +255,11 @@ impl PromptServer { // Add a message for each key point for (i, point) in args.key_points.iter().enumerate() { messages.push(PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!("For point {}: {}, what would you suggest?", i + 1, point), )); messages.push(PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!("For '{}', I recommend...", point), )); } @@ -291,14 +288,14 @@ impl PromptServer { let mut messages = vec![ PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "You are a debugging expert for {}. Help diagnose and fix issues.", args.stack.join(", ") ), ), PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!( "I'm encountering this error: {}\nStack: {}", args.error_message, @@ -311,18 +308,18 @@ impl PromptServer { if let Some(tried) = args.tried_solutions { if !tried.is_empty() { messages.push(PromptMessage::new_text( - PromptMessageRole::User, + Role::User, format!("I've already tried: {}", tried.join(", ")), )); messages.push(PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "I see you've already attempted some solutions. Let me suggest different approaches.", )); } } messages.push(PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, "Let's debug this systematically. First, let me understand the error context better.", )); @@ -343,18 +340,18 @@ impl PromptServer { Ok(vec![ PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "Create a learning path for someone at {} level who prefers {} language explanations.", prefs.expertise_level, prefs.preferred_language ), ), PromptMessage::new_text( - PromptMessageRole::User, + Role::User, "What should I learn next to improve my programming skills?", ), PromptMessage::new_text( - PromptMessageRole::Assistant, + Role::Assistant, format!( "Based on your {} expertise level, I recommend the following learning path...", prefs.expertise_level diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 9c1d21d6d..f75c445f6 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -69,7 +69,7 @@ impl ServerHandler for SamplingDemoServer { ) })?; tracing::debug!("Response: {:?}", response); - Ok(CallToolResult::success(vec![Content::text(format!( + Ok(CallToolResult::success(vec![ContentBlock::text(format!( "Question: {}\nAnswer: {}", question, response From f1daa9277ec3f3422b4f335133ef54f0fbd73eac Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:59:47 -0400 Subject: [PATCH 191/333] docs: explain OAuth HTTP client setup (#918) --- docs/OAUTH_SUPPORT.md | 178 ++++++++++++++++------ examples/clients/README.md | 1 + examples/clients/src/auth/oauth_client.rs | 12 +- 3 files changed, 140 insertions(+), 51 deletions(-) diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index dd32a17c1..16809a407 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -14,6 +14,7 @@ This document describes the OAuth 2.1 authorization implementation for Model Con - Scope upgrade on 403 insufficient_scope (SEP-835) - Automatic token refresh - Authorized HTTP Client implementation +- Injectable OAuth HTTP client for custom network environments ## Usage Guide @@ -26,86 +27,162 @@ Enable the auth feature in Cargo.toml: rmcp = { version = "0.1", features = ["auth", "transport-streamable-http-client-reqwest"] } ``` -### 2. Use OAuthState +### 2. Configure OAuth network requests + +OAuth makes several HTTP requests before the MCP transport is connected: +protected-resource discovery, authorization-server discovery, dynamic client +registration, authorization-code exchange, token refresh, and client credentials +exchange. When no OAuth HTTP client is provided, the SDK sends those requests +with an internally-created `reqwest::Client`. + +If you only need to customize reqwest behavior, pass a configured +`reqwest::Client` to `OAuthState::new`. This preserves the caller-provided +reqwest configuration across OAuth operations, including token requests. + +```rust ignore +let default_headers = reqwest::header::HeaderMap::new(); +let oauth_http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .default_headers(default_headers) + .build()?; + +let mut oauth_state = OAuthState::new(&server_url, Some(oauth_http_client)) + .await + .context("Failed to initialize oauth state machine")?; +``` + +This is useful for proxy, TLS root, connector, timeout, and default-header +configuration while staying within reqwest. The redirect behavior is the +behavior of the provided reqwest client, so configure that client accordingly. +This OAuth HTTP client is separate from the `reqwest::Client` later passed to +`AuthClient::new`, which is used for the authorized MCP transport after tokens +have been obtained. + +If OAuth requests must run outside reqwest, implement `OAuthHttpClient` and use +`OAuthState::new_with_oauth_http_client`. The SDK passes each OAuth request to +your implementation with the raw HTTP request, a suggested timeout, and an +`OAuthHttpRedirectPolicy`. + +```rust ignore +use std::sync::Arc; + +use rmcp::transport::{ + OAuthHttpClient, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, + OAuthHttpRequest, OAuthState, +}; + +struct MyOAuthHttpClient; + +impl OAuthHttpClient for MyOAuthHttpClient { + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + Box::pin(async move { + match request.redirect_policy { + OAuthHttpRedirectPolicy::Follow => { + // Follow redirects according to your HTTP environment. + } + OAuthHttpRedirectPolicy::Stop => { + // Return redirect responses without following them. + } + _ => { + // Future redirect policies may be added. + } + } + + // Convert `request.request` into your HTTP stack's request type, + // execute it, then convert the response back into the expected + // OAuth HTTP response type. + let response = todo!("send OAuth request"); + Ok(response) + }) + } +} + +let mut oauth_state = OAuthState::new_with_oauth_http_client( + &server_url, + Arc::new(MyOAuthHttpClient), +) +.await?; +``` + +Use this path when OAuth traffic must go through a browser fetch API, a remote +execution environment, a company gateway, a test fake, or any other non-reqwest +transport. + +### 3. Start authorization with OAuthState The `OAuthState` state machine manages the full authorization lifecycle. When no scopes are provided, the SDK automatically selects scopes from the server's WWW-Authenticate header, Protected Resource Metadata, or AS metadata. ```rust ignore - // initialize oauth state machine - let mut oauth_state = OAuthState::new(&server_url, None) - .await - .context("Failed to initialize oauth state machine")?; - - // start authorization - pass empty scopes to let the SDK auto-select - oauth_state - .start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client")) - .await - .context("Failed to start authorization")?; +// start authorization - pass empty scopes to let the SDK auto-select +oauth_state + .start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client")) + .await + .context("Failed to start authorization")?; ``` If you know the scopes you need, you can still pass them explicitly: ```rust ignore - oauth_state - .start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client")) - .await - .context("Failed to start authorization")?; +oauth_state + .start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client")) + .await + .context("Failed to start authorization")?; ``` -### 3. Get authorization url and handle callback +### 4. Get authorization url and handle callback ```rust ignore - // get authorization URL and guide user to open it - let auth_url = oauth_state.get_authorization_url().await?; - println!("Please open the following URL in your browser for authorization:\n{}", auth_url); - - // handle callback - in real applications, this is typically done in a callback server - let auth_code = "Authorization code (`code` param) obtained from browser after user authorization"; - let csrf_token = "CSRF token (`state` param) obtained from browser after user authorization"; - oauth_state.handle_callback(auth_code, csrf_token).await?; +// get authorization URL and guide user to open it +let auth_url = oauth_state.get_authorization_url().await?; +println!("Please open the following URL in your browser for authorization:\n{}", auth_url); + +// handle callback - in real applications, this is typically done in a callback server +let auth_code = "Authorization code (`code` param) obtained from browser after user authorization"; +let csrf_token = "CSRF token (`state` param) obtained from browser after user authorization"; +oauth_state.handle_callback(auth_code, csrf_token).await?; ``` -### 4. Use Authorized Streamable HTTP Transport and create client +### 5. Use Authorized Streamable HTTP Transport and create client ```rust ignore - let am = oauth_state - .into_authorization_manager() - .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; - let client = AuthClient::new(reqwest::Client::default(), am); - let transport = StreamableHttpClientTransport::with_client( - client, - StreamableHttpClientTransportConfig::with_uri(MCP_SERVER_URL), - ); - - // create client and connect to MCP server - let client_service = ClientInfo::default(); - let client = client_service.serve(transport).await?; +let am = oauth_state + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; +let client = AuthClient::new(reqwest::Client::default(), am); +let transport = StreamableHttpClientTransport::with_client( + client, + StreamableHttpClientTransportConfig::with_uri(MCP_SERVER_URL), +); + +// create client and connect to MCP server +let client_service = ClientInfo::default(); +let client = client_service.serve(transport).await?; ``` -### 5. Handle scope upgrades +### 6. Handle scope upgrades If a server returns 403 with `insufficient_scope`, you can request a scope upgrade. The SDK computes the union of current and required scopes and transitions back to the session state for re-authorization. ```rust ignore - match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await { - Ok(auth_url) => { - // open auth_url in browser, handle callback as before - println!("Re-authorize at: {}", auth_url); - } - Err(e) => { - eprintln!("Scope upgrade failed: {}", e); - } +match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await { + Ok(auth_url) => { + // open auth_url in browser, handle callback as before + println!("Re-authorize at: {}", auth_url); + } + Err(e) => { + eprintln!("Scope upgrade failed: {}", e); } +} ``` ## Complete Examples -- **Client**: `examples/clients/src/auth/oauth_client.rs` -- **Server**: `examples/servers/src/complex_auth_streamhttp.rs` +- **Client**: [`examples/clients/src/auth/oauth_client.rs`](../examples/clients/src/auth/oauth_client.rs) +- **Server**: [`examples/servers/src/complex_auth_streamhttp.rs`](../examples/servers/src/complex_auth_streamhttp.rs) ### Running the Examples @@ -134,6 +211,7 @@ cargo run -p mcp-client-examples --example clients_oauth_client - **PKCE S256 always enforced**: never falls back to `plain` or no challenge. OAuth 2.1 mandates S256 as Mandatory To Implement for servers. - **RFC 8707 resource binding**: authorization and token requests include the `resource` parameter to bind tokens to the protected resource +- **Redirect policy is explicit for custom OAuth clients**: discovery and registration requests use `OAuthHttpRedirectPolicy::Follow`, while token requests use `OAuthHttpRedirectPolicy::Stop` so custom implementations can avoid forwarding credentials to redirected endpoints - All tokens are securely stored in memory (custom credential stores supported) - Automatic token refresh reduces user intervention - Server metadata validation warns on non-compliant configurations but proceeds where relatively safe @@ -147,7 +225,9 @@ If you encounter authorization issues, check the following: 3. Check network connection and firewall settings 4. Verify server supports metadata discovery or dynamic client registration 5. If PKCE fails, the server may not support S256 (non-compliant with OAuth 2.1) -6. Check `tracing` logs at debug level for detailed discovery and validation info +6. If OAuth requests need custom proxy, TLS, or connector settings, pass a configured reqwest client to `OAuthState::new` +7. If OAuth requests must run through a non-reqwest environment, implement `OAuthHttpClient` and use `OAuthState::new_with_oauth_http_client` +8. Check `tracing` logs at debug level for detailed discovery and validation info ## References diff --git a/examples/clients/README.md b/examples/clients/README.md index 4361d681d..419cdac22 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -45,6 +45,7 @@ A client demonstrating how to authenticate with an MCP server using OAuth. - Starts a local HTTP server to handle OAuth callbacks - Initializes the OAuth state machine and begins the authorization flow +- Shows how to pass a configured reqwest client for OAuth discovery, registration, token exchange, and refresh requests - Displays the authorization URL and waits for user authorization - Establishes an authorized connection to the MCP server using the acquired access token - Demonstrates how to use the authorized connection to retrieve available tools and prompts diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 7a2bfab00..ffd343428 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -1,4 +1,4 @@ -use std::{env, net::SocketAddr, sync::Arc}; +use std::{env, net::SocketAddr, sync::Arc, time::Duration}; use anyhow::{Context, Result}; use axum::{ @@ -115,8 +115,16 @@ async fn main() -> Result<()> { client_metadata_url ); + // Configure the HTTP client used for OAuth discovery, registration, token + // exchange, and refresh. Customize this builder for proxies, TLS roots, + // default headers, or other reqwest settings required by your environment. + let oauth_http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("Failed to build OAuth HTTP client")?; + // initialize oauth state machine - let mut oauth_state = OAuthState::new(&server_url, None) + let mut oauth_state = OAuthState::new(&server_url, Some(oauth_http_client)) .await .context("Failed to initialize oauth state machine")?; // use CIMD (SEP-991) with client metadata URL. From f07ee4ae1a334926072de1689d9ca7a96c825e13 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:00:51 -0400 Subject: [PATCH 192/333] docs: align README examples with v2 model API (#928) --- README.md | 222 +++++++++++++++--------------------- docs/readme/README.zh-cn.md | 222 +++++++++++++++--------------------- 2 files changed, 188 insertions(+), 256 deletions(-) diff --git a/README.md b/README.md index 7ec71ac6a..68f3343c9 100644 --- a/README.md +++ b/README.md @@ -239,12 +239,11 @@ struct MyServer; impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_resources() .build(), - ..Default::default() - } + ) } async fn list_resources( @@ -254,8 +253,8 @@ impl ServerHandler for MyServer { ) -> Result { Ok(ListResourcesResult { resources: vec![ - RawResource::new("file:///config.json", "config").no_annotation(), - RawResource::new("memo://insights", "insights").no_annotation(), + Resource::new("file:///config.json", "config"), + Resource::new("memo://insights", "insights"), ], next_cursor: None, meta: None, @@ -268,12 +267,12 @@ impl ServerHandler for MyServer { _context: RequestContext, ) -> Result { match request.uri.as_str() { - "file:///config.json" => Ok(ReadResourceResult { - contents: vec![ResourceContents::text(r#"{"key": "value"}"#, &request.uri)], - }), - "memo://insights" => Ok(ReadResourceResult { - contents: vec![ResourceContents::text("Analysis results...", &request.uri)], - }), + "file:///config.json" => Ok(ReadResourceResult::new(vec![ + ResourceContents::text(r#"{"key": "value"}"#, &request.uri), + ])), + "memo://insights" => Ok(ReadResourceResult::new(vec![ + ResourceContents::text("Analysis results...", &request.uri), + ])), _ => Err(McpError::resource_not_found( "resource_not_found", Some(json!({ "uri": request.uri })), @@ -304,10 +303,9 @@ use rmcp::model::{ReadResourceRequestParams}; let resources = client.list_all_resources().await?; // Read a specific resource by URI -let result = client.read_resource(ReadResourceRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; +let result = client.read_resource( + ReadResourceRequestParams::new("file:///config.json"), +).await?; // List resource templates let templates = client.list_all_resource_templates().await?; @@ -322,9 +320,9 @@ Servers can notify clients when the resource list changes or when a specific res context.peer.notify_resource_list_changed().await?; // Notify that a specific resource was updated -context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { - uri: "file:///config.json".into(), -}).await?; +context.peer.notify_resource_updated( + ResourceUpdatedNotificationParam::new("file:///config.json"), +).await?; ``` Clients handle these via `ClientHandler`: @@ -397,7 +395,7 @@ impl MyServer { #[prompt(name = "greeting", description = "A simple greeting")] async fn greeting(&self) -> Vec { vec![PromptMessage::new_text( - PromptMessageRole::User, + Role::User, "Hello! How can you help me today?", )] } @@ -411,25 +409,20 @@ impl MyServer { let focus = args.focus_areas .unwrap_or_else(|| vec!["correctness".into()]); - Ok(GetPromptResult { - description: Some(format!("Code review for {}", args.language)), - messages: vec![ - PromptMessage::new_text( - PromptMessageRole::User, - format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), - ), - ], - }) + Ok(GetPromptResult::new(vec![ + PromptMessage::new_text( + Role::User, + format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), + ), + ]) + .with_description(format!("Code review for {}", args.language))) } } #[prompt_handler] impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_prompts().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_prompts().build()) } } ``` @@ -485,25 +478,22 @@ Access the client's sampling capability through `context.peer.create_message()`: use rmcp::model::*; // Inside a ServerHandler method (e.g., call_tool): -let response = context.peer.create_message(CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("Explain this error: connection refused")], - model_preferences: Some(ModelPreferences { - hints: Some(vec![ModelHint { name: Some("claude".into()) }]), - cost_priority: Some(0.3), - speed_priority: Some(0.8), - intelligence_priority: Some(0.7), - }), - system_prompt: Some("You are a helpful assistant.".into()), - include_context: Some(ContextInclusion::None), - temperature: Some(0.7), - max_tokens: 150, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, -}).await?; +let response = context.peer.create_message( + CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("Explain this error: connection refused")], + 150, + ) + .with_model_preferences( + ModelPreferences::new() + .with_hints(vec![ModelHint::new("claude")]) + .with_cost_priority(0.3) + .with_speed_priority(0.8) + .with_intelligence_priority(0.7), + ) + .with_system_prompt("You are a helpful assistant.") + .with_include_context(ContextInclusion::None) + .with_temperature(0.7), +).await?; // Extract the response text let text = response.message.content @@ -531,11 +521,11 @@ impl ClientHandler for MyClient { // Forward to your LLM, or return a mock response: let response_text = call_your_llm(¶ms.messages).await; - Ok(CreateMessageResult { - message: SamplingMessage::assistant_text(response_text), - model: "my-model".into(), - stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.into()), - }) + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text(response_text), + "my-model".into(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) } } ``` @@ -593,14 +583,9 @@ impl ClientHandler for MyClient { &self, _context: RequestContext, ) -> Result { - Ok(ListRootsResult { - roots: vec![ - Root { - uri: "file:///home/user/project".into(), - name: Some("My Project".into()), - }, - ], - }) + Ok(ListRootsResult::new(vec![ + Root::new("file:///home/user/project").with_name("My Project"), + ])) } } ``` @@ -631,12 +616,11 @@ use rmcp::{ServerHandler, model::*, service::RequestContext}; impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_logging() .build(), - ..Default::default() - } + ) } // Client sets the minimum log level @@ -651,14 +635,16 @@ impl ServerHandler for MyServer { } // Send a log message from any handler with access to the peer: -context.peer.notify_logging_message(LoggingMessageNotificationParam { - level: LoggingLevel::Info, - logger: Some("my-server".into()), - data: serde_json::json!({ - "message": "Processing completed", - "items_processed": 42 - }), -}).await?; +context.peer.notify_logging_message( + LoggingMessageNotificationParam::new( + LoggingLevel::Info, + serde_json::json!({ + "message": "Processing completed", + "items_processed": 42 + }), + ) + .with_logger("my-server"), +).await?; ``` Available log levels (from least to most severe): `Debug`, `Info`, `Notice`, `Warning`, `Error`, `Critical`, `Alert`, `Emergency`. @@ -683,10 +669,7 @@ impl ClientHandler for MyClient { Clients can also set the server's log level: ```rust -client.set_level(SetLevelRequestParams { - level: LoggingLevel::Warning, - meta: None, -}).await?; +client.set_level(SetLevelRequestParams::new(LoggingLevel::Warning)).await?; ``` --- @@ -706,13 +689,12 @@ use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestConte impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_completions() .enable_prompts() .build(), - ..Default::default() - } + ) } async fn complete( @@ -750,13 +732,9 @@ impl ServerHandler for MyServer { .filter(|v| v.to_lowercase().contains(&request.argument.value.to_lowercase())) .collect(); - Ok(CompleteResult { - completion: CompletionInfo { - values: filtered, - total: None, - has_more: Some(false), - }, - }) + let completion = CompletionInfo::with_pagination(filtered, None, false) + .map_err(|e| McpError::internal_error(e, None))?; + Ok(CompleteResult::new(completion)) } } ``` @@ -766,17 +744,10 @@ impl ServerHandler for MyServer { ```rust use rmcp::model::*; -let result = client.complete(CompleteRequestParams { - meta: None, - r#ref: Reference::Prompt(PromptReference { - name: "sql_query".into(), - }), - argument: ArgumentInfo { - name: "operation".into(), - value: "SEL".into(), - }, - context: None, -}).await?; +let result = client.complete(CompleteRequestParams::new( + Reference::for_prompt("sql_query"), + ArgumentInfo::new("operation", "SEL"), +)).await?; // result.completion.values contains suggestions like ["SELECT"] ``` @@ -802,12 +773,14 @@ use rmcp::model::*; for i in 0..total_items { process_item(i).await; - context.peer.notify_progress(ProgressNotificationParam { - progress_token: ProgressToken(NumberOrString::Number(i as i64)), - progress: i as f64, - total: Some(total_items as f64), - message: Some(format!("Processing item {}/{}", i + 1, total_items)), - }).await?; + context.peer.notify_progress( + ProgressNotificationParam::new( + ProgressToken(NumberOrString::Number(i as i64)), + i as f64, + ) + .with_total(total_items as f64) + .with_message(format!("Processing item {}/{}", i + 1, total_items)), + ).await?; } ``` @@ -817,10 +790,10 @@ Either side can cancel an in-progress request: ```rust // Send a cancellation -context.peer.notify_cancelled(CancelledNotificationParam { - request_id: the_request_id, - reason: Some("User requested cancellation".into()), -}).await?; +context.peer.notify_cancelled(CancelledNotificationParam::new( + Some(the_request_id), + Some("User requested cancellation".into()), +)).await?; ``` Handle cancellation in `ServerHandler` or `ClientHandler`: @@ -891,13 +864,12 @@ struct MyServer { impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_resources() .enable_resources_subscribe() .build(), - ..Default::default() - } + ) } async fn subscribe( @@ -924,9 +896,9 @@ When a subscribed resource changes, notify the client: ```rust // Check if the resource has subscribers, then notify -context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { - uri: "file:///config.json".into(), -}).await?; +context.peer.notify_resource_updated( + ResourceUpdatedNotificationParam::new("file:///config.json"), +).await?; ``` ### Client-side @@ -935,16 +907,10 @@ context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { use rmcp::model::*; // Subscribe to updates for a resource -client.subscribe(SubscribeRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; +client.subscribe(SubscribeRequestParams::new("file:///config.json")).await?; // Unsubscribe when no longer needed -client.unsubscribe(UnsubscribeRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; +client.unsubscribe(UnsubscribeRequestParams::new("file:///config.json")).await?; ``` Handle update notifications in `ClientHandler`: diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md index 5ddfaee5d..84393849a 100644 --- a/docs/readme/README.zh-cn.md +++ b/docs/readme/README.zh-cn.md @@ -237,12 +237,11 @@ struct MyServer; impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_resources() .build(), - ..Default::default() - } + ) } async fn list_resources( @@ -252,8 +251,8 @@ impl ServerHandler for MyServer { ) -> Result { Ok(ListResourcesResult { resources: vec![ - RawResource::new("file:///config.json", "config").no_annotation(), - RawResource::new("memo://insights", "insights").no_annotation(), + Resource::new("file:///config.json", "config"), + Resource::new("memo://insights", "insights"), ], next_cursor: None, meta: None, @@ -266,12 +265,12 @@ impl ServerHandler for MyServer { _context: RequestContext, ) -> Result { match request.uri.as_str() { - "file:///config.json" => Ok(ReadResourceResult { - contents: vec![ResourceContents::text(r#"{"key": "value"}"#, &request.uri)], - }), - "memo://insights" => Ok(ReadResourceResult { - contents: vec![ResourceContents::text("Analysis results...", &request.uri)], - }), + "file:///config.json" => Ok(ReadResourceResult::new(vec![ + ResourceContents::text(r#"{"key": "value"}"#, &request.uri), + ])), + "memo://insights" => Ok(ReadResourceResult::new(vec![ + ResourceContents::text("Analysis results...", &request.uri), + ])), _ => Err(McpError::resource_not_found( "resource_not_found", Some(json!({ "uri": request.uri })), @@ -302,10 +301,9 @@ use rmcp::model::{ReadResourceRequestParams}; let resources = client.list_all_resources().await?; // 通过 URI 读取特定资源 -let result = client.read_resource(ReadResourceRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; +let result = client.read_resource( + ReadResourceRequestParams::new("file:///config.json"), +).await?; // 列出资源模板 let templates = client.list_all_resource_templates().await?; @@ -320,9 +318,9 @@ let templates = client.list_all_resource_templates().await?; context.peer.notify_resource_list_changed().await?; // 通知特定资源已更新 -context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { - uri: "file:///config.json".into(), -}).await?; +context.peer.notify_resource_updated( + ResourceUpdatedNotificationParam::new("file:///config.json"), +).await?; ``` 客户端通过 `ClientHandler` 处理这些通知: @@ -395,7 +393,7 @@ impl MyServer { #[prompt(name = "greeting", description = "A simple greeting")] async fn greeting(&self) -> Vec { vec![PromptMessage::new_text( - PromptMessageRole::User, + Role::User, "Hello! How can you help me today?", )] } @@ -409,25 +407,20 @@ impl MyServer { let focus = args.focus_areas .unwrap_or_else(|| vec!["correctness".into()]); - Ok(GetPromptResult { - description: Some(format!("Code review for {}", args.language)), - messages: vec![ - PromptMessage::new_text( - PromptMessageRole::User, - format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), - ), - ], - }) + Ok(GetPromptResult::new(vec![ + PromptMessage::new_text( + Role::User, + format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), + ), + ]) + .with_description(format!("Code review for {}", args.language))) } } #[prompt_handler] impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_prompts().build(), - ..Default::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_prompts().build()) } } ``` @@ -481,25 +474,22 @@ context.peer.notify_prompt_list_changed().await?; use rmcp::model::*; // 在 ServerHandler 方法内部(例如 call_tool): -let response = context.peer.create_message(CreateMessageRequestParams { - meta: None, - task: None, - messages: vec![SamplingMessage::user_text("Explain this error: connection refused")], - model_preferences: Some(ModelPreferences { - hints: Some(vec![ModelHint { name: Some("claude".into()) }]), - cost_priority: Some(0.3), - speed_priority: Some(0.8), - intelligence_priority: Some(0.7), - }), - system_prompt: Some("You are a helpful assistant.".into()), - include_context: Some(ContextInclusion::None), - temperature: Some(0.7), - max_tokens: 150, - stop_sequences: None, - metadata: None, - tools: None, - tool_choice: None, -}).await?; +let response = context.peer.create_message( + CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("Explain this error: connection refused")], + 150, + ) + .with_model_preferences( + ModelPreferences::new() + .with_hints(vec![ModelHint::new("claude")]) + .with_cost_priority(0.3) + .with_speed_priority(0.8) + .with_intelligence_priority(0.7), + ) + .with_system_prompt("You are a helpful assistant.") + .with_include_context(ContextInclusion::None) + .with_temperature(0.7), +).await?; // 提取响应文本 let text = response.message.content @@ -527,11 +517,11 @@ impl ClientHandler for MyClient { // 转发到你的 LLM,或返回模拟响应: let response_text = call_your_llm(¶ms.messages).await; - Ok(CreateMessageResult { - message: SamplingMessage::assistant_text(response_text), - model: "my-model".into(), - stop_reason: Some(CreateMessageResult::STOP_REASON_END_TURN.into()), - }) + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text(response_text), + "my-model".into(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) } } ``` @@ -587,14 +577,9 @@ impl ClientHandler for MyClient { &self, _context: RequestContext, ) -> Result { - Ok(ListRootsResult { - roots: vec![ - Root { - uri: "file:///home/user/project".into(), - name: Some("My Project".into()), - }, - ], - }) + Ok(ListRootsResult::new(vec![ + Root::new("file:///home/user/project").with_name("My Project"), + ])) } } ``` @@ -623,12 +608,11 @@ use rmcp::{ServerHandler, model::*, service::RequestContext}; impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_logging() .build(), - ..Default::default() - } + ) } // 客户端设置最低日志级别 @@ -643,14 +627,16 @@ impl ServerHandler for MyServer { } // 在任何可以访问 peer 的处理器中发送日志消息: -context.peer.notify_logging_message(LoggingMessageNotificationParam { - level: LoggingLevel::Info, - logger: Some("my-server".into()), - data: serde_json::json!({ - "message": "Processing completed", - "items_processed": 42 - }), -}).await?; +context.peer.notify_logging_message( + LoggingMessageNotificationParam::new( + LoggingLevel::Info, + serde_json::json!({ + "message": "Processing completed", + "items_processed": 42 + }), + ) + .with_logger("my-server"), +).await?; ``` 可用日志级别(从低到高):`Debug`、`Info`、`Notice`、`Warning`、`Error`、`Critical`、`Alert`、`Emergency`。 @@ -675,10 +661,7 @@ impl ClientHandler for MyClient { 客户端也可以设置服务端的日志级别: ```rust -client.set_level(SetLevelRequestParams { - level: LoggingLevel::Warning, - meta: None, -}).await?; +client.set_level(SetLevelRequestParams::new(LoggingLevel::Warning)).await?; ``` --- @@ -698,13 +681,12 @@ use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestConte impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_completions() .enable_prompts() .build(), - ..Default::default() - } + ) } async fn complete( @@ -742,13 +724,9 @@ impl ServerHandler for MyServer { .filter(|v| v.to_lowercase().contains(&request.argument.value.to_lowercase())) .collect(); - Ok(CompleteResult { - completion: CompletionInfo { - values: filtered, - total: None, - has_more: Some(false), - }, - }) + let completion = CompletionInfo::with_pagination(filtered, None, false) + .map_err(|e| McpError::internal_error(e, None))?; + Ok(CompleteResult::new(completion)) } } ``` @@ -758,17 +736,10 @@ impl ServerHandler for MyServer { ```rust use rmcp::model::*; -let result = client.complete(CompleteRequestParams { - meta: None, - r#ref: Reference::Prompt(PromptReference { - name: "sql_query".into(), - }), - argument: ArgumentInfo { - name: "operation".into(), - value: "SEL".into(), - }, - context: None, -}).await?; +let result = client.complete(CompleteRequestParams::new( + Reference::for_prompt("sql_query"), + ArgumentInfo::new("operation", "SEL"), +)).await?; // result.completion.values 包含建议,例如 ["SELECT"] ``` @@ -794,12 +765,14 @@ use rmcp::model::*; for i in 0..total_items { process_item(i).await; - context.peer.notify_progress(ProgressNotificationParam { - progress_token: ProgressToken(NumberOrString::Number(i as i64)), - progress: i as f64, - total: Some(total_items as f64), - message: Some(format!("Processing item {}/{}", i + 1, total_items)), - }).await?; + context.peer.notify_progress( + ProgressNotificationParam::new( + ProgressToken(NumberOrString::Number(i as i64)), + i as f64, + ) + .with_total(total_items as f64) + .with_message(format!("Processing item {}/{}", i + 1, total_items)), + ).await?; } ``` @@ -809,10 +782,10 @@ for i in 0..total_items { ```rust // 发送取消通知 -context.peer.notify_cancelled(CancelledNotificationParam { - request_id: the_request_id, - reason: Some("User requested cancellation".into()), -}).await?; +context.peer.notify_cancelled(CancelledNotificationParam::new( + Some(the_request_id), + Some("User requested cancellation".into()), +)).await?; ``` 在 `ServerHandler` 或 `ClientHandler` 中处理取消: @@ -883,13 +856,12 @@ struct MyServer { impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_resources() .enable_resources_subscribe() .build(), - ..Default::default() - } + ) } async fn subscribe( @@ -916,9 +888,9 @@ impl ServerHandler for MyServer { ```rust // 检查资源是否有订阅者,然后通知 -context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { - uri: "file:///config.json".into(), -}).await?; +context.peer.notify_resource_updated( + ResourceUpdatedNotificationParam::new("file:///config.json"), +).await?; ``` ### 客户端 @@ -927,16 +899,10 @@ context.peer.notify_resource_updated(ResourceUpdatedNotificationParam { use rmcp::model::*; // 订阅资源更新 -client.subscribe(SubscribeRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; +client.subscribe(SubscribeRequestParams::new("file:///config.json")).await?; // 不再需要时取消订阅 -client.unsubscribe(UnsubscribeRequestParams { - meta: None, - uri: "file:///config.json".into(), -}).await?; +client.unsubscribe(UnsubscribeRequestParams::new("file:///config.json")).await?; ``` 在 `ClientHandler` 中处理更新通知: From d1cabb458f079675a8a78875e3b5d0126a0470f9 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:11:59 -0400 Subject: [PATCH 193/333] feat: deprecate roots/sampling/logging types (#923) --- crates/rmcp/src/handler/client.rs | 2 + crates/rmcp/src/handler/server.rs | 2 + crates/rmcp/src/model.rs | 67 +++++++++++++++++++ crates/rmcp/src/model/content.rs | 10 +++ crates/rmcp/src/service/client.rs | 2 + crates/rmcp/src/service/server.rs | 2 + crates/rmcp/tests/common/handlers.rs | 2 + crates/rmcp/tests/test_message_protocol.rs | 1 + .../client_json_rpc_message_schema.json | 8 +++ ...lient_json_rpc_message_schema_current.json | 8 +++ .../server_json_rpc_message_schema.json | 16 ++++- ...erver_json_rpc_message_schema_current.json | 16 ++++- 12 files changed, 130 insertions(+), 6 deletions(-) diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 886268073..90f7ed4a9 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -1,3 +1,5 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] pub mod progress; use std::sync::Arc; diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 54964559d..0fb4bf891 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -1,3 +1,5 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] use std::sync::Arc; use crate::{ diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index cf2fe5900..fc45f1efa 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1,3 +1,6 @@ +// Internal references to the SEP-2577-deprecated Roots/Sampling/Logging types +// defined in this module are expected; the deprecation is advisory for downstream users. +#![expect(deprecated)] use std::{ borrow::Cow, ops::{Deref, DerefMut}, @@ -1488,6 +1491,10 @@ pub type ToolListChangedNotification = NotificationNoParam; const_string!(LoggingMessageNotificationMethod = "notifications/message"); @@ -1542,6 +1557,10 @@ const_string!(LoggingMessageNotificationMethod = "notifications/message"); #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct LoggingMessageNotificationParam { /// The severity level of this log message pub level: LoggingLevel, @@ -1573,6 +1592,10 @@ impl LoggingMessageNotificationParam { } /// Notification containing a log message +#[deprecated( + since = "2.0.0", + note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub type LoggingMessageNotification = Notification; @@ -1581,6 +1604,10 @@ pub type LoggingMessageNotification = // ============================================================================= const_string!(CreateMessageRequestMethod = "sampling/createMessage"); +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub type CreateMessageRequest = Request; /// Represents the role of a participant in a conversation or message exchange. @@ -1618,6 +1645,10 @@ pub enum ToolChoiceMode { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct ToolChoice { #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, @@ -1750,6 +1781,10 @@ impl From> for SamplingContent { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct SamplingMessage { /// The role of the message sender (User or Assistant) pub role: Role, @@ -1764,6 +1799,10 @@ pub struct SamplingMessage { #[serde(tag = "type", rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub enum SamplingMessageContentBlock { Text(TextContent), Image(ImageContent), @@ -1912,6 +1951,10 @@ pub enum ContextInclusion { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct CreateMessageRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -2125,6 +2168,10 @@ pub type CreateMessageRequestParam = CreateMessageRequestParams; #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct ModelPreferences { /// Specific model names or families to prefer (e.g., "claude", "gpt") #[serde(skip_serializing_if = "Option::is_none")] @@ -2189,6 +2236,10 @@ impl Default for ModelPreferences { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct ModelHint { /// The suggested model name or family identifier #[serde(skip_serializing_if = "Option::is_none")] @@ -2522,6 +2573,10 @@ impl ArgumentInfo { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct Root { pub uri: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -2554,12 +2609,20 @@ impl Root { } const_string!(ListRootsRequestMethod = "roots/list"); +#[deprecated( + since = "2.0.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub type ListRootsRequest = RequestNoParam; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct ListRootsResult { pub roots: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] @@ -3167,6 +3230,10 @@ pub type CallToolRequest = Request #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct CreateMessageResult { /// The identifier of the model that generated the response pub model: String, diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index c32f81b3d..680136f8e 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -7,6 +7,8 @@ //! [`SamplingMessageContentBlock`] extends the union with `tool_use` and `tool_result` //! variants for sampling messages (SEP-1577). +// ToolUseContent/ToolResultContent are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] use serde::{Deserialize, Serialize}; use serde_json::json; @@ -178,6 +180,10 @@ impl EmbeddedResource { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct ToolUseContent { pub id: String, pub name: String, @@ -191,6 +197,10 @@ pub struct ToolUseContent { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] +#[deprecated( + since = "2.0.0", + note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" +)] pub struct ToolResultContent { #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 7bb5d8238..05c2749fe 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1,3 +1,5 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] use std::borrow::Cow; use thiserror::Error; diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 160d5b324..c369e5aca 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -1,3 +1,5 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. +#![expect(deprecated)] use std::borrow::Cow; #[cfg(feature = "elicitation")] use std::collections::HashSet; diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index 276e1bd23..7928fa6eb 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -1,3 +1,5 @@ +// Sampling/Roots/Logging are SEP-2577-deprecated; this test handler exercises them. +#![expect(deprecated)] use std::{ future::Future, sync::{Arc, Mutex}, diff --git a/crates/rmcp/tests/test_message_protocol.rs b/crates/rmcp/tests/test_message_protocol.rs index 6fdb4285d..f42cfe0a1 100644 --- a/crates/rmcp/tests/test_message_protocol.rs +++ b/crates/rmcp/tests/test_message_protocol.rs @@ -1,5 +1,6 @@ //cargo test --test test_message_protocol --features "client server" #![cfg(not(feature = "local"))] +#![expect(deprecated)] // exercises SEP-2577-deprecated Sampling/Roots/Logging types mod common; use common::handlers::{TestClientHandler, TestServer}; diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 46378bc9c..3bfdb7c43 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -494,6 +494,7 @@ ] } }, + "deprecated": true, "required": [ "model", "role", @@ -1152,6 +1153,7 @@ } } }, + "deprecated": true, "required": [ "roots" ] @@ -1169,6 +1171,7 @@ "LoggingLevel": { "description": "Logging levels supported by the MCP protocol", "type": "string", + "deprecated": true, "enum": [ "debug", "info", @@ -1894,6 +1897,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "uri" ] @@ -1953,6 +1957,7 @@ }, "SamplingMessageContentBlock": { "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, "oneOf": [ { "type": "object", @@ -2082,6 +2087,7 @@ ] } }, + "deprecated": true, "required": [ "level" ] @@ -2357,6 +2363,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "toolUseId", "content" @@ -2384,6 +2391,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "id", "name", diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 46378bc9c..3bfdb7c43 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -494,6 +494,7 @@ ] } }, + "deprecated": true, "required": [ "model", "role", @@ -1152,6 +1153,7 @@ } } }, + "deprecated": true, "required": [ "roots" ] @@ -1169,6 +1171,7 @@ "LoggingLevel": { "description": "Logging levels supported by the MCP protocol", "type": "string", + "deprecated": true, "enum": [ "debug", "info", @@ -1894,6 +1897,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "uri" ] @@ -1953,6 +1957,7 @@ }, "SamplingMessageContentBlock": { "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, "oneOf": [ { "type": "object", @@ -2082,6 +2087,7 @@ ] } }, + "deprecated": true, "required": [ "level" ] @@ -2357,6 +2363,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "toolUseId", "content" @@ -2384,6 +2391,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "id", "name", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 2b3b41732..fcf821f52 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -565,6 +565,7 @@ } } }, + "deprecated": true, "required": [ "messages", "maxTokens" @@ -1577,6 +1578,7 @@ "LoggingLevel": { "description": "Logging levels supported by the MCP protocol", "type": "string", + "deprecated": true, "enum": [ "debug", "info", @@ -1623,6 +1625,7 @@ ] } }, + "deprecated": true, "required": [ "level", "data" @@ -1639,7 +1642,8 @@ "null" ] } - } + }, + "deprecated": true }, "ModelPreferences": { "description": "Preferences for model selection and behavior in sampling requests.\n\nThis allows servers to express their preferences for which model to use\nand how to balance different priorities when the client has multiple\nmodel options available.", @@ -1679,7 +1683,8 @@ ], "format": "float" } - } + }, + "deprecated": true }, "MultiSelectEnumSchema": { "description": "Multi-select enum options", @@ -2494,6 +2499,7 @@ ] } }, + "deprecated": true, "required": [ "role", "content" @@ -2501,6 +2507,7 @@ }, "SamplingMessageContentBlock": { "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, "oneOf": [ { "type": "object", @@ -3379,7 +3386,8 @@ } ] } - } + }, + "deprecated": true }, "ToolChoiceMode": { "description": "Tool selection mode (SEP-1577).", @@ -3457,6 +3465,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "toolUseId", "content" @@ -3484,6 +3493,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "id", "name", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 2b3b41732..fcf821f52 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -565,6 +565,7 @@ } } }, + "deprecated": true, "required": [ "messages", "maxTokens" @@ -1577,6 +1578,7 @@ "LoggingLevel": { "description": "Logging levels supported by the MCP protocol", "type": "string", + "deprecated": true, "enum": [ "debug", "info", @@ -1623,6 +1625,7 @@ ] } }, + "deprecated": true, "required": [ "level", "data" @@ -1639,7 +1642,8 @@ "null" ] } - } + }, + "deprecated": true }, "ModelPreferences": { "description": "Preferences for model selection and behavior in sampling requests.\n\nThis allows servers to express their preferences for which model to use\nand how to balance different priorities when the client has multiple\nmodel options available.", @@ -1679,7 +1683,8 @@ ], "format": "float" } - } + }, + "deprecated": true }, "MultiSelectEnumSchema": { "description": "Multi-select enum options", @@ -2494,6 +2499,7 @@ ] } }, + "deprecated": true, "required": [ "role", "content" @@ -2501,6 +2507,7 @@ }, "SamplingMessageContentBlock": { "description": "Content types for sampling messages (SEP-1577).", + "deprecated": true, "oneOf": [ { "type": "object", @@ -3379,7 +3386,8 @@ } ] } - } + }, + "deprecated": true }, "ToolChoiceMode": { "description": "Tool selection mode (SEP-1577).", @@ -3457,6 +3465,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "toolUseId", "content" @@ -3484,6 +3493,7 @@ "type": "string" } }, + "deprecated": true, "required": [ "id", "name", From 415852806d0b9d847882084f46c5540ce6c4de43 Mon Sep 17 00:00:00 2001 From: moroviintaas Date: Fri, 26 Jun 2026 18:38:17 +0200 Subject: [PATCH 194/333] fix: fill missing fully qualified syntax in prompt_handler macros (#866) * fix: fill missing fully qualified syntax in prompt_handler macros * fix: remove unused prompt handler test imports --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp-macros/src/prompt_handler.rs | 14 +++++++------- crates/rmcp/tests/test_prompt_handler.rs | 8 +------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 4f2541ac6..19ba4388e 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -33,9 +33,9 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - ) -> Result { + request: rmcp::model::GetPromptRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { let prompt_context = rmcp::handler::server::prompt::PromptContext::new( self, request.name, @@ -56,11 +56,11 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - _context: RequestContext, - ) -> Result { + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { let prompts = #router_expr.list_all(); - Ok(ListPromptsResult { + Ok(rmcp::model::ListPromptsResult { prompts, meta: #meta, next_cursor: None, diff --git a/crates/rmcp/tests/test_prompt_handler.rs b/crates/rmcp/tests/test_prompt_handler.rs index 6288cddc0..97018a55a 100644 --- a/crates/rmcp/tests/test_prompt_handler.rs +++ b/crates/rmcp/tests/test_prompt_handler.rs @@ -3,13 +3,7 @@ // the ServerHandler trait implementation methods. #![allow(dead_code)] -use rmcp::{ - RoleServer, ServerHandler, - handler::server::router::prompt::PromptRouter, - model::{GetPromptRequestParams, GetPromptResult, ListPromptsResult, PaginatedRequestParams}, - prompt_handler, - service::RequestContext, -}; +use rmcp::{ServerHandler, handler::server::router::prompt::PromptRouter, prompt_handler}; #[derive(Debug, Clone)] pub struct TestPromptServer { From b8a936c4f529d0ce4a8680a41a213792c7786b70 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:57:18 -0400 Subject: [PATCH 195/333] feat!: relax tool result structuredContent type (#919) --- crates/rmcp/src/model/content.rs | 4 +-- .../client_json_rpc_message_schema.json | 8 +---- ...lient_json_rpc_message_schema_current.json | 8 +---- .../server_json_rpc_message_schema.json | 8 +---- ...erver_json_rpc_message_schema_current.json | 8 +---- crates/rmcp/tests/test_sampling.rs | 32 +++++++++++++++++++ 6 files changed, 38 insertions(+), 30 deletions(-) diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index 680136f8e..cd98f96cd 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -10,7 +10,7 @@ // ToolUseContent/ToolResultContent are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; use super::{Annotations, Meta, resource::ResourceContents}; @@ -207,7 +207,7 @@ pub struct ToolResultContent { pub tool_use_id: String, pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub structured_content: Option, + pub structured_content: Option, #[serde(skip_serializing_if = "Option::is_none")] pub is_error: Option, } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 3bfdb7c43..1108254b4 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -2352,13 +2352,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 3bfdb7c43..1108254b4 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -2352,13 +2352,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index fcf821f52..c002b1809 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -3454,13 +3454,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index fcf821f52..c002b1809 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -3454,13 +3454,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 83d3f6fb1..b1c1710be 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -370,6 +370,38 @@ fn test_tool_result_content_requires_content() { assert!(err.to_string().contains("missing field `content`")); } +#[tokio::test] +async fn test_tool_result_content_with_array_structured_content() -> Result<()> { + let structured = + serde_json::json!([{ "city": "SF", "temp": 72 }, { "city": "NY", "temp": 65 }]); + let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("forecast")]); + tool_result.structured_content = Some(structured); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(tool_result, deserialized); + assert!(deserialized.structured_content.unwrap().is_array()); + + Ok(()) +} + +#[tokio::test] +async fn test_tool_result_content_with_primitive_structured_content() -> Result<()> { + let structured = serde_json::json!(42); + let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("count")]); + tool_result.structured_content = Some(structured); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(tool_result, deserialized); + assert!(matches!( + deserialized.structured_content, + Some(serde_json::Value::Number(_)) + )); + + Ok(()) +} + #[tokio::test] async fn test_sampling_message_with_tool_use() -> Result<()> { let message = SamplingMessage::assistant_tool_use( From 4b9bea7e7da6ec4f9ebc126d70b4950f5b1533f2 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:00:02 -0400 Subject: [PATCH 196/333] Revert "feat!: relax tool result structuredContent type (#919)" (#932) This reverts commit b8a936c4f529d0ce4a8680a41a213792c7786b70. --- crates/rmcp/src/model/content.rs | 4 +-- .../client_json_rpc_message_schema.json | 8 ++++- ...lient_json_rpc_message_schema_current.json | 8 ++++- .../server_json_rpc_message_schema.json | 8 ++++- ...erver_json_rpc_message_schema_current.json | 8 ++++- crates/rmcp/tests/test_sampling.rs | 32 ------------------- 6 files changed, 30 insertions(+), 38 deletions(-) diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index cd98f96cd..680136f8e 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -10,7 +10,7 @@ // ToolUseContent/ToolResultContent are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::json; use super::{Annotations, Meta, resource::ResourceContents}; @@ -207,7 +207,7 @@ pub struct ToolResultContent { pub tool_use_id: String, pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub structured_content: Option, + pub structured_content: Option, #[serde(skip_serializing_if = "Option::is_none")] pub is_error: Option, } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 1108254b4..3bfdb7c43 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -2352,7 +2352,13 @@ "null" ] }, - "structuredContent": true, + "structuredContent": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 1108254b4..3bfdb7c43 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -2352,7 +2352,13 @@ "null" ] }, - "structuredContent": true, + "structuredContent": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index c002b1809..fcf821f52 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -3454,7 +3454,13 @@ "null" ] }, - "structuredContent": true, + "structuredContent": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index c002b1809..fcf821f52 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -3454,7 +3454,13 @@ "null" ] }, - "structuredContent": true, + "structuredContent": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index b1c1710be..83d3f6fb1 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -370,38 +370,6 @@ fn test_tool_result_content_requires_content() { assert!(err.to_string().contains("missing field `content`")); } -#[tokio::test] -async fn test_tool_result_content_with_array_structured_content() -> Result<()> { - let structured = - serde_json::json!([{ "city": "SF", "temp": 72 }, { "city": "NY", "temp": 65 }]); - let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("forecast")]); - tool_result.structured_content = Some(structured); - - let json = serde_json::to_string(&tool_result)?; - let deserialized: ToolResultContent = serde_json::from_str(&json)?; - assert_eq!(tool_result, deserialized); - assert!(deserialized.structured_content.unwrap().is_array()); - - Ok(()) -} - -#[tokio::test] -async fn test_tool_result_content_with_primitive_structured_content() -> Result<()> { - let structured = serde_json::json!(42); - let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("count")]); - tool_result.structured_content = Some(structured); - - let json = serde_json::to_string(&tool_result)?; - let deserialized: ToolResultContent = serde_json::from_str(&json)?; - assert_eq!(tool_result, deserialized); - assert!(matches!( - deserialized.structured_content, - Some(serde_json::Value::Number(_)) - )); - - Ok(()) -} - #[tokio::test] async fn test_sampling_message_with_tool_use() -> Result<()> { let message = SamplingMessage::assistant_tool_use( From e1af378949b375bfa36de8f9744ec7d5b693775a Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:15:20 -0400 Subject: [PATCH 197/333] chore: consolidate repeated rmcp tests (#931) --- crates/rmcp/Cargo.toml | 1 + crates/rmcp/src/handler/server/common.rs | 121 ++++--- crates/rmcp/src/model/elicitation_schema.rs | 85 +++-- crates/rmcp/src/transport/auth.rs | 297 ++++++++---------- .../common/reqwest/streamable_http_client.rs | 30 +- .../tests/test_inflight_response_drain.rs | 1 + crates/rmcp/tests/test_notification.rs | 2 +- crates/rmcp/tests/test_progress_subscriber.rs | 1 + crates/rmcp/tests/test_prompt_macros.rs | 16 +- crates/rmcp/tests/test_prompt_routers.rs | 14 +- .../tests/test_task_support_validation.rs | 1 + .../tests/test_tool_disable_notification.rs | 31 +- crates/rmcp/tests/test_tool_macros.rs | 24 +- crates/rmcp/tests/test_tool_routers.rs | 14 +- 14 files changed, 306 insertions(+), 332 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 638679812..fe2ecaac5 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -200,6 +200,7 @@ tracing-subscriber = { version = "0.3", features = [ "fmt", ] } async-trait = "0.1" +rstest = "0.26.1" [[test]] name = "test_tool_macros" required-features = ["server", "client"] diff --git a/crates/rmcp/src/handler/server/common.rs b/crates/rmcp/src/handler/server/common.rs index aa996b5b6..0d76547db 100644 --- a/crates/rmcp/src/handler/server/common.rs +++ b/crates/rmcp/src/handler/server/common.rs @@ -233,6 +233,8 @@ pub trait AsRequestContext { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] @@ -245,46 +247,44 @@ mod tests { value: i32, } - #[test] - fn test_schema_for_type_handles_primitive() { - let schema = schema_for_type::(); - - assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); + #[rstest] + #[case::primitive(schema_for_type::, "integer")] + #[case::array(schema_for_type::>, "array")] + #[case::struct_object(schema_for_type::, "object")] + fn schema_for_type_sets_expected_root_type( + #[case] schema_fn: fn() -> Arc, + #[case] expected_type: &str, + ) { + let schema = schema_fn(); + + assert_eq!(schema.get("type"), Some(&serde_json::json!(expected_type))); } #[test] - fn test_schema_for_type_handles_array() { + fn schema_for_type_sets_array_item_type() { let schema = schema_for_type::>(); + let items = schema.get("items").and_then(|v| v.as_object()).unwrap(); - assert_eq!(schema.get("type"), Some(&serde_json::json!("array"))); - let items = schema.get("items").and_then(|v| v.as_object()); - assert_eq!( - items.unwrap().get("type"), - Some(&serde_json::json!("integer")) - ); + assert_eq!(items.get("type"), Some(&serde_json::json!("integer"))); } #[test] - fn test_schema_for_type_handles_struct() { + fn schema_for_type_sets_struct_properties() { let schema = schema_for_type::(); + let properties = schema + .get("properties") + .and_then(|v| v.as_object()) + .unwrap(); - assert_eq!(schema.get("type"), Some(&serde_json::json!("object"))); - let properties = schema.get("properties").and_then(|v| v.as_object()); - assert!(properties.unwrap().contains_key("value")); - } - - #[test] - fn test_schema_for_type_caches_primitive_types() { - let schema1 = schema_for_type::(); - let schema2 = schema_for_type::(); - - assert!(Arc::ptr_eq(&schema1, &schema2)); + assert!(properties.contains_key("value")); } - #[test] - fn test_schema_for_type_caches_struct_types() { - let schema1 = schema_for_type::(); - let schema2 = schema_for_type::(); + #[rstest] + #[case::primitive(schema_for_type::)] + #[case::struct_object(schema_for_type::)] + fn test_schema_for_type_caches_schemas(#[case] schema_fn: fn() -> Arc) { + let schema1 = schema_fn(); + let schema2 = schema_fn(); assert!(Arc::ptr_eq(&schema1, &schema2)); } @@ -305,51 +305,36 @@ mod tests { assert!(Arc::ptr_eq(&schema, &cloned)); } - #[test] - fn test_schema_for_output_rejects_primitive() { - let result = schema_for_output::(); - assert!(result.is_err(),); - } - - #[test] - fn test_schema_for_output_accepts_object() { - let result = schema_for_output::(); - assert!(result.is_ok(),); - } - - #[test] - fn test_schema_for_output_strips_top_level_title() { - let schema = schema_for_output::().unwrap(); - assert!(!schema.contains_key("title")); - } - - #[test] - fn test_schema_for_output_strips_top_level_description() { - let schema = schema_for_output::().unwrap(); - assert!(!schema.contains_key("description")); - } - - #[test] - fn test_schema_for_input_rejects_primitive() { - let result = schema_for_input::(); + #[rstest] + #[case::output(schema_for_output::)] + #[case::input(schema_for_input::)] + fn test_schema_for_object_wrappers_reject_primitives( + #[case] schema_fn: fn() -> Result, String>, + ) { + let result = schema_fn(); assert!(result.is_err()); } - #[test] - fn test_schema_for_input_accepts_object() { - let result = schema_for_input::(); + #[rstest] + #[case::output(schema_for_output::)] + #[case::input(schema_for_input::)] + fn test_schema_for_object_wrappers_accept_objects( + #[case] schema_fn: fn() -> Result, String>, + ) { + let result = schema_fn(); assert!(result.is_ok()); } - #[test] - fn test_schema_for_input_strips_top_level_title() { - let schema = schema_for_input::().unwrap(); - assert!(!schema.contains_key("title")); - } - - #[test] - fn test_schema_for_input_strips_top_level_description() { - let schema = schema_for_input::().unwrap(); - assert!(!schema.contains_key("description")); + #[rstest] + #[case::output_title(schema_for_output::, "title")] + #[case::output_description(schema_for_output::, "description")] + #[case::input_title(schema_for_input::, "title")] + #[case::input_description(schema_for_input::, "description")] + fn test_schema_for_object_wrappers_strip_top_level_metadata( + #[case] schema_fn: fn() -> Result, String>, + #[case] field: &str, + ) { + let schema = schema_fn().unwrap(); + assert!(!schema.contains_key(field)); } } diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index d2712f463..3867502d0 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -1701,49 +1701,70 @@ impl ElicitationSchemaBuilder { #[cfg(test)] mod tests { use anyhow::anyhow; + use rstest::rstest; use serde_json::json; use super::*; - #[test] - fn test_string_schema_serialization() { - let schema = StringSchema::email().description("Email address"); - let json = serde_json::to_value(&schema).unwrap(); - - assert_eq!(json["type"], "string"); - assert_eq!(json["format"], "email"); - assert_eq!(json["description"], "Email address"); + fn string_schema_json() -> serde_json::Value { + serde_json::to_value(StringSchema::email().description("Email address")).unwrap() } - #[test] - fn test_number_schema_serialization() { - let schema = NumberSchema::new() - .range(0.0, 100.0) - .description("Percentage"); - let json = serde_json::to_value(&schema).unwrap(); - - assert_eq!(json["type"], "number"); - assert_eq!(json["minimum"], 0.0); - assert_eq!(json["maximum"], 100.0); + fn number_schema_json() -> serde_json::Value { + serde_json::to_value( + NumberSchema::new() + .range(0.0, 100.0) + .description("Percentage"), + ) + .unwrap() } - #[test] - fn test_integer_schema_serialization() { - let schema = IntegerSchema::new().range(0, 150); - let json = serde_json::to_value(&schema).unwrap(); - - assert_eq!(json["type"], "integer"); - assert_eq!(json["minimum"], 0); - assert_eq!(json["maximum"], 150); + fn integer_schema_json() -> serde_json::Value { + serde_json::to_value(IntegerSchema::new().range(0, 150)).unwrap() } - #[test] - fn test_boolean_schema_serialization() { - let schema = BooleanSchema::new().with_default(true); - let json = serde_json::to_value(&schema).unwrap(); + fn boolean_schema_json() -> serde_json::Value { + serde_json::to_value(BooleanSchema::new().with_default(true)).unwrap() + } - assert_eq!(json["type"], "boolean"); - assert_eq!(json["default"], true); + #[rstest] + #[case::string_schema( + string_schema_json, + json!({ + "type": "string", + "format": "email", + "description": "Email address", + }) + )] + #[case::number_schema( + number_schema_json, + json!({ + "type": "number", + "description": "Percentage", + "minimum": 0.0, + "maximum": 100.0, + }) + )] + #[case::integer_schema( + integer_schema_json, + json!({ + "type": "integer", + "minimum": 0, + "maximum": 150, + }) + )] + #[case::boolean_schema( + boolean_schema_json, + json!({ + "type": "boolean", + "default": true, + }) + )] + fn primitive_schema_serializes_to_expected_json( + #[case] schema_json: fn() -> serde_json::Value, + #[case] expected: serde_json::Value, + ) { + assert_eq!(schema_json(), expected); } #[test] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index e2a8541e5..cd4176f66 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2975,6 +2975,7 @@ mod tests { }; use oauth2::{AuthType, CsrfToken, HttpResponse, PkceCodeVerifier}; + use rstest::rstest; use url::Url; use super::{ @@ -3348,44 +3349,35 @@ mod tests { // -- header value parsing -- - #[test] - fn parse_auth_param_value_handles_quoted_string() { - let fragment = r#""example", realm="foo""#; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, "example"); - assert_eq!(parsed.1, 9); - } - - #[test] - fn parse_auth_param_value_handles_escaped_quotes_and_whitespace() { - let fragment = r#" "a\"b\\c" ,next=value"#; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, r#"a"b\c"#); - assert_eq!(parsed.1, 12); - } - - #[test] - fn parse_auth_param_value_handles_token_values() { - let fragment = " token,next"; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, "token"); - assert_eq!(parsed.1, 7); - } - - #[test] - fn parse_auth_param_value_handles_semicolon_separated_tokens() { - let fragment = r#" https://example.com/meta; error="invalid_token""#; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, "https://example.com/meta"); - assert_eq!(&fragment[..parsed.1], " https://example.com/meta"); - } + #[rstest] + #[case::quoted_string(r#""example", realm="foo""#, "example", r#""example""#)] + #[case::escaped_quotes_and_whitespace( + r#" "a\"b\\c" ,next=value"#, + r#"a"b\c"#, + r#" "a\"b\\c""# + )] + #[case::token_values(" token,next", "token", " token")] + #[case::semicolon_separated_tokens( + r#" https://example.com/meta; error="invalid_token""#, + "https://example.com/meta", + " https://example.com/meta" + )] + #[case::semicolon_after_quoted_value( + r#" "https://example.com/meta"; error="invalid_token""#, + "https://example.com/meta", + r#" "https://example.com/meta""# + )] + fn parse_auth_param_value_handles_supported_values( + #[case] fragment: &str, + #[case] expected_value: &str, + #[case] expected_consumed_prefix: &str, + ) { + let (value, consumed) = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - #[test] - fn parse_auth_param_value_handles_semicolon_after_quoted_value() { - let fragment = r#" "https://example.com/meta"; error="invalid_token""#; - let parsed = AuthorizationManager::parse_next_header_value(fragment).unwrap(); - assert_eq!(parsed.0, "https://example.com/meta"); - assert_eq!(&fragment[..parsed.1], r#" "https://example.com/meta""#); + assert_eq!( + (value.as_str(), &fragment[..consumed]), + (expected_value, expected_consumed_prefix) + ); } #[test] @@ -4010,104 +4002,113 @@ mod tests { )); } - #[test] - fn validate_authorization_response_issuer_accepts_match_and_missing_issuer() { - let pkce = PkceCodeVerifier::new("verifier".to_string()); - let csrf = CsrfToken::new("csrf".to_string()); - let state = StoredAuthorizationState::new_with_expected_issuer( - &pkce, - &csrf, - Some("https://auth.example.com".to_string()), - false, - ); - - assert!( - AuthorizationManager::validate_authorization_response_issuer( - &state, - Some("https://auth.example.com") - ) - .is_ok() - ); - assert!(AuthorizationManager::validate_authorization_response_issuer(&state, None).is_ok()); - } - - #[test] - fn validate_authorization_response_issuer_requires_issuer_when_advertised() { + #[rstest] + #[case::matching_issuer( + Some("https://auth.example.com"), + false, + Some("https://auth.example.com") + )] + #[case::missing_issuer_when_not_required(Some("https://auth.example.com"), false, None)] + fn validate_authorization_response_issuer_accepts_valid_cases( + #[case] expected_issuer: Option<&str>, + #[case] require_issuer: bool, + #[case] received_issuer: Option<&str>, + ) { let pkce = PkceCodeVerifier::new("verifier".to_string()); let csrf = CsrfToken::new("csrf".to_string()); let state = StoredAuthorizationState::new_with_expected_issuer( &pkce, &csrf, - Some("https://auth.example.com".to_string()), - true, + expected_issuer.map(str::to_owned), + require_issuer, ); - let error = - AuthorizationManager::validate_authorization_response_issuer(&state, None).unwrap_err(); - - assert!(matches!( - error, - AuthError::AuthorizationServerMissingIssuer { expected_issuer } - if expected_issuer == "https://auth.example.com" - )); - } - - #[test] - fn validate_authorization_response_issuer_rejects_present_issuer_without_expected_issuer() { - let pkce = PkceCodeVerifier::new("verifier".to_string()); - let csrf = CsrfToken::new("csrf".to_string()); - let state = StoredAuthorizationState::new_with_expected_issuer(&pkce, &csrf, None, false); - - let error = AuthorizationManager::validate_authorization_response_issuer( - &state, - Some("https://auth.example.com"), - ) - .unwrap_err(); - assert!( - matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded")) + AuthorizationManager::validate_authorization_response_issuer(&state, received_issuer) + .is_ok() ); } - #[test] - fn validate_authorization_response_issuer_rejects_required_issuer_without_expected_issuer() { - let pkce = PkceCodeVerifier::new("verifier".to_string()); - let csrf = CsrfToken::new("csrf".to_string()); - let state = StoredAuthorizationState::new_with_expected_issuer(&pkce, &csrf, None, true); - - let error = - AuthorizationManager::validate_authorization_response_issuer(&state, None).unwrap_err(); - - assert!( - matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded")) - ); + #[derive(Clone, Copy, Debug)] + enum ExpectedIssuerError { + Missing { + expected_issuer: &'static str, + }, + NotRecorded, + Mismatch { + expected_issuer: &'static str, + received_issuer: &'static str, + }, + } + + fn assert_expected_issuer_error(error: AuthError, expected: ExpectedIssuerError) { + match expected { + ExpectedIssuerError::Missing { expected_issuer } => assert!(matches!( + error, + AuthError::AuthorizationServerMissingIssuer { expected_issuer: actual } + if actual == expected_issuer + )), + ExpectedIssuerError::NotRecorded => assert!( + matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded")) + ), + ExpectedIssuerError::Mismatch { + expected_issuer, + received_issuer, + } => assert!(matches!( + error, + AuthError::AuthorizationServerMismatch { + expected_issuer: actual_expected, + received_issuer: actual_received + } if actual_expected == expected_issuer && actual_received == received_issuer + )), + } } - #[test] - fn validate_authorization_response_issuer_rejects_mismatch() { + #[rstest] + #[case::requires_advertised_issuer( + Some("https://auth.example.com"), + true, + None, + ExpectedIssuerError::Missing { + expected_issuer: "https://auth.example.com", + } + )] + #[case::present_issuer_without_expected( + None, + false, + Some("https://auth.example.com"), + ExpectedIssuerError::NotRecorded + )] + #[case::required_issuer_without_expected(None, true, None, ExpectedIssuerError::NotRecorded)] + #[case::mismatched_issuer( + Some("https://auth.example.com"), + false, + Some("https://evil.example.com"), + ExpectedIssuerError::Mismatch { + expected_issuer: "https://auth.example.com", + received_issuer: "https://evil.example.com", + } + )] + fn validate_authorization_response_issuer_rejects_invalid_cases( + #[case] expected_issuer: Option<&str>, + #[case] require_issuer: bool, + #[case] received_issuer: Option<&str>, + #[case] expected_error: ExpectedIssuerError, + ) { let pkce = PkceCodeVerifier::new("verifier".to_string()); let csrf = CsrfToken::new("csrf".to_string()); let state = StoredAuthorizationState::new_with_expected_issuer( &pkce, &csrf, - Some("https://auth.example.com".to_string()), - false, + expected_issuer.map(str::to_owned), + require_issuer, ); - let error = AuthorizationManager::validate_authorization_response_issuer( - &state, - Some("https://evil.example.com"), - ) - .unwrap_err(); + let error = + AuthorizationManager::validate_authorization_response_issuer(&state, received_issuer) + .unwrap_err(); - assert!(matches!( - error, - AuthError::AuthorizationServerMismatch { - expected_issuer, - received_issuer - } if expected_issuer == "https://auth.example.com" - && received_issuer == "https://evil.example.com" - )); + assert_expected_issuer_error(error, expected_error); } #[tokio::test] @@ -4642,62 +4643,40 @@ mod tests { ); } - #[tokio::test] - async fn validate_client_credentials_metadata_accepts_supported_method() { - let mut additional_fields = HashMap::new(); - additional_fields.insert( - "token_endpoint_auth_methods_supported".to_string(), - serde_json::json!(["client_secret_post", "client_secret_basic"]), - ); - let meta = AuthorizationMetadata { - authorization_endpoint: "http://localhost/authorize".to_string(), - token_endpoint: "http://localhost/token".to_string(), - additional_fields, - ..Default::default() - }; - let mgr = manager_with_metadata(Some(meta)).await; - let config = super::ClientCredentialsConfig::ClientSecret { - client_id: "id".to_string(), - client_secret: "secret".to_string(), - scopes: vec![], - resource: None, - }; - mgr.validate_client_credentials_metadata(&config).unwrap(); - } - - #[tokio::test] - async fn validate_client_credentials_metadata_permits_when_field_absent() { - let mgr = manager_with_metadata(None).await; - let config = super::ClientCredentialsConfig::ClientSecret { + fn client_secret_credentials_config() -> super::ClientCredentialsConfig { + super::ClientCredentialsConfig::ClientSecret { client_id: "id".to_string(), client_secret: "secret".to_string(), scopes: vec![], resource: None, - }; - mgr.validate_client_credentials_metadata(&config).unwrap(); + } } - #[tokio::test] - async fn validate_client_credentials_metadata_accepts_client_secret_basic_only() { + fn metadata_with_auth_methods(methods: serde_json::Value) -> AuthorizationMetadata { let mut additional_fields = HashMap::new(); - additional_fields.insert( - "token_endpoint_auth_methods_supported".to_string(), - serde_json::json!(["client_secret_basic"]), - ); - let meta = AuthorizationMetadata { + additional_fields.insert("token_endpoint_auth_methods_supported".to_string(), methods); + AuthorizationMetadata { authorization_endpoint: "http://localhost/authorize".to_string(), token_endpoint: "http://localhost/token".to_string(), additional_fields, ..Default::default() - }; - let mgr = manager_with_metadata(Some(meta)).await; - let config = super::ClientCredentialsConfig::ClientSecret { - client_id: "id".to_string(), - client_secret: "secret".to_string(), - scopes: vec![], - resource: None, - }; - // A server advertising only client_secret_basic must be accepted. + } + } + + #[rstest] + #[case::supported_methods(Some(serde_json::json!([ + "client_secret_post", + "client_secret_basic" + ])))] + #[case::field_absent(None)] + #[case::client_secret_basic_only(Some(serde_json::json!(["client_secret_basic"])))] + #[tokio::test] + async fn validate_client_credentials_metadata_accepts_supported_configurations( + #[case] auth_methods: Option, + ) { + let mgr = manager_with_metadata(auth_methods.map(metadata_with_auth_methods)).await; + let config = client_secret_credentials_config(); + mgr.validate_client_credentials_metadata(&config).unwrap(); } diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index e6d4943db..fffcd3933 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -309,6 +309,8 @@ impl StreamableHttpClientTransport { #[cfg(test)] mod tests { + use rstest::rstest; + use super::parse_json_rpc_error; use crate::{ model::JsonRpcMessage, @@ -346,25 +348,15 @@ mod tests { )); } - #[test] - fn parse_json_rpc_error_rejects_non_error_request() { - // A valid JSON-RPC request (method + id) must not be accepted as an error. - let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; - assert!(parse_json_rpc_error(body).is_none()); - } - - #[test] - fn parse_json_rpc_error_rejects_notification() { - // A notification (method, no id) must not be accepted as an error. - let body = - r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"#; + #[rstest] + #[case::non_error_request(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#)] + #[case::notification( + r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"# + )] + #[case::plain_text("not json at all")] + #[case::empty("")] + #[case::truncated_json(r#"{"broken":"#)] + fn parse_json_rpc_error_rejects_non_error_bodies(#[case] body: &str) { assert!(parse_json_rpc_error(body).is_none()); } - - #[test] - fn parse_json_rpc_error_rejects_malformed_json() { - assert!(parse_json_rpc_error("not json at all").is_none()); - assert!(parse_json_rpc_error("").is_none()); - assert!(parse_json_rpc_error(r#"{"broken":"#).is_none()); - } } diff --git a/crates/rmcp/tests/test_inflight_response_drain.rs b/crates/rmcp/tests/test_inflight_response_drain.rs index 8af62ba53..c75e17c42 100644 --- a/crates/rmcp/tests/test_inflight_response_drain.rs +++ b/crates/rmcp/tests/test_inflight_response_drain.rs @@ -23,6 +23,7 @@ use tokio::io::{AsyncRead, ReadBuf}; // A slow tool server that sleeps before returning a response. #[derive(Debug, Clone)] struct SlowToolServer { + #[expect(dead_code, reason = "tool_handler macro accesses this router field")] tool_router: ToolRouter, } diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index db4cf33f8..073396ee7 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -12,7 +12,7 @@ use serde_json::json; use tokio::sync::{Mutex, Notify}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -pub struct Server {} +struct Server {} impl ServerHandler for Server { fn get_info(&self) -> ServerInfo { diff --git a/crates/rmcp/tests/test_progress_subscriber.rs b/crates/rmcp/tests/test_progress_subscriber.rs index 5df8a7b71..18f91c218 100644 --- a/crates/rmcp/tests/test_progress_subscriber.rs +++ b/crates/rmcp/tests/test_progress_subscriber.rs @@ -39,6 +39,7 @@ impl ClientHandler for MyClient { } pub struct MyServer { + #[expect(dead_code, reason = "tool_handler macro accesses this router field")] tool_router: ToolRouter, } diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs index e8d9d7dc0..7a00249a4 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -17,9 +17,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, JsonSchema)] -pub struct CodeReviewRequest { - pub file_path: String, - pub language: String, +struct CodeReviewRequest { + file_path: String, + language: String, } #[prompt_handler(router = self.prompt_router)] @@ -194,17 +194,17 @@ impl CodeReviewRequest {} // Struct defined for testing optional field schema generation #[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct OptionalFieldTestSchema { +struct OptionalFieldTestSchema { #[schemars(description = "An optional description field")] - pub description: Option, + description: Option, } // Struct defined for testing optional i64 field schema generation and null handling #[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct OptionalI64TestSchema { +struct OptionalI64TestSchema { #[schemars(description = "An optional i64 field")] - pub count: Option, - pub mandatory_field: String, // Added to ensure non-empty object schema + count: Option, + mandatory_field: String, // Added to ensure non-empty object schema } // Dummy struct to host the test prompt method diff --git a/crates/rmcp/tests/test_prompt_routers.rs b/crates/rmcp/tests/test_prompt_routers.rs index 68b265db2..eecdb1e4a 100644 --- a/crates/rmcp/tests/test_prompt_routers.rs +++ b/crates/rmcp/tests/test_prompt_routers.rs @@ -9,21 +9,21 @@ use rmcp::{ }; #[derive(Debug, Default)] -pub struct TestHandler { - pub _marker: std::marker::PhantomData, +struct TestHandler { + _marker: std::marker::PhantomData, } impl ServerHandler for TestHandler {} #[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -pub struct Request { - pub fields: HashMap, +struct Request { + fields: HashMap, } #[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -pub struct Sum { - pub a: i32, - pub b: i32, +struct Sum { + a: i32, + b: i32, } #[rmcp::prompt_router(router = "test_router")] diff --git a/crates/rmcp/tests/test_task_support_validation.rs b/crates/rmcp/tests/test_task_support_validation.rs index 773f759f9..41d03f031 100644 --- a/crates/rmcp/tests/test_task_support_validation.rs +++ b/crates/rmcp/tests/test_task_support_validation.rs @@ -18,6 +18,7 @@ use rmcp::{ /// Server with tools having different task support modes. #[derive(Debug, Clone)] pub struct TaskSupportTestServer { + #[expect(dead_code, reason = "tool_handler macro accesses this router field")] tool_router: ToolRouter, } diff --git a/crates/rmcp/tests/test_tool_disable_notification.rs b/crates/rmcp/tests/test_tool_disable_notification.rs index 84037b59a..b30a58e18 100644 --- a/crates/rmcp/tests/test_tool_disable_notification.rs +++ b/crates/rmcp/tests/test_tool_disable_notification.rs @@ -45,33 +45,26 @@ impl ServerHandler for TestToolServer { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } - fn call_tool( + async fn call_tool( &self, request: rmcp::model::CallToolRequestParams, context: rmcp::service::RequestContext, - ) -> impl std::future::Future> + MaybeSendFuture + '_ - { - async move { - let router = self.router.read().await; - let tcc = ToolCallContext::new(self, request, context); - router.call(tcc).await - } + ) -> Result { + let router = self.router.read().await; + let tcc = ToolCallContext::new(self, request, context); + router.call(tcc).await } - fn list_tools( + async fn list_tools( &self, _request: Option, _context: rmcp::service::RequestContext, - ) -> impl std::future::Future> - + MaybeSendFuture - + '_ { - async move { - let router = self.router.read().await; - Ok(rmcp::model::ListToolsResult { - tools: router.list_all(), - ..Default::default() - }) - } + ) -> Result { + let router = self.router.read().await; + Ok(rmcp::model::ListToolsResult { + tools: router.list_all(), + ..Default::default() + }) } fn on_initialized( diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index ed2e697a2..89846fa79 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -18,11 +18,11 @@ use serde::{Deserialize, Serialize}; /// Parameters for weather tool. #[derive(Serialize, Deserialize, JsonSchema)] -pub struct GetWeatherRequest { +struct GetWeatherRequest { /// City of interest. - pub city: String, + city: String, /// Date of interest. - pub date: String, + date: String, } #[tool_handler(router = self.tool_router)] @@ -162,21 +162,21 @@ impl GetWeatherRequest {} /// Struct defined for testing optional field schema generation. #[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct OptionalFieldTestSchema { +struct OptionalFieldTestSchema { /// Field description. #[schemars(description = "An optional description field")] - pub description: Option, + description: Option, } /// Struct defined for testing optional i64 field schema generation and null handling. #[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct OptionalI64TestSchema { +struct OptionalI64TestSchema { /// Optional count field. #[schemars(description = "An optional i64 field")] - pub count: Option, + count: Option, /// Added to ensure non-empty object schema. - pub mandatory_field: String, + mandatory_field: String, } /// Dummy struct to host the test tool method. @@ -370,7 +370,7 @@ async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> { /// Minimal server: no tool_router field, no new(), no get_info(). #[derive(Debug, Clone)] -pub struct MinimalServer; +struct MinimalServer; #[tool_router] impl MinimalServer { @@ -452,7 +452,7 @@ async fn test_minimal_server_tool_call() -> anyhow::Result<()> { /// Same minimal pattern as [`MinimalServer`], but `#[tool_handler]` is omitted using /// `#[tool_router(server_handler)]` (emits `#[tool_handler]` for a second macro pass). #[derive(Debug, Clone)] -pub struct ElidedToolHandlerServer; +struct ElidedToolHandlerServer; #[tool_router(server_handler)] impl ElidedToolHandlerServer { @@ -509,7 +509,7 @@ async fn test_tool_router_server_handler_flag_end_to_end_tool_call() -> anyhow:: /// Server with custom name/version/instructions via tool_handler attributes. #[derive(Debug, Clone)] -pub struct CustomInfoServer; +struct CustomInfoServer; #[tool_router] impl CustomInfoServer { @@ -539,7 +539,7 @@ fn test_custom_info_server() { /// Server that provides its own get_info() — macro should not override it. #[derive(Debug, Clone)] -pub struct ManualInfoServer; +struct ManualInfoServer; #[tool_router] impl ManualInfoServer { diff --git a/crates/rmcp/tests/test_tool_routers.rs b/crates/rmcp/tests/test_tool_routers.rs index f2e28b0f3..d2bbe8687 100644 --- a/crates/rmcp/tests/test_tool_routers.rs +++ b/crates/rmcp/tests/test_tool_routers.rs @@ -11,20 +11,20 @@ use rmcp::{ }; #[derive(Debug, Default)] -pub struct TestHandler { - pub _marker: std::marker::PhantomData, +struct TestHandler { + _marker: std::marker::PhantomData, } impl ServerHandler for TestHandler {} #[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -pub struct Request { - pub fields: HashMap, +struct Request { + fields: HashMap, } #[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -pub struct Sum { - pub a: i32, - pub b: i32, +struct Sum { + a: i32, + b: i32, } #[rmcp::tool_router(router = test_router_1)] From dfa7fd6f9309deab60bea230b041be9a3fcda846 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:09:16 -0400 Subject: [PATCH 198/333] fix: prevent streamable HTTP session leak (#934) --- .../transport/streamable_http_server/tower.rs | 58 +++++++++---------- .../test_streamable_http_protocol_version.rs | 40 ++++++++++++- 2 files changed, 66 insertions(+), 32 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index cd2f5f1e5..8ebec4e5b 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -1124,44 +1124,40 @@ where } } } else { - let (session_id, transport) = self - .session_manager - .create_session() - .await - .map_err(internal_error_response("create session"))?; // Capture init params for external store persistence before // extensions are injected (which would require Clone). - let stored_init_params = if self.config.session_store.is_some() { - if let ClientJsonRpcMessage::Request(req) = &message { - if let ClientRequest::InitializeRequest(init_req) = &req.request { - Some(init_req.params.clone()) - } else { - None - } - } else { - None + let stored_init_params = match &mut message { + ClientJsonRpcMessage::Request(req) => { + let ClientRequest::InitializeRequest(init_req) = &req.request else { + return Err(unexpected_message_response("initialize request")); + }; + // Reject mismatched MCP-Protocol-Version header before binding the session to anything. + validate_header_matches_init_body( + &part.headers, + init_req.params.protocol_version.as_str(), + Some(req.id.clone()), + )?; + let stored_init_params = self + .config + .session_store + .as_ref() + .map(|_| init_req.params.clone()); + // inject request part to extensions + req.request.extensions_mut().insert(part); + stored_init_params } - } else { - None - }; - if let ClientJsonRpcMessage::Request(req) = &mut message { - let ClientRequest::InitializeRequest(init_req) = &req.request else { + _ => { return Err(unexpected_message_response("initialize request")); - }; - // Reject mismatched MCP-Protocol-Version header before binding the session to anything. - validate_header_matches_init_body( - &part.headers, - init_req.params.protocol_version.as_str(), - Some(req.id.clone()), - )?; - // inject request part to extensions - req.request.extensions_mut().insert(part); - } else { - return Err(unexpected_message_response("initialize request")); - } + } + }; let service = self .get_service() .map_err(internal_error_response("get service"))?; + let (session_id, transport) = self + .session_manager + .create_session() + .await + .map_err(internal_error_response("create session"))?; // spawn a task to serve the session Self::spawn_session_worker( self.session_manager.clone(), diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index 3500266b9..0ed61c0e6 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -1,5 +1,7 @@ #![cfg(not(feature = "local"))] //! Regression tests for the `MCP-Protocol-Version` header / initialize body consistency check. +use std::sync::Arc; + use rmcp::transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }; @@ -16,10 +18,17 @@ fn init_body(body_version: &str) -> String { async fn spawn_server( config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + spawn_server_with_manager(config, Arc::new(LocalSessionManager::default())).await +} + +async fn spawn_server_with_manager( + config: StreamableHttpServerConfig, + session_manager: Arc, ) -> (reqwest::Client, String, CancellationToken) { let ct = config.cancellation_token.clone(); let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config); + StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, config); let router = axum::Router::new().nest_service("/mcp", service); let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -71,6 +80,17 @@ async fn post_init( req.send().await.expect("send initialize request") } +async fn post_non_initialize(client: &reqwest::Client, url: &str) -> reqwest::Response { + client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) + .send() + .await + .expect("send non-initialize request") +} + #[tokio::test] async fn stateless_init_rejects_when_header_older_than_body() -> anyhow::Result<()> { let (client, url, ct) = spawn_server(stateless_json_config()).await; @@ -147,3 +167,21 @@ async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<( ct.cancel(); Ok(()) } + +#[tokio::test] +async fn stateful_rejected_initial_posts_do_not_create_sessions() -> anyhow::Result<()> { + let session_manager = Arc::new(LocalSessionManager::default()); + let (client, url, ct) = + spawn_server_with_manager(stateful_config(), session_manager.clone()).await; + + let response = post_non_initialize(&client, &url).await; + assert_eq!(response.status(), 422); + assert_eq!(session_manager.sessions.read().await.len(), 0); + + let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await; + assert_eq!(response.status(), 400); + assert_eq!(session_manager.sessions.read().await.len(), 0); + + ct.cancel(); + Ok(()) +} From eb435c6f51864df74926ef115befce55c16b01ba Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:37:12 -0400 Subject: [PATCH 199/333] fix: block oauth metadata ssrf (#935) * fix: block oauth metadata ssrf * fix: warn on oauth metadata blocks --- crates/rmcp/src/transport/auth.rs | 324 ++++++++++++++++++++++++++++-- 1 file changed, 302 insertions(+), 22 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index cd4176f66..db1f35360 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1,6 +1,7 @@ use std::{ collections::HashMap, future::Future, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, pin::Pin, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, @@ -16,7 +17,7 @@ use oauth2::{ }; use reqwest::{ Client as ReqwestClient, IntoUrl, StatusCode, Url, - header::{AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE}, + header::{AUTHORIZATION, CONTENT_TYPE, LOCATION, WWW_AUTHENTICATE}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -28,6 +29,12 @@ use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024; +const MAX_OAUTH_DISCOVERY_REDIRECTS: usize = 10; +const CLOUD_METADATA_HOSTS: &[&str] = &[ + "metadata", + "metadata.google.internal", + "metadata.azure.internal", +]; /// Redirect handling requested for an outbound OAuth HTTP operation. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -821,6 +828,100 @@ fn is_https_url(value: &str) -> bool { } impl AuthorizationManager { + fn is_http_url(url: &Url) -> bool { + matches!(url.scheme(), "http" | "https") && url.host_str().is_some() + } + + fn is_same_origin(base: &Url, candidate: &Url) -> bool { + base.scheme() == candidate.scheme() + && base + .host_str() + .zip(candidate.host_str()) + .is_some_and(|(base, candidate)| base.eq_ignore_ascii_case(candidate)) + && base.port_or_known_default() == candidate.port_or_known_default() + } + + fn is_same_origin_resource_metadata_url(base_url: &Url, candidate: &Url) -> bool { + Self::is_http_url(candidate) && Self::is_same_origin(base_url, candidate) + } + + fn is_disallowed_metadata_ipv4(addr: Ipv4Addr) -> bool { + let octets = addr.octets(); + addr.is_private() + || addr.is_loopback() + || addr.is_link_local() + || addr.is_broadcast() + || addr.is_unspecified() + || addr.is_multicast() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 198 && matches!(octets[1], 18 | 19)) + } + + fn is_disallowed_metadata_ipv6(addr: Ipv6Addr) -> bool { + if let Some(mapped) = addr.to_ipv4_mapped() { + return Self::is_disallowed_metadata_ipv4(mapped); + } + + let segments = addr.segments(); + addr.is_loopback() + || addr.is_unspecified() + || addr.is_multicast() + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] & 0xfe00) == 0xfc00 + } + + fn is_disallowed_metadata_hostname(host: &str) -> bool { + matches!(host, "localhost") + || host.ends_with(".localhost") + || CLOUD_METADATA_HOSTS.contains(&host) + } + + fn is_disallowed_metadata_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if Self::is_disallowed_metadata_hostname(&host) { + return true; + } + + match host.parse::() { + Ok(IpAddr::V4(addr)) => Self::is_disallowed_metadata_ipv4(addr), + Ok(IpAddr::V6(addr)) => Self::is_disallowed_metadata_ipv6(addr), + Err(_) => false, + } + } + + fn is_allowed_authorization_server_metadata_url(url: &Url) -> bool { + Self::is_http_url(url) + && url + .host_str() + .is_some_and(|host| !Self::is_disallowed_metadata_host(host)) + } + + fn resolve_resource_metadata_url(value: &str, base_url: &Url) -> Option { + let value = value.trim(); + if value.is_empty() { + debug!("ignoring empty resource_metadata value"); + return None; + } + + let url = match Url::parse(value).or_else(|_| base_url.join(value)) { + Ok(url) => url, + Err(error) => { + debug!("failed to parse resource metadata value `{value}` as URL: {error}"); + return None; + } + }; + + if Self::is_same_origin_resource_metadata_url(base_url, &url) { + Some(url) + } else { + warn!( + "rejecting resource metadata URL `{url}` because it is not same-origin with `{base_url}`" + ); + None + } + } + fn well_known_paths(base_path: &str, resource: &str) -> Vec { let trimmed = base_path.trim_start_matches('/').trim_end_matches('/'); let mut candidates = Vec::new(); @@ -1771,6 +1872,11 @@ impl AuthorizationManager { }, }; + if !Self::is_allowed_authorization_server_metadata_url(&candidate_url) { + warn!("rejecting authorization server metadata URL `{candidate_url}`"); + continue; + } + if candidate_url.path().contains("/.well-known/") { if let Some(metadata) = self.fetch_authorization_metadata(&candidate_url).await? { return Ok(Some(metadata)); @@ -1889,18 +1995,49 @@ impl AuthorizationManager { } async fn discovery_get(&self, url: &Url) -> Result { - let request = oauth2::http::Request::builder() - .method("GET") - .uri(url.as_str()) - .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") - .body(Vec::new()) - .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; - self.http_client - .execute(OAuthHttpRequest::new( - request, - OAuthHttpRedirectPolicy::Follow, - )) - .await + let mut current_url = url.clone(); + for _ in 0..MAX_OAUTH_DISCOVERY_REDIRECTS { + let request = oauth2::http::Request::builder() + .method("GET") + .uri(current_url.as_str()) + .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") + .body(Vec::new()) + .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + let response = self + .http_client + .execute(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Stop, + )) + .await?; + + if !response.status().is_redirection() { + return Ok(response); + } + + let Some(location) = response.headers().get(LOCATION) else { + return Ok(response); + }; + let location = location + .to_str() + .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + let next_url = current_url + .join(location) + .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + + if Self::is_http_url(&next_url) && Self::is_same_origin(¤t_url, &next_url) { + current_url = next_url; + continue; + } + + return Err(OAuthHttpClientError::new(format!( + "OAuth discovery redirect to non-same-origin URL rejected: {next_url}" + ))); + } + + Err(OAuthHttpClientError::new(format!( + "OAuth discovery exceeded {MAX_OAUTH_DISCOVERY_REDIRECTS} redirects" + ))) } /// extract parameters from WWW-Authenticate header (resource_metadata and scope) @@ -1915,15 +2052,10 @@ impl AuthorizationManager { let global_pos = search_offset + pos + resource_key.len(); let value_slice = &header[global_pos..]; if let Some((value, consumed)) = Self::parse_next_header_value(value_slice) { - if let Ok(url) = Url::parse(&value) { + if let Some(url) = Self::resolve_resource_metadata_url(&value, base_url) { params.resource_metadata_url = Some(url); break; } - if let Ok(url) = base_url.join(&value) { - params.resource_metadata_url = Some(url); - break; - } - debug!("failed to parse resource metadata value `{value}` as URL"); search_offset = global_pos + consumed; continue; } else { @@ -3035,6 +3167,14 @@ mod tests { .unwrap() } + fn redirect_response(location: &str) -> HttpResponse { + oauth2::http::Response::builder() + .status(302) + .header("location", location) + .body(Vec::new()) + .unwrap() + } + #[tokio::test] async fn custom_http_client_handles_protected_resource_discovery() { let challenge = oauth2::http::Response::builder() @@ -3077,26 +3217,147 @@ mod tests { RecordedOAuthRequest { method: "GET".to_string(), uri: "https://mcp.example.com/mcp".to_string(), - redirect_policy: OAuthHttpRedirectPolicy::Follow, + redirect_policy: OAuthHttpRedirectPolicy::Stop, body: Vec::new(), }, RecordedOAuthRequest { method: "GET".to_string(), uri: "https://mcp.example.com/.well-known/oauth-protected-resource".to_string(), - redirect_policy: OAuthHttpRedirectPolicy::Follow, + redirect_policy: OAuthHttpRedirectPolicy::Stop, body: Vec::new(), }, RecordedOAuthRequest { method: "GET".to_string(), uri: "https://auth.example.com/.well-known/oauth-authorization-server" .to_string(), - redirect_policy: OAuthHttpRedirectPolicy::Follow, + redirect_policy: OAuthHttpRedirectPolicy::Stop, body: Vec::new(), }, ] ); } + #[tokio::test] + async fn discovery_get_follows_same_origin_redirects() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + redirect_response("/redirected"), + http_response(200, serde_json::json!({})), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let response = manager + .discovery_get(&Url::parse("https://mcp.example.com/start").unwrap()) + .await + .unwrap(); + let requests = client.requests(); + + assert_eq!( + ( + response.status(), + requests + .iter() + .map(|request| request.uri.as_str()) + .collect::>() + ), + ( + oauth2::http::StatusCode::OK, + vec![ + "https://mcp.example.com/start", + "https://mcp.example.com/redirected" + ] + ) + ); + } + + #[tokio::test] + async fn discovery_get_rejects_cross_origin_redirects() { + let client = RecordingOAuthHttpClient::with_responses(vec![redirect_response( + "http://169.254.169.254/", + )]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let err = manager + .discovery_get(&Url::parse("https://mcp.example.com/start").unwrap()) + .await + .unwrap_err(); + + assert_eq!( + ( + err.to_string().contains("non-same-origin"), + client.requests().len() + ), + (true, 1) + ); + } + + #[tokio::test] + async fn protected_resource_metadata_rejects_private_authorization_server_urls() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "authorization_servers": [ + "http://169.254.169.254/latest/meta-data/", + "https://auth.example.com" + ] + }), + ), + http_response( + 200, + serde_json::json!({ + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.discover_metadata().await.unwrap(); + let requests = client.requests(); + + assert_eq!( + ( + metadata.token_endpoint.as_str(), + requests + .iter() + .map(|request| request.uri.as_str()) + .collect::>() + ), + ( + "https://auth.example.com/token", + vec![ + "https://mcp.example.com/mcp", + "https://mcp.example.com/.well-known/oauth-protected-resource", + "https://auth.example.com/.well-known/oauth-authorization-server" + ] + ) + ); + } + #[tokio::test] async fn custom_http_client_handles_registration_exchange_and_refresh() { let client = RecordingOAuthHttpClient::with_responses(vec![ @@ -3410,6 +3671,25 @@ mod tests { ); } + #[test] + fn rejects_cross_origin_resource_metadata_parameter() { + let header = r#"Bearer error="invalid_request", resource_metadata="http://169.254.169.254/latest/meta-data/", scope="read""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + assert_eq!(params.scope.unwrap(), "read"); + } + + #[test] + fn rejects_non_http_resource_metadata_parameter() { + let header = r#"Bearer resource_metadata="file:///etc/passwd""#; + let base = Url::parse("https://example.com/api").unwrap(); + let params = AuthorizationManager::extract_www_authenticate_params(header, &base); + + assert!(params.resource_metadata_url.is_none()); + } + #[test] fn extract_www_authenticate_params_with_all_fields() { let header = r#"Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource", scope="read:data write:data", error_description="token expired""#; From c1a8b29ff2cc45e7820b900dae42cbb4958089ec Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:43:46 -0400 Subject: [PATCH 200/333] fix: prevent OAuth resource spoofing (#937) --- crates/rmcp/src/transport/auth.rs | 130 ++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index db1f35360..c4c038722 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -542,6 +542,7 @@ pub struct AuthorizationMetadata { #[derive(Debug, Clone, Deserialize)] struct ResourceServerMetadata { + resource: Option, authorization_server: Option, authorization_servers: Option>, scopes_supported: Option>, @@ -1839,6 +1840,8 @@ impl AuthorizationManager { return Ok(None); }; + self.validate_resource_metadata_resource(&resource_metadata)?; + // store scopes_supported from protected resource metadata for select_scopes() if let Some(scopes) = resource_metadata.scopes_supported { if !scopes.is_empty() { @@ -1892,6 +1895,39 @@ impl AuthorizationManager { Ok(None) } + fn validate_resource_metadata_resource( + &self, + metadata: &ResourceServerMetadata, + ) -> Result<(), AuthError> { + let Some(resource) = metadata.resource.as_deref() else { + return Err(AuthError::MetadataError( + "Protected resource metadata missing required resource field".to_string(), + )); + }; + + if !Self::resource_identifiers_match(self.base_url.as_str(), resource) { + return Err(AuthError::MetadataError(format!( + "Protected resource metadata resource mismatch: expected '{}', got '{}'", + self.base_url, resource + ))); + } + + Ok(()) + } + + fn resource_identifiers_match(expected: &str, actual: &str) -> bool { + expected == actual + || (Self::is_root_resource_identifier(expected) + && actual == expected.trim_end_matches('/')) + || (Self::is_root_resource_identifier(actual) + && expected == actual.trim_end_matches('/')) + } + + fn is_root_resource_identifier(value: &str) -> bool { + Url::parse(value) + .is_ok_and(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none()) + } + async fn discover_resource_metadata_url(&self) -> Result, AuthError> { if let Ok(Some(resource_metadata_url)) = self.fetch_resource_metadata_url(&self.base_url).await @@ -3190,6 +3226,7 @@ mod tests { http_response( 200, serde_json::json!({ + "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com"] }), ), @@ -3315,6 +3352,7 @@ mod tests { http_response( 200, serde_json::json!({ + "resource": "https://mcp.example.com/mcp", "authorization_servers": [ "http://169.254.169.254/latest/meta-data/", "https://auth.example.com" @@ -3358,6 +3396,98 @@ mod tests { ); } + #[tokio::test] + async fn protected_resource_discovery_rejects_mismatched_resource() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://real.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let error = manager.discover_metadata().await.unwrap_err(); + + assert!( + matches!(error, AuthError::MetadataError(ref message) if message.contains("resource mismatch")), + "expected resource mismatch metadata error, got: {error:?}" + ); + assert_eq!(client.requests().len(), 2); + } + + #[tokio::test] + async fn protected_resource_discovery_rejects_missing_resource() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let error = manager.discover_metadata().await.unwrap_err(); + + assert!( + matches!(error, AuthError::MetadataError(ref message) if message.contains("missing required resource")), + "expected missing resource metadata error, got: {error:?}" + ); + assert_eq!(client.requests().len(), 2); + } + + #[test] + fn resource_identifier_matching_allows_only_root_trailing_slash_difference() { + assert!(AuthorizationManager::resource_identifiers_match( + "https://mcp.example.com/", + "https://mcp.example.com" + )); + assert!(AuthorizationManager::resource_identifiers_match( + "https://mcp.example.com", + "https://mcp.example.com/" + )); + + assert!(!AuthorizationManager::resource_identifiers_match( + "https://mcp.example.com/mcp", + "https://mcp.example.com/mcp/" + )); + assert!(!AuthorizationManager::resource_identifiers_match( + "https://mcp.example.com/mcp", + "https://real.example.com/mcp" + )); + } + #[tokio::test] async fn custom_http_client_handles_registration_exchange_and_refresh() { let client = RecordingOAuthHttpClient::with_responses(vec![ From 67a30859443ab0fe79f2d50307c7d7bc9518f7e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:24:11 -0400 Subject: [PATCH 201/333] chore: release v2.0.0 (#920) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 14 ++++++++++++++ crates/rmcp/CHANGELOG.md | 22 ++++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4a112a94e..f60c9868f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "1.8.0", path = "./crates/rmcp" } -rmcp-macros = { version = "1.8.0", path = "./crates/rmcp-macros" } +rmcp = { version = "2.0.0", path = "./crates/rmcp" } +rmcp-macros = { version = "2.0.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "1.8.0" +version = "2.0.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 05a7bc2fc..242e591d0 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.8.0...rmcp-macros-v2.0.0) - 2026-06-27 + +### Added + +- [**breaking**] align model types with MCP 2025-11-25 spec ([#927](https://github.com/modelcontextprotocol/rust-sdk/pull/927)) + +### Fixed + +- fill missing fully qualified syntax in prompt_handler macros ([#866](https://github.com/modelcontextprotocol/rust-sdk/pull/866)) + +### Other + +- align README examples with v2 model API ([#928](https://github.com/modelcontextprotocol/rust-sdk/pull/928)) + ## [1.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.7.0...rmcp-macros-v1.8.0) - 2026-06-22 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 0d71a36b8..226fae94a 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.8.0...rmcp-v2.0.0) - 2026-06-27 + +### Added + +- [**breaking**] relax tool result structuredContent type ([#919](https://github.com/modelcontextprotocol/rust-sdk/pull/919)) +- deprecate roots/sampling/logging types ([#923](https://github.com/modelcontextprotocol/rust-sdk/pull/923)) +- [**breaking**] align model types with MCP 2025-11-25 spec ([#927](https://github.com/modelcontextprotocol/rust-sdk/pull/927)) + +### Fixed + +- prevent OAuth resource spoofing ([#937](https://github.com/modelcontextprotocol/rust-sdk/pull/937)) +- block oauth metadata ssrf ([#935](https://github.com/modelcontextprotocol/rust-sdk/pull/935)) +- prevent streamable HTTP session leak ([#934](https://github.com/modelcontextprotocol/rust-sdk/pull/934)) +- fill missing fully qualified syntax in prompt_handler macros ([#866](https://github.com/modelcontextprotocol/rust-sdk/pull/866)) +- *(rmcp)* add Audio variant to PromptMessageContent ([#865](https://github.com/modelcontextprotocol/rust-sdk/pull/865)) + +### Other + +- consolidate repeated rmcp tests ([#931](https://github.com/modelcontextprotocol/rust-sdk/pull/931)) +- Revert "feat!: relax tool result structuredContent type ([#919](https://github.com/modelcontextprotocol/rust-sdk/pull/919))" ([#932](https://github.com/modelcontextprotocol/rust-sdk/pull/932)) +- align README examples with v2 model API ([#928](https://github.com/modelcontextprotocol/rust-sdk/pull/928)) + ## [1.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0) - 2026-06-22 ### Added From 80a74795e9d9d061197efc27d288a1ae4ffa27de Mon Sep 17 00:00:00 2001 From: actsalan Date: Mon, 29 Jun 2026 10:29:06 -0700 Subject: [PATCH 202/333] fix: negotiate protocol version in handler (#930) * fix: negotiate protocol version in handler (fixes #916) * fix: use server pinned version as fallback in default initialize handler --- crates/rmcp/Cargo.toml | 10 ++ crates/rmcp/src/handler/server.rs | 10 +- crates/rmcp/src/service/server.rs | 7 +- .../transport/streamable_http_server/tower.rs | 44 ++++++++- .../test_protocol_version_negotiation.rs | 83 ++++++++++++++++ .../tests/test_stateless_protocol_version.rs | 99 +++++++++++++++++++ 6 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 crates/rmcp/tests/test_protocol_version_negotiation.rs create mode 100644 crates/rmcp/tests/test_stateless_protocol_version.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index fe2ecaac5..e3e9ff0d2 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -283,6 +283,16 @@ name = "test_streamable_http_protocol_version" required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] path = "tests/test_streamable_http_protocol_version.rs" +[[test]] +name = "test_stateless_protocol_version" +required-features = ["server", "transport-streamable-http-server", "reqwest"] +path = "tests/test_stateless_protocol_version.rs" + +[[test]] +name = "test_protocol_version_negotiation" +required-features = ["server", "client"] +path = "tests/test_protocol_version_negotiation.rs" + [[test]] name = "test_streamable_http_4xx_error_body" required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"] diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 0fb4bf891..3cec563e8 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -7,6 +7,7 @@ use crate::{ model::{TaskSupport, *}, service::{ MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole, + negotiate_protocol_version, }, }; @@ -202,8 +203,13 @@ macro_rules! server_handler_methods { request: InitializeRequestParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - context.peer.set_peer_info(request); - std::future::ready(Ok(self.get_info())) + context.peer.set_peer_info(request.clone()); + let mut info = self.get_info(); + info.protocol_version = negotiate_protocol_version( + &request.protocol_version, + info.protocol_version, + ); + std::future::ready(Ok(info)) } fn complete( &self, diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index c369e5aca..4f479b9e8 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -162,7 +162,7 @@ where } /// Echoes the client-requested version if known; otherwise returns `server_fallback`. -fn negotiate_protocol_version( +pub(crate) fn negotiate_protocol_version( client_requested: &ProtocolVersion, server_fallback: ProtocolVersion, ) -> ProtocolVersion { @@ -254,6 +254,11 @@ where &peer_info.params.protocol_version, init_response.protocol_version, ); + // Update peer_info so context.protocol_version() reflects the negotiated + // version in all subsequent request handlers. + let mut negotiated_peer_info = peer_info.params.clone(); + negotiated_peer_info.protocol_version = init_response.protocol_version.clone(); + peer.set_peer_info(negotiated_peer_info); transport .send(ServerJsonRpcMessage::response( ServerResult::InitializeResult(init_response), diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 8ebec4e5b..22be73798 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -16,8 +16,9 @@ use super::session::{ use crate::{ RoleServer, model::{ - ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, - InitializeRequest, InitializedNotification, JsonRpcError, ProtocolVersion, RequestId, + ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, + GetExtensions, Implementation, InitializeRequest, InitializeRequestParams, + InitializedNotification, JsonRpcError, ProtocolVersion, RequestId, }, serve_server, service::serve_directly, @@ -1239,10 +1240,17 @@ where .map_err(internal_error_response("get service"))?; match message { ClientJsonRpcMessage::Request(mut request) => { + // Build a peer_info so context.protocol_version() works inside handlers. + // serve_directly skips the handshake and receives None by default, making + // protocol_version() always return None in stateless mode. We reconstruct it: + // - initialize requests: version comes from the request body params + // - all other requests: version comes from the MCP-Protocol-Version header + // (already validated above; absent header defaults to 2025-03-26) + let peer_info = Self::peer_info_for_stateless_request(&request, &part.headers); request.request.extensions_mut().insert(part); let (transport, mut receiver) = OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); - let service = serve_directly(service, transport, None); + let service = serve_directly(service, transport, peer_info); tokio::spawn(async move { // on service created let _ = service.waiting().await; @@ -1331,4 +1339,34 @@ where } Ok(accepted_response()) } + + /// Build a `ClientInfo` (peer_info) for a stateless request so that + /// `context.protocol_version()` returns the correct value inside handlers. + /// + /// `serve_directly` skips the MCP handshake and accepts `peer_info = None`, + /// which means `context.protocol_version()` is always `None` in stateless mode. + /// We reconstruct the protocol version from the available signal per request type: + /// - initialize: version is in the request body params (authoritative) + /// - all other requests: version is in the MCP-Protocol-Version header + /// (validated before this point; absent header defaults to 2025-03-26) + fn peer_info_for_stateless_request( + request: &crate::model::JsonRpcRequest, + headers: &HeaderMap, + ) -> Option { + let version = if let ClientRequest::InitializeRequest(ref init) = request.request { + init.params.protocol_version.clone() + } else { + headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) + .unwrap_or(ProtocolVersion::V_2025_03_26) + }; + Some(InitializeRequestParams { + meta: None, + protocol_version: version, + capabilities: ClientCapabilities::default(), + client_info: Implementation::default(), + }) + } } diff --git a/crates/rmcp/tests/test_protocol_version_negotiation.rs b/crates/rmcp/tests/test_protocol_version_negotiation.rs new file mode 100644 index 000000000..44a314e68 --- /dev/null +++ b/crates/rmcp/tests/test_protocol_version_negotiation.rs @@ -0,0 +1,83 @@ +//! Tests for protocol version negotiation in the default ServerHandler::initialize impl. +//! +//! Known versions are echoed back; unknown versions fall back to LATEST. +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::{ClientInfo, ProtocolVersion, ServerInfo}, +}; + +#[derive(Debug, Clone, Default)] +struct EchoServer; + +impl ServerHandler for EchoServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn negotiated_version(client_version: ProtocolVersion) -> ProtocolVersion { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + tokio::spawn(async move { + let _ = EchoServer + .serve(server_transport) + .await + .expect("server should start") + .waiting() + .await; + }); + + let client = VersionedClient { + protocol_version: client_version, + } + .serve(client_transport) + .await + .expect("client should connect"); + + let version = client + .peer_info() + .expect("peer_info should be set") + .protocol_version + .clone(); + + client.cancel().await.expect("client should cancel"); + version +} + +#[tokio::test] +async fn known_version_echoed_back() { + for version in ProtocolVersion::KNOWN_VERSIONS { + let negotiated = negotiated_version(version.clone()).await; + assert_eq!( + negotiated, *version, + "known version {version} should be echoed back" + ); + } +} + +#[tokio::test] +async fn unknown_version_falls_back_to_latest() { + let unknown: ProtocolVersion = serde_json::from_str(r#""1999-01-01""#).unwrap(); + let negotiated = negotiated_version(unknown).await; + assert_eq!( + negotiated, + ProtocolVersion::LATEST, + "unknown version should fall back to LATEST" + ); +} diff --git a/crates/rmcp/tests/test_stateless_protocol_version.rs b/crates/rmcp/tests/test_stateless_protocol_version.rs new file mode 100644 index 000000000..5103ddd8d --- /dev/null +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -0,0 +1,99 @@ +//! Tests for protocol version negotiation in stateless HTTP mode. +//! +//! Known versions are echoed back; unknown versions fall back to LATEST. +#![cfg(not(feature = "local"))] + +use rmcp::{ + model::ProtocolVersion, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio_util::sync::CancellationToken; + +mod common; +use common::calculator::Calculator; + +fn stateless_json_config() -> StreamableHttpServerConfig { + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()) +} + +async fn spawn_server( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + (reqwest::Client::new(), format!("http://{addr}/mcp"), ct) +} + +async fn post_init(client: &reqwest::Client, url: &str, body_version: &str) -> serde_json::Value { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": body_version, + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.0.1"} + } + }); + let resp = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(body.to_string()) + .send() + .await + .expect("send request"); + assert!(resp.status().is_success(), "HTTP {}", resp.status()); + resp.json().await.expect("parse JSON") +} + +#[tokio::test] +async fn stateless_init_echoes_known_version() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + for version in ProtocolVersion::KNOWN_VERSIONS { + let resp = post_init(&client, &url, version.as_str()).await; + assert_eq!( + resp["result"]["protocolVersion"], + version.as_str(), + "known version {version} should be echoed back" + ); + } + + ct.cancel(); +} + +#[tokio::test] +async fn stateless_init_unknown_version_falls_back_to_latest() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let resp = post_init(&client, &url, "1999-01-01").await; + assert_eq!( + resp["result"]["protocolVersion"], + ProtocolVersion::LATEST.as_str(), + "unknown version should fall back to LATEST" + ); + + ct.cancel(); +} From 288f99653e61949cc3c61e29573f87ead17ba7e5 Mon Sep 17 00:00:00 2001 From: John Howard Date: Wed, 1 Jul 2026 11:40:30 -0700 Subject: [PATCH 203/333] feat: add SEP-2575 meta helpers (#942) Expose typed accessors for the per-request protocol version, client info, client capabilities, and log level entries carried in _meta without making those fields required for older peers. Fixes #869 --- crates/rmcp/src/model/meta.rs | 111 ++++++++++++++++++++----- crates/rmcp/tests/test_meta_helpers.rs | 73 ++++++++++++++++ 2 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 crates/rmcp/tests/test_meta_helpers.rs diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 4c9cd618a..cbaf3ec06 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -4,8 +4,9 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use super::{ - ClientNotification, ClientRequest, CustomNotification, CustomRequest, Extensions, JsonObject, - JsonRpcMessage, NumberOrString, ProgressToken, ServerNotification, ServerRequest, TaskMetadata, + ClientCapabilities, ClientNotification, ClientRequest, CustomNotification, CustomRequest, + Extensions, Implementation, JsonObject, JsonRpcMessage, LoggingLevel, NumberOrString, + ProgressToken, ProtocolVersion, ServerNotification, ServerRequest, TaskMetadata, }; pub trait GetMeta { @@ -199,8 +200,14 @@ variant_extension! { #[serde(transparent)] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct Meta(pub JsonObject); -const PROGRESS_TOKEN_FIELD: &str = "progressToken"; + impl Meta { + const PROGRESS_TOKEN_FIELD: &str = "progressToken"; + const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; + const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; + const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; + const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; + pub fn new() -> Self { Self(JsonObject::new()) } @@ -218,42 +225,106 @@ impl Meta { } pub fn get_progress_token(&self) -> Option { - self.0.get(PROGRESS_TOKEN_FIELD).and_then(|v| match v { - Value::String(s) => Some(ProgressToken(NumberOrString::String(s.to_string().into()))), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Some(ProgressToken(NumberOrString::Number(i))) - } else if let Some(u) = n.as_u64() { - if u <= i64::MAX as u64 { - Some(ProgressToken(NumberOrString::Number(u as i64))) + self.0 + .get(Self::PROGRESS_TOKEN_FIELD) + .and_then(|v| match v { + Value::String(s) => { + Some(ProgressToken(NumberOrString::String(s.to_string().into()))) + } + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Some(ProgressToken(NumberOrString::Number(i))) + } else if let Some(u) = n.as_u64() { + if u <= i64::MAX as u64 { + Some(ProgressToken(NumberOrString::Number(u as i64))) + } else { + None + } } else { None } - } else { - None } - } - _ => None, - }) + _ => None, + }) } pub fn set_progress_token(&mut self, token: ProgressToken) { match token.0 { NumberOrString::String(ref s) => self.0.insert( - PROGRESS_TOKEN_FIELD.to_string(), + Self::PROGRESS_TOKEN_FIELD.to_string(), Value::String(s.to_string()), ), - NumberOrString::Number(n) => self - .0 - .insert(PROGRESS_TOKEN_FIELD.to_string(), Value::Number(n.into())), + NumberOrString::Number(n) => self.0.insert( + Self::PROGRESS_TOKEN_FIELD.to_string(), + Value::Number(n.into()), + ), }; } + /// Get the MCP protocol version carried in `_meta`, if present and valid. + pub fn protocol_version(&self) -> Option { + self.decode_value(Self::META_KEY_PROTOCOL_VERSION) + } + + /// Set the MCP protocol version carried in `_meta`. + pub fn set_protocol_version(&mut self, protocol_version: ProtocolVersion) { + self.0.insert( + Self::META_KEY_PROTOCOL_VERSION.to_string(), + Value::String(protocol_version.to_string()), + ); + } + + /// Get the client implementation identity carried in `_meta`, if present and valid. + pub fn client_info(&self) -> Option { + self.decode_value(Self::META_KEY_CLIENT_INFO) + } + + /// Set the client implementation identity carried in `_meta`. + pub fn set_client_info(&mut self, client_info: Implementation) { + self.insert_serialized(Self::META_KEY_CLIENT_INFO, client_info); + } + + /// Get the client capabilities carried in `_meta`, if present and valid. + pub fn client_capabilities(&self) -> Option { + self.decode_value(Self::META_KEY_CLIENT_CAPABILITIES) + } + + /// Set the client capabilities carried in `_meta`. + pub fn set_client_capabilities(&mut self, client_capabilities: ClientCapabilities) { + self.insert_serialized(Self::META_KEY_CLIENT_CAPABILITIES, client_capabilities); + } + + /// Get the requested per-request log level carried in `_meta`, if present and valid. + pub fn log_level(&self) -> Option { + self.decode_value(Self::META_KEY_LOG_LEVEL) + } + + /// Set the requested per-request log level carried in `_meta`. + pub fn set_log_level(&mut self, log_level: LoggingLevel) { + self.insert_serialized(Self::META_KEY_LOG_LEVEL, log_level); + } + pub fn extend(&mut self, other: Meta) { for (k, v) in other.0.into_iter() { self.0.insert(k, v); } } + + fn decode_value(&self, key: &str) -> Option + where + T: for<'de> Deserialize<'de>, + { + self.0.get(key).and_then(|value| T::deserialize(value).ok()) + } + + fn insert_serialized(&mut self, key: &str, value: T) + where + T: Serialize, + { + let value = serde_json::to_value(value) + .expect("MCP meta helper value should serialize to valid JSON"); + self.0.insert(key.to_string(), value); + } } impl Deref for Meta { diff --git a/crates/rmcp/tests/test_meta_helpers.rs b/crates/rmcp/tests/test_meta_helpers.rs new file mode 100644 index 000000000..a22a420f3 --- /dev/null +++ b/crates/rmcp/tests/test_meta_helpers.rs @@ -0,0 +1,73 @@ +#![allow(deprecated)] + +use rmcp::model::{ClientCapabilities, Implementation, LoggingLevel, Meta, ProtocolVersion}; +use serde_json::json; + +const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; +const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; +const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; +const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; + +#[test] +fn meta_setters_store_sep_2575_values() { + let mut meta = Meta::new(); + meta.set_protocol_version(ProtocolVersion::V_2026_07_28); + meta.set_client_info(Implementation::new("test-client", "1.0.0")); + meta.set_client_capabilities(ClientCapabilities::default()); + meta.set_log_level(LoggingLevel::Warning); + + assert_eq!( + meta.get(META_KEY_PROTOCOL_VERSION), + Some(&json!("2026-07-28")) + ); + assert_eq!( + meta.get(META_KEY_CLIENT_INFO), + Some(&json!({ "name": "test-client", "version": "1.0.0" })) + ); + assert_eq!(meta.get(META_KEY_CLIENT_CAPABILITIES), Some(&json!({}))); + assert_eq!(meta.get(META_KEY_LOG_LEVEL), Some(&json!("warning"))); +} + +#[test] +fn meta_accessors_decode_wire_values() { + let meta: Meta = serde_json::from_value(json!({ + "progressToken": "progress-1", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "wire-client", + "version": "9.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": { + "sampling": {} + }, + "io.modelcontextprotocol/logLevel": "error" + })) + .unwrap(); + + assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); + assert_eq!( + meta.client_info(), + Some(Implementation::new("wire-client", "9.0.0")) + ); + assert!( + meta.client_capabilities() + .is_some_and(|capabilities| capabilities.sampling.is_some()) + ); + assert_eq!(meta.log_level(), Some(LoggingLevel::Error)); +} + +#[test] +fn meta_accessors_ignore_missing_or_malformed_values() { + let meta: Meta = serde_json::from_value(json!({ + "io.modelcontextprotocol/protocolVersion": 20260728, + "io.modelcontextprotocol/clientInfo": "not an implementation", + "io.modelcontextprotocol/clientCapabilities": "not capabilities", + "io.modelcontextprotocol/logLevel": "loud" + })) + .unwrap(); + + assert_eq!(meta.protocol_version(), None); + assert_eq!(meta.client_info(), None); + assert_eq!(meta.client_capabilities(), None); + assert_eq!(meta.log_level(), None); +} From 64d22def0254b30da515646b81fcb3f4ea923006 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 1 Jul 2026 15:01:58 -0500 Subject: [PATCH 204/333] fix: don't respond to unparseable messages (#940) * fix: don't respond to unparseable messages * docs: spell 'unparsable' to satisfy typos linter * fix: only ignore unparsable JSON, keep protocol errors visible Classify the serde error in the receive loop: syntax/EOF errors are unparsable input with no correlatable id (issue #938) and stay silent, while data errors (valid JSON that doesn't match the message shape) are real protocol errors and get an error response instead of being dropped. Add a test covering the protocol-error path. * fix: respond with Invalid Request for malformed protocol messages --------- Co-authored-by: tsouth89 --- crates/rmcp/src/transport/async_rw.rs | 101 ++++++++++++++++++++------ 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/crates/rmcp/src/transport/async_rw.rs b/crates/rmcp/src/transport/async_rw.rs index 2ef0aae25..bb5418350 100644 --- a/crates/rmcp/src/transport/async_rw.rs +++ b/crates/rmcp/src/transport/async_rw.rs @@ -143,15 +143,30 @@ where Ok(Some(msg)) => return Some(msg), Ok(None) => continue, Err(JsonRpcMessageCodecError::Serde(e)) => { - tracing::debug!("Parse error on incoming message: {e}"); - let mut write = self.write.lock().await; - let framed = write.as_mut()?; - let response = TxJsonRpcMessage::::error( - ErrorData::parse_error("Parse error", None), - None, - ); - if framed.send(response).await.is_err() { - return None; + match e.classify() { + serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { + // The input isn't valid JSON, so there's no message id to correlate a + // response to, and replying to invalid data can trigger an error storm + // if the peer echoes the response back as more invalid data. This + // matches the other official MCP SDKs, which ignore unparsable input. + // See https://github.com/modelcontextprotocol/rust-sdk/issues/938 + tracing::debug!("Ignoring unparsable incoming message: {e}"); + } + serde_json::error::Category::Data | serde_json::error::Category::Io => { + // Well-formed JSON that doesn't match the expected message shape is a + // real protocol error rather than unparsable input, so surface it with + // an Invalid Request response instead of silently dropping it. + tracing::debug!("Protocol error on incoming message: {e}"); + let mut write = self.write.lock().await; + let framed = write.as_mut()?; + let response = TxJsonRpcMessage::::error( + ErrorData::invalid_request("Invalid request", None), + None, + ); + if framed.send(response).await.is_err() { + return None; + } + } } } Err(e) => { @@ -618,8 +633,8 @@ mod test { #[cfg(feature = "server")] #[tokio::test] - async fn receive_recovers_from_parse_error() { - use tokio::io::AsyncWriteExt; + async fn receive_ignores_parse_error() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::{RoleServer, transport::Transport}; @@ -638,28 +653,70 @@ mod test { .await .unwrap(); + // The unparsable line is skipped and the next valid message is still yielded. let received = transport .receive() .await - .expect("transport should recover and yield the next valid message"); + .expect("transport should skip the invalid line and yield the next valid message"); + assert_eq!( + serde_json::to_value(&received).unwrap()["method"], + "notifications/initialized", + ); + + // No response is sent back for the unparsable message (issue #938). Dropping the + // transport closes its write side, so the peer reads to EOF and should see no bytes. + drop(transport); + let mut reply_buf = Vec::new(); + client_r.read_to_end(&mut reply_buf).await.unwrap(); + assert!( + reply_buf.is_empty(), + "expected no response to an unparsable message, got: {}", + String::from_utf8_lossy(&reply_buf), + ); + } + + #[cfg(feature = "server")] + #[tokio::test] + async fn receive_responds_to_protocol_error() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + use crate::{RoleServer, transport::Transport}; + + let (server_io, client_io) = tokio::io::duplex(4096); + let (server_r, server_w) = tokio::io::split(server_io); + let (client_r, mut client_w) = tokio::io::split(client_io); + + let mut transport = AsyncRwTransport::::new(server_r, server_w); + + // Well-formed JSON that does not match the JSON-RPC message shape, followed by a + // valid notification. Unlike unparsable bytes, this is a protocol error: the + // transport should reply to it and still yield the next valid message. + client_w + .write_all( + b"{\"foo\":\"bar\"}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + ) + .await + .unwrap(); - // Read one line back from the peer side and parse as JSON. + let received = transport.receive().await.expect( + "transport should reply to the protocol error and yield the next valid message", + ); + assert_eq!( + serde_json::to_value(&received).unwrap()["method"], + "notifications/initialized", + ); + + // A protocol error gets an error response back (id omitted since it can't be read). let mut reply_buf = Vec::new(); - let mut peer = tokio::io::BufReader::new(&mut client_r); + let mut peer = BufReader::new(client_r); peer.read_until(b'\n', &mut reply_buf).await.unwrap(); let reply: serde_json::Value = serde_json::from_slice(&reply_buf).unwrap(); - - // Per MCP 2025-11-25: id is omitted when the server can't read the request id. assert_eq!( reply, serde_json::json!({ "jsonrpc": "2.0", - "error": {"code": -32700, "message": "Parse error"}, - }) - ); - assert_eq!( - serde_json::to_value(&received).unwrap()["method"], - "notifications/initialized", + "error": {"code": -32600, "message": "Invalid request"}, + }), ); } } From 496902b9cf2c8a947454718da31829ae776b969b Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:28:08 -0400 Subject: [PATCH 205/333] fix: block redirect header leaks (#936) --- .../common/reqwest/streamable_http_client.rs | 137 +++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index fffcd3933..37c2b08fe 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -299,9 +299,13 @@ impl StreamableHttpClientTransport { /// Disables idle connection pooling to avoid ~40 ms stalls caused by /// TCP Delayed ACK on Linux when the previous response body was not /// fully consumed before the pool attempts to reuse the connection. + /// + /// Automatic redirects are disabled so caller-supplied custom headers + /// cannot be replayed to a redirect target. fn default_http_client() -> reqwest::Client { reqwest::Client::builder() .pool_max_idle_per_host(0) + .redirect(reqwest::redirect::Policy::none()) .build() .expect("failed to build default reqwest client") } @@ -313,7 +317,7 @@ mod tests { use super::parse_json_rpc_error; use crate::{ - model::JsonRpcMessage, + model::{ClientJsonRpcMessage, ClientRequest, JsonRpcMessage, PingRequest, RequestId}, transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError}, }; @@ -359,4 +363,135 @@ mod tests { fn parse_json_rpc_error_rejects_non_error_bodies(#[case] body: &str) { assert!(parse_json_rpc_error(body).is_none()); } + + #[tokio::test] + async fn default_http_client_does_not_leak_custom_headers_to_redirect_target() + -> anyhow::Result<()> { + use std::{collections::HashMap, net::SocketAddr, sync::Arc}; + + use axum::{ + Router, extract::State, http::StatusCode, response::IntoResponse, routing::post, + }; + use http::{HeaderMap, HeaderName, HeaderValue, header::LOCATION}; + use tokio::sync::Mutex; + + use super::StreamableHttpClientTransport; + use crate::transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}; + + const API_KEY_HEADER: &str = "x-api-key"; + const API_KEY_VALUE: &str = "secret"; + + type CapturedHeader = Arc>>; + + #[derive(Clone)] + struct RedirectState { + location: String, + captured_header: CapturedHeader, + } + + async fn capture_api_key_header(headers: &HeaderMap, captured_header: &CapturedHeader) { + if let Some(value) = headers + .get(API_KEY_HEADER) + .and_then(|value| value.to_str().ok()) + { + *captured_header.lock().await = Some(value.to_owned()); + } + } + + async fn redirect_handler( + State(state): State, + headers: HeaderMap, + ) -> impl IntoResponse { + capture_api_key_header(&headers, &state.captured_header).await; + + ( + StatusCode::TEMPORARY_REDIRECT, + [(LOCATION, state.location)], + "", + ) + } + + async fn redirected_handler( + State(captured_header): State, + headers: HeaderMap, + ) -> impl IntoResponse { + capture_api_key_header(&headers, &captured_header).await; + + ( + StatusCode::OK, + [(http::header::CONTENT_TYPE, "application/json")], + r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, + ) + } + + let redirected_header = Arc::new(Mutex::new(None)); + let redirected_listener = + tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; + let redirected_addr = redirected_listener.local_addr()?; + let redirected_server = tokio::spawn({ + let redirected_header = redirected_header.clone(); + async move { + let app = Router::new() + .route("/capture", post(redirected_handler)) + .with_state(redirected_header); + axum::serve(redirected_listener, app).await + } + }); + + let original_header = Arc::new(Mutex::new(None)); + let redirect_listener = + tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; + let redirect_addr = redirect_listener.local_addr()?; + let redirect_server = tokio::spawn({ + let state = RedirectState { + location: format!("http://{redirected_addr}/capture"), + captured_header: original_header.clone(), + }; + async move { + let app = Router::new() + .route("/mcp", post(redirect_handler)) + .with_state(state); + axum::serve(redirect_listener, app).await + } + }); + + let mut custom_headers = HashMap::new(); + custom_headers.insert( + HeaderName::from_static(API_KEY_HEADER), + HeaderValue::from_static(API_KEY_VALUE), + ); + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + + let client = StreamableHttpClientTransport::::default_http_client(); + let result = client + .post_message( + Arc::::from(format!("http://{redirect_addr}/mcp")), + message, + None, + None, + custom_headers, + ) + .await; + + assert!( + matches!( + result, + Err(StreamableHttpError::UnexpectedServerResponse(_)) + ), + "redirect response should be returned to the transport, got {result:?}" + ); + assert_eq!(original_header.lock().await.as_deref(), Some(API_KEY_VALUE)); + assert!( + redirected_header.lock().await.is_none(), + "custom headers should not be sent to redirect targets" + ); + + redirect_server.abort(); + redirected_server.abort(); + + Ok(()) + } } From ee2d81f4d06b715bde7e4f9532bb88daa4c95083 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:51:05 -0400 Subject: [PATCH 206/333] feat: add SEP-414 trace context meta accessors (#910) --- crates/rmcp/Cargo.toml | 5 + crates/rmcp/src/model/meta.rs | 142 ++++++++++++++++++++++++ crates/rmcp/tests/test_trace_context.rs | 85 ++++++++++++++ typos.toml | 4 + 4 files changed, 236 insertions(+) create mode 100644 crates/rmcp/tests/test_trace_context.rs create mode 100644 typos.toml diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index e3e9ff0d2..9704bfc72 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -304,6 +304,11 @@ name = "test_custom_request" required-features = ["server", "client"] path = "tests/test_custom_request.rs" +[[test]] +name = "test_trace_context" +required-features = ["server", "client"] +path = "tests/test_trace_context.rs" + [[test]] name = "test_prompt_macros" required-features = ["server", "client"] diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index cbaf3ec06..712a439d9 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -47,6 +47,34 @@ pub trait RequestParamsMeta { } } } + /// Get the W3C `traceparent` value from meta, if present (SEP-414) + fn traceparent(&self) -> Option<&str> { + self.meta().and_then(|m| m.get_traceparent()) + } + /// Set the W3C `traceparent` value in meta (SEP-414) + fn set_traceparent(&mut self, value: &str) { + self.meta_or_default().set_traceparent(value); + } + /// Get the W3C `tracestate` value from meta, if present (SEP-414) + fn tracestate(&self) -> Option<&str> { + self.meta().and_then(|m| m.get_tracestate()) + } + /// Set the W3C `tracestate` value in meta (SEP-414) + fn set_tracestate(&mut self, value: &str) { + self.meta_or_default().set_tracestate(value); + } + /// Get the W3C `baggage` value from meta, if present (SEP-414) + fn baggage(&self) -> Option<&str> { + self.meta().and_then(|m| m.get_baggage()) + } + /// Set the W3C `baggage` value in meta (SEP-414) + fn set_baggage(&mut self, value: &str) { + self.meta_or_default().set_baggage(value); + } + /// Get a mutable reference to meta, inserting an empty one if absent. + fn meta_or_default(&mut self) -> &mut Meta { + self.meta_mut().get_or_insert_with(Meta::new) + } } /// Trait for task-augmented request params that contain both `_meta` and `task` fields. @@ -207,6 +235,12 @@ impl Meta { const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; + /// Reserved `_meta` key for the W3C Trace Context `traceparent` value (SEP-414). + const TRACEPARENT_FIELD: &str = "traceparent"; + /// Reserved `_meta` key for the W3C Trace Context `tracestate` value (SEP-414). + const TRACESTATE_FIELD: &str = "tracestate"; + /// Reserved `_meta` key for the W3C Baggage value (SEP-414). + const BAGGAGE_FIELD: &str = "baggage"; pub fn new() -> Self { Self(JsonObject::new()) @@ -304,6 +338,58 @@ impl Meta { self.insert_serialized(Self::META_KEY_LOG_LEVEL, log_level); } + /// Read a string-valued `_meta` field, or `None` if absent or not a string. + fn get_str(&self, field: &str) -> Option<&str> { + self.0.get(field).and_then(Value::as_str) + } + + /// Write a string-valued `_meta` field. + fn set_str(&mut self, field: &str, value: impl Into) { + self.0 + .insert(field.to_string(), Value::String(value.into())); + } + + /// Get the W3C `traceparent` value (SEP-414), if present. + pub fn get_traceparent(&self) -> Option<&str> { + self.get_str(Self::TRACEPARENT_FIELD) + } + + /// Set the W3C `traceparent` value (SEP-414). + /// + /// ``` + /// use rmcp::model::Meta; + /// + /// let mut meta = Meta::new(); + /// meta.set_traceparent("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"); + /// assert_eq!( + /// meta.get_traceparent(), + /// Some("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"), + /// ); + /// ``` + pub fn set_traceparent(&mut self, value: impl Into) { + self.set_str(Self::TRACEPARENT_FIELD, value); + } + + /// Get the W3C `tracestate` value (SEP-414), if present. + pub fn get_tracestate(&self) -> Option<&str> { + self.get_str(Self::TRACESTATE_FIELD) + } + + /// Set the W3C `tracestate` value (SEP-414). + pub fn set_tracestate(&mut self, value: impl Into) { + self.set_str(Self::TRACESTATE_FIELD, value); + } + + /// Get the W3C `baggage` value (SEP-414), if present. + pub fn get_baggage(&self) -> Option<&str> { + self.get_str(Self::BAGGAGE_FIELD) + } + + /// Set the W3C `baggage` value (SEP-414). + pub fn set_baggage(&mut self, value: impl Into) { + self.set_str(Self::BAGGAGE_FIELD, value); + } + pub fn extend(&mut self, other: Meta) { for (k, v) in other.0.into_iter() { self.0.insert(k, v); @@ -361,3 +447,59 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct Params { + meta: Option, + } + + impl RequestParamsMeta for Params { + fn meta(&self) -> Option<&Meta> { + self.meta.as_ref() + } + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } + } + + const TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"; + + #[test] + fn trace_context_round_trip() { + let mut meta = Meta::new(); + meta.set_traceparent(TRACEPARENT); + meta.set_tracestate("vendor1=value1,vendor2=value2"); + meta.set_baggage("userId=alice,region=us-east-1"); + assert_eq!(meta.get_traceparent(), Some(TRACEPARENT)); + assert_eq!(meta.get_tracestate(), Some("vendor1=value1,vendor2=value2")); + assert_eq!(meta.get_baggage(), Some("userId=alice,region=us-east-1")); + } + + #[test] + fn absent_field_is_none() { + let meta = Meta::new(); + assert_eq!(meta.get_traceparent(), None); + assert_eq!(meta.get_tracestate(), None); + assert_eq!(meta.get_baggage(), None); + } + + #[test] + fn non_string_value_is_none() { + let mut meta = Meta::new(); + meta.0 + .insert(Meta::TRACEPARENT_FIELD.to_string(), Value::from(42)); + assert_eq!(meta.get_traceparent(), None); + } + + #[test] + fn trait_setter_inserts_meta_when_absent() { + let mut params = Params::default(); + assert_eq!(params.traceparent(), None); + params.set_traceparent(TRACEPARENT); + assert_eq!(params.traceparent(), Some(TRACEPARENT)); + } +} diff --git a/crates/rmcp/tests/test_trace_context.rs b/crates/rmcp/tests/test_trace_context.rs new file mode 100644 index 000000000..50214b715 --- /dev/null +++ b/crates/rmcp/tests/test_trace_context.rs @@ -0,0 +1,85 @@ +#![cfg(not(feature = "local"))] +//! SEP-414: the reserved trace-context `_meta` keys survive a client→server round trip unchanged. +use std::sync::Arc; + +use rmcp::{ + RoleServer, ServerHandler, ServiceExt, + model::{ClientRequest, CustomRequest, CustomResult, Meta}, + service::{PeerRequestOptions, RequestContext}, +}; +use serde_json::json; +use tokio::sync::{Mutex, Notify}; + +const TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"; +const TRACESTATE: &str = "vendor1=value1,vendor2=value2"; +const BAGGAGE: &str = "userId=alice,region=us-east-1"; + +/// Records the `_meta` it receives on the incoming request so the test can assert passthrough. +struct TraceCapturingServer { + receive_signal: Arc, + seen: Arc>>, +} + +impl ServerHandler for TraceCapturingServer { + async fn on_custom_request( + &self, + _request: CustomRequest, + context: RequestContext, + ) -> Result { + *self.seen.lock().await = Some(context.meta); + self.receive_signal.notify_one(); + Ok(CustomResult::new(json!({ "status": "ok" }))) + } +} + +#[tokio::test] +async fn trace_context_meta_survives_round_trip() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let receive_signal = Arc::new(Notify::new()); + let seen = Arc::new(Mutex::new(None)); + + { + let receive_signal = receive_signal.clone(); + let seen = seen.clone(); + tokio::spawn(async move { + let server = TraceCapturingServer { + receive_signal, + seen, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + }); + } + + let client = ().serve(client_transport).await?; + + // Client attaches trace context to the outgoing request's `_meta`. + let mut meta = Meta::new(); + meta.set_traceparent(TRACEPARENT); + meta.set_tracestate(TRACESTATE); + meta.set_baggage(BAGGAGE); + + let mut options = PeerRequestOptions::no_options(); + options.meta = Some(meta); + client + .send_cancellable_request( + ClientRequest::CustomRequest(CustomRequest::new("requests/trace-test", None)), + options, + ) + .await? + .await_response() + .await?; + + tokio::time::timeout(std::time::Duration::from_secs(5), receive_signal.notified()).await?; + + // Server saw the reserved keys unchanged (alongside the injected progressToken). + let seen = seen.lock().await.take().expect("server observed meta"); + assert_eq!(seen.get_traceparent(), Some(TRACEPARENT)); + assert_eq!(seen.get_tracestate(), Some(TRACESTATE)); + assert_eq!(seen.get_baggage(), Some(BAGGAGE)); + + client.cancel().await?; + Ok(()) +} diff --git a/typos.toml b/typos.toml new file mode 100644 index 000000000..38fcb1fd0 --- /dev/null +++ b/typos.toml @@ -0,0 +1,4 @@ +# W3C `traceparent` example values (SEP-414) embed hex spans like `0ba9` that the +# spell checker misreads as typos; ignore the canonical traceparent format. +[default] +extend-ignore-re = ["00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}"] From 5837e225d3b85efe662478a94446e5f50a119b9e Mon Sep 17 00:00:00 2001 From: Sarthak Bhardwaj <100398847+SarthakB11@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:14:59 +0530 Subject: [PATCH 207/333] fix(auth): preserve refresh_token when refresh response omits it (#949) Per RFC 6749 section 6 and OAuth 2.1: when the server does not issue a new refresh_token on a refresh response, the client MUST keep the existing one. AuthorizationManager::refresh_token() was persisting the response verbatim, dropping the previous refresh_token and forcing full re-authorization on the next refresh. Match the fix from python-sdk#2270: preserve the existing refresh_token when the response omits it, replace when the server rotates. Fixes #921 Signed-off-by: SarthakB11 --- crates/rmcp/src/transport/auth.rs | 106 +++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index c4c038722..353c911ae 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1693,7 +1693,7 @@ impl AuthorizationManager { for scope in refresh_scopes { refresh_request = refresh_request.add_scope(Scope::new(scope)); } - let token_result = refresh_request + let mut token_result = refresh_request .request_async(&OAuth2HttpClient { client: self.http_client.as_ref(), redirect_policy: self.refresh_redirect_policy, @@ -1701,6 +1701,13 @@ impl AuthorizationManager { .await .map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?; + // RFC 6749 section 6: issuing a new refresh token on refresh is optional. + // When the response omits one, keep the existing refresh token rather than + // dropping it. When a new one is present, the response value is used as-is. + if token_result.refresh_token().is_none() { + token_result.set_refresh_token(Some(refresh_token_value)); + } + let granted_scopes: Vec = match token_result.scopes() { Some(scopes) => scopes.iter().map(|s| s.to_string()).collect(), None => self.current_scopes.read().await.clone(), @@ -5554,4 +5561,101 @@ mod tests { "scope should be absent when granted_scopes is empty, body: {body}" ); } + + #[tokio::test] + async fn refresh_token_preserves_existing_refresh_token_when_response_omits_it() { + use oauth2::TokenResponse; + // start_token_server returns a response without a refresh_token, matching + // an authorization server that does not rotate refresh tokens on refresh. + let (base_url, _captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }; + manager.credential_store.save(stored).await.unwrap(); + + let result = manager.refresh_token().await.unwrap(); + assert_eq!( + result.refresh_token().map(|t| t.secret().as_str()), + Some("my-refresh-token"), + "returned response should keep the previous refresh token" + ); + + let reloaded = manager + .credential_store + .load() + .await + .unwrap() + .unwrap() + .token_response + .unwrap(); + assert_eq!( + reloaded.refresh_token().map(|t| t.secret().as_str()), + Some("my-refresh-token"), + "stored credentials should keep the previous refresh token" + ); + } + + #[tokio::test] + async fn refresh_token_replaces_refresh_token_when_response_includes_new_one() { + use axum::{Router, body::Body, http::Response, routing::post}; + use oauth2::TokenResponse; + + let app = Router::new().route( + "/token", + post(|| async { + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"new-token","token_type":"Bearer","expires_in":3600,"refresh_token":"rotated-refresh-token"}"#, + )) + .unwrap() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let base_url = format!("http://{}", addr); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }; + manager.credential_store.save(stored).await.unwrap(); + + let result = manager.refresh_token().await.unwrap(); + assert_eq!( + result.refresh_token().map(|t| t.secret().as_str()), + Some("rotated-refresh-token"), + "a rotated refresh token from the response should replace the old one" + ); + } } From 4833ec7b6357fcc84335572a8fd49737c368854a Mon Sep 17 00:00:00 2001 From: Amey Pawar <138877912+ameyypawar@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:38:24 +0530 Subject: [PATCH 208/333] fix(transport): make AsyncRwTransport::receive cancel-safe (#941) (#947) `receive()` reads incoming lines with `read_until`, which is not cancellation-safe, and it is polled inside the service loop's `select!`. When another branch (e.g. an outgoing response) becomes ready while a request line is only partially read, the `receive()` future is dropped and the next call's `self.line_buf.clear()` discards the partial bytes, so that incoming request is silently lost and never gets a response. Under many concurrent large responses this intermittently drops requests. Keep the partially-read bytes in `line_buf` across calls and clear it only after a whole line has been consumed, so a cancelled read resumes the same line instead of dropping it. The buffer is cleared (retaining capacity) rather than reallocated per message. Adds a regression test that fires 200 concurrent 64 KiB tool responses over real stdio pipes and asserts no response id goes missing. This also covers the child-process client transport, which reuses `AsyncRwTransport`. --- crates/rmcp/src/transport/async_rw.rs | 35 +++- .../tests/test_stdio_response_concurrency.rs | 198 ++++++++++++++++++ 2 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 crates/rmcp/tests/test_stdio_response_concurrency.rs diff --git a/crates/rmcp/src/transport/async_rw.rs b/crates/rmcp/src/transport/async_rw.rs index bb5418350..f50d91334 100644 --- a/crates/rmcp/src/transport/async_rw.rs +++ b/crates/rmcp/src/transport/async_rw.rs @@ -124,8 +124,19 @@ where async fn receive(&mut self) -> Option> { loop { - self.line_buf.clear(); + // `read_until` is not cancellation-safe on its own, and `receive` is + // polled inside a `select!` in the service loop: an in-progress line + // read is dropped whenever another branch (e.g. an outgoing response) + // becomes ready. We rely on `read_until` appending into `self.line_buf` + // and only returning at a delimiter or EOF, so a cancelled read leaves + // its partial bytes in `self.line_buf`. Keeping that buffer across + // calls lets the next read resume the same line; it is cleared only + // after a whole line has been consumed. Clearing at the top of the + // loop (the previous behaviour) discarded the partial read and so + // dropped incoming requests under concurrent response load. match self.read.read_until(b'\n', &mut self.line_buf).await { + // EOF. Any bytes still in `line_buf` are an incomplete trailing + // message with no delimiter, so there is nothing to deliver. Ok(0) => return None, Ok(_) => {} Err(e) => { @@ -133,13 +144,21 @@ where return None; } } - let line = without_carriage_return( - self.line_buf.strip_suffix(b"\n").unwrap_or(&self.line_buf), - ); - if line.is_empty() { - continue; - } - match try_parse_with_compatibility::>(line, "receive") { + // A returned `read_until` means a full line is buffered. Parse it + // (borrowing `line_buf`), then clear the buffer — retaining its + // capacity for the next read — before handling the parse result. + let parsed = { + let line = without_carriage_return( + self.line_buf.strip_suffix(b"\n").unwrap_or(&self.line_buf), + ); + if line.is_empty() { + self.line_buf.clear(); + continue; + } + try_parse_with_compatibility::>(line, "receive") + }; + self.line_buf.clear(); + match parsed { Ok(Some(msg)) => return Some(msg), Ok(None) => continue, Err(JsonRpcMessageCodecError::Serde(e)) => { diff --git a/crates/rmcp/tests/test_stdio_response_concurrency.rs b/crates/rmcp/tests/test_stdio_response_concurrency.rs new file mode 100644 index 000000000..563b0b379 --- /dev/null +++ b/crates/rmcp/tests/test_stdio_response_concurrency.rs @@ -0,0 +1,198 @@ +#![cfg(not(feature = "local"))] + +use std::{collections::BTreeSet, process::Stdio, time::Duration}; + +use rmcp::{ + ErrorData as McpError, ServerHandler, ServiceExt, + model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}, + process::{Child, Command}, +}; + +const HELPER_ENV: &str = "RMCP_STDIO_RESPONSE_CONCURRENCY_HELPER"; +const REQUESTS: usize = 200; +const RESPONSE_BYTES: usize = 64 * 1024; +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] +async fn raw_client_concurrent_large_stdio_tool_responses_are_not_lost() -> anyhow::Result<()> { + // Spawn the same test binary as a child process so the server uses real + // stdio pipes, not an in-process transport. + let mut child = spawn_helper(); + let mut writer = child.stdin.take().expect("helper stdin"); + let stdout = child.stdout.take().expect("helper stdout"); + let mut reader = BufReader::new(stdout); + + // Complete the normal MCP initialization flow before stressing tools/call. + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "raw-test-client", "version": "0.0.0" } + } + }), + ) + .await?; + read_response_for_id(&mut reader, 1).await?; + + send_json( + &mut writer, + &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + ) + .await?; + + // Send the whole batch before reading responses. This creates concurrent + // request handling and concurrent response production inside rmcp. + for id in request_ids() { + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": "large-response", "arguments": {} } + }), + ) + .await?; + } + + let missing_ids = read_responses_for_ids(&mut reader, request_ids(), READ_TIMEOUT).await?; + assert!( + missing_ids.is_empty(), + "missing response ids: {missing_ids:?}" + ); + + drop(writer); + wait_for_child(&mut child).await; + Ok(()) +} + +struct LargeResponseServer; + +impl ServerHandler for LargeResponseServer { + #[allow(deprecated)] + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: rmcp::service::RequestContext, + ) -> Result { + assert_eq!("large-response", request.name.as_ref()); + Ok(CallToolResult::success(vec![ContentBlock::text( + "x".repeat(RESPONSE_BYTES), + )])) + } +} + +#[tokio::test] +async fn stdio_response_concurrency_helper() -> anyhow::Result<()> { + // The parent test starts this same binary with HELPER_ENV=1 so it can act + // as a small MCP server connected over real stdin/stdout pipes. + if std::env::var(HELPER_ENV).as_deref() != Ok("1") { + return Ok(()); + } + let server = LargeResponseServer.serve(rmcp::transport::stdio()).await?; + server.waiting().await?; + Ok(()) +} + +fn spawn_helper() -> Child { + let exe = std::env::current_exe().expect("current test exe"); + Command::new(exe) + .arg("--exact") + .arg("stdio_response_concurrency_helper") + .arg("--quiet") + .arg("--nocapture") + .arg("--test-threads") + .arg("1") + .env(HELPER_ENV, "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("spawn helper") +} + +async fn wait_for_child(child: &mut Child) { + let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await; + if child.id().is_some() { + let _ = child.kill().await; + } +} + +fn request_ids() -> BTreeSet { + (1000..1000 + REQUESTS as u64).collect() +} + +async fn send_json(writer: &mut W, message: &Value) -> anyhow::Result<()> +where + W: AsyncWrite + Unpin, +{ + let serialized = serde_json::to_string(message)?; + writer.write_all(serialized.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + Ok(()) +} + +async fn read_response_for_id(reader: &mut BufReader, expected_id: u64) -> anyhow::Result<()> +where + R: tokio::io::AsyncRead + Unpin, +{ + let missing = + read_responses_for_ids(reader, BTreeSet::from([expected_id]), READ_TIMEOUT).await?; + if missing.is_empty() { + Ok(()) + } else { + anyhow::bail!("missing response id {expected_id}") + } +} + +async fn read_responses_for_ids( + reader: &mut BufReader, + mut pending_ids: BTreeSet, + timeout: Duration, +) -> anyhow::Result> +where + R: tokio::io::AsyncRead + Unpin, +{ + let deadline = tokio::time::Instant::now() + timeout; + while !pending_ids.is_empty() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let mut line = String::new(); + let Ok(read_result) = tokio::time::timeout(remaining, reader.read_line(&mut line)).await + else { + break; + }; + let read = read_result?; + if read == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + if let Some(id) = value.get("id").and_then(Value::as_u64) { + pending_ids.remove(&id); + } + } + Ok(pending_ids) +} From 8e44af499bfa8d54f18fb475eb86a601a5a2e432 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:35:24 -0400 Subject: [PATCH 209/333] chore: release v2.1.0 (#950) * chore: release v2.1.0 * chore: fix changelog spelling --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp/CHANGELOG.md | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f60c9868f..192022b12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "2.0.0", path = "./crates/rmcp" } -rmcp-macros = { version = "2.0.0", path = "./crates/rmcp-macros" } +rmcp = { version = "2.1.0", path = "./crates/rmcp" } +rmcp-macros = { version = "2.1.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "2.0.0" +version = "2.1.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 226fae94a..e33b77c81 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.0.0...rmcp-v2.1.0) - 2026-07-02 + +### Added + +- add SEP-414 trace context meta accessors ([#910](https://github.com/modelcontextprotocol/rust-sdk/pull/910)) +- add SEP-2575 meta helpers ([#942](https://github.com/modelcontextprotocol/rust-sdk/pull/942)) + +### Fixed + +- *(transport)* make AsyncRwTransport::receive cancel-safe ([#941](https://github.com/modelcontextprotocol/rust-sdk/pull/941)) ([#947](https://github.com/modelcontextprotocol/rust-sdk/pull/947)) +- *(auth)* preserve refresh_token when refresh response omits it ([#949](https://github.com/modelcontextprotocol/rust-sdk/pull/949)) +- block redirect header leaks ([#936](https://github.com/modelcontextprotocol/rust-sdk/pull/936)) +- don't respond to unparsable messages ([#940](https://github.com/modelcontextprotocol/rust-sdk/pull/940)) +- negotiate protocol version in handler ([#930](https://github.com/modelcontextprotocol/rust-sdk/pull/930)) + ## [2.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.8.0...rmcp-v2.0.0) - 2026-06-27 ### Added From bdf0c32e8c1ea1847ab9c581c0ee0d4984d6b556 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:42:35 -0400 Subject: [PATCH 210/333] fix: address 2025-11-25 conformance audit findings (#951) * fix: interpret task ttl as milliseconds * fix: use text/plain for default text mime type * fix: include resource param in token refresh * test: align conformance prompt args with runner * ci: run server conformance suite on PRs * ci: build client bin and gate pending scenarios --- .github/workflows/conformance.yml | 77 +++++++++++++++++++++++++++++++ conformance/src/bin/server.rs | 17 +++---- crates/rmcp/src/model/content.rs | 2 +- crates/rmcp/src/model/resource.rs | 2 +- crates/rmcp/src/task_manager.rs | 17 ++++--- crates/rmcp/src/transport/auth.rs | 41 +++++++++++++++- crates/rmcp/tests/test_task.rs | 32 +++++++++++++ 7 files changed, 169 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/conformance.yml diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 000000000..ac1dfe204 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,77 @@ +name: Conformance + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned for reproducible runs; bump deliberately when the suite updates. + CONFORMANCE_VERSION: "0.1.16" + +jobs: + server: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + # Build the whole package (server + client bins): the conformance crate is + # excluded from the workspace default-members, so this is the only CI job + # that catches compile breakage in it. + - name: Build conformance binaries + run: cargo build -p mcp-conformance + + - name: Start conformance server + run: | + PORT=8001 ./target/debug/conformance-server & + echo $! > server.pid + for _ in $(seq 1 30); do + if curl -s -o /dev/null http://127.0.0.1:8001/mcp; then + exit 0 + fi + sleep 1 + done + echo "conformance server did not become ready" >&2 + exit 1 + + - name: Run server conformance suite + run: | + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8001/mcp \ + --spec-version 2025-11-25 \ + -o conformance-results + + # These pass today but are excluded from the default "active" suite; + # run them explicitly so regressions are still caught. + - name: Run pending scenarios + run: | + for scenario in json-schema-2020-12 server-sse-polling; do + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8001/mcp \ + --scenario "$scenario" \ + -o conformance-results + done + + - name: Stop conformance server + if: always() + run: kill "$(cat server.pid)" 2>/dev/null || true + + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: conformance-server-results + path: conformance-results diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index b0b0d635c..4fc89bfed 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -656,11 +656,11 @@ impl ServerHandler for ConformanceServer { "test_prompt_with_arguments", Some("A test prompt that accepts arguments"), Some(vec![ - PromptArgument::new("name") - .with_description("The name to greet") + PromptArgument::new("arg1") + .with_description("First test argument") .with_required(true), - PromptArgument::new("style") - .with_description("The greeting style") + PromptArgument::new("arg2") + .with_description("Second test argument") .with_required(false), ]), ), @@ -692,14 +692,11 @@ impl ServerHandler for ConformanceServer { .with_description("A simple test prompt")), "test_prompt_with_arguments" => { let args = request.arguments.unwrap_or_default(); - let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("World"); - let style = args - .get("style") - .and_then(|v| v.as_str()) - .unwrap_or("friendly"); + let arg1 = args.get("arg1").and_then(|v| v.as_str()).unwrap_or(""); + let arg2 = args.get("arg2").and_then(|v| v.as_str()).unwrap_or(""); Ok(GetPromptResult::new(vec![PromptMessage::new_text( Role::User, - format!("Please greet {} in a {} style.", name, style), + format!("Prompt with arguments: arg1='{}', arg2='{}'", arg1, arg2), )]) .with_description("A prompt with arguments")) } diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index 680136f8e..a468de8fb 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -297,7 +297,7 @@ impl ContentBlock { ContentBlock::Resource(EmbeddedResource::new( ResourceContents::TextResourceContents { uri: uri.into(), - mime_type: Some("text".to_string()), + mime_type: Some("text/plain".to_string()), text: content.into(), meta: None, }, diff --git a/crates/rmcp/src/model/resource.rs b/crates/rmcp/src/model/resource.rs index a5ad95061..0381d4d82 100644 --- a/crates/rmcp/src/model/resource.rs +++ b/crates/rmcp/src/model/resource.rs @@ -193,7 +193,7 @@ impl ResourceContents { pub fn text(text: impl Into, uri: impl Into) -> Self { Self::TextResourceContents { uri: uri.into(), - mime_type: Some("text".into()), + mime_type: Some("text/plain".into()), text: text.into(), meta: None, } diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 32bcf8f0e..21adb38b3 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -49,6 +49,7 @@ impl OperationDescriptor { self } + /// Time-to-live in milliseconds, matching `TaskMetadata.ttl` from the MCP spec. pub fn with_ttl(mut self, ttl: u64) -> Self { self.ttl = Some(ttl); self @@ -75,7 +76,11 @@ pub trait OperationResultTransport: Send + Sync + 'static { } // ===== Operation Processor ===== -pub const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; // 5 minutes +#[deprecated(note = "use DEFAULT_TASK_TIMEOUT_MS; ttl values are milliseconds per the MCP spec")] +pub const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; +/// Default execution timeout (5 minutes), in milliseconds, applied when a +/// descriptor does not specify a `ttl`. +pub const DEFAULT_TASK_TIMEOUT_MS: u64 = 300_000; /// Operation processor that coordinates extractors and handlers pub struct OperationProcessor { /// Currently running tasks keyed by id @@ -165,13 +170,13 @@ impl OperationProcessor { fn spawn_async_task(&mut self, message: OperationMessage) { let OperationMessage { descriptor, future } = message; let task_id = descriptor.operation_id.clone(); - let timeout_secs = descriptor.ttl.or(Some(DEFAULT_TASK_TIMEOUT_SECS)); + let timeout_ms = descriptor.ttl.or(Some(DEFAULT_TASK_TIMEOUT_MS)); let sender = self.task_result_sender.clone(); let descriptor_for_result = descriptor.clone(); let timed_future = async move { - if let Some(secs) = timeout_secs { - match timeout(Duration::from_secs(secs), future).await { + if let Some(ms) = timeout_ms { + match timeout(Duration::from_millis(ms), future).await { Ok(result) => result, Err(_) => Err(Error::TaskError("Operation timed out".to_string())), } @@ -191,7 +196,7 @@ impl OperationProcessor { let running_task = RunningTask { task_handle: handle, started_at: std::time::Instant::now(), - timeout: timeout_secs, + timeout: timeout_ms, descriptor, }; self.running_tasks.insert(task_id, running_task); @@ -213,7 +218,7 @@ impl OperationProcessor { for (task_id, task) in &self.running_tasks { if let Some(timeout_duration) = task.timeout { - if now.duration_since(task.started_at).as_secs() > timeout_duration { + if now.duration_since(task.started_at).as_millis() > u128::from(timeout_duration) { task.task_handle.abort(); timed_out_tasks.push(task_id.clone()); } diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 353c911ae..9c15d4519 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1687,7 +1687,10 @@ impl AuthorizationManager { debug!("refresh token present, attempting refresh"); let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string()); - let mut refresh_request = oauth_client.exchange_refresh_token(&refresh_token_value); + let mut refresh_request = oauth_client + .exchange_refresh_token(&refresh_token_value) + // RFC 8707: the resource indicator is required on token requests, including refreshes + .add_extra_param("resource", self.base_url.to_string()); let mut refresh_scopes = stored_credentials.granted_scopes; self.add_offline_access_if_supported(&mut refresh_scopes); for scope in refresh_scopes { @@ -5489,6 +5492,42 @@ mod tests { assert_eq!(scope_parts, vec!["read", "write"]); } + #[tokio::test] + async fn refresh_token_includes_resource_parameter() { + let (base_url, captured) = start_token_server().await; + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{}/authorize", base_url), + token_endpoint: format!("{}/token", base_url), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + + let stored = StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }; + manager.credential_store.save(stored).await.unwrap(); + + manager.refresh_token().await.unwrap(); + + let body = captured.lock().unwrap().take().unwrap(); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) + .into_owned() + .collect(); + assert_eq!( + params.get("resource").map(String::as_str), + Some("http://localhost/"), + "refresh requests must carry the RFC 8707 resource parameter, got body: {body}" + ); + } + #[tokio::test] async fn refresh_token_adds_offline_access_when_as_supports_it() { let (base_url, captured) = start_token_server().await; diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index ca0f4af50..6f9d6604b 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -80,6 +80,38 @@ async fn rejects_duplicate_operation_ids() { assert!(format!("{err}").contains("already running")); } +#[tokio::test] +async fn ttl_is_interpreted_as_milliseconds() { + let mut processor = OperationProcessor::new(); + let descriptor = OperationDescriptor::new("slow", "dummy").with_ttl(50); + let future = Box::pin(async { + tokio::time::sleep(Duration::from_millis(500)).await; + Ok(Box::new(DummyTransport { + id: "slow".to_string(), + value: 0, + }) as Box) + }); + + processor + .submit_operation(OperationMessage::new(descriptor, future)) + .expect("submit operation"); + + tokio::time::sleep(Duration::from_millis(200)).await; + let results = processor.peek_completed(); + assert_eq!( + results.len(), + 1, + "50ms ttl should have timed out the operation well within 200ms" + ); + match &results[0].result { + Err(err) => assert!( + err.to_string().contains("timed out"), + "unexpected error: {err}" + ), + Ok(_) => panic!("expected the operation to time out, but it completed"), + } +} + #[test] fn task_status_notification_param_preserves_meta() { let raw = json!({ From 95490facd61a4f5c9bf5f6abe4a621eaa80cc3eb Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:05:09 -0400 Subject: [PATCH 211/333] feat: reject auth servers lacking S256 PKCE support (#955) --- crates/rmcp/src/transport/auth.rs | 62 +++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 9c15d4519..35c4c1643 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -477,6 +477,9 @@ pub enum AuthError { #[error("Metadata error: {0}")] MetadataError(String), + #[error("Authorization server does not support the required PKCE code challenge method (S256)")] + PkceUnsupported, + #[error("URL parse error: {0}")] UrlError(#[from] url::ParseError), @@ -1159,18 +1162,21 @@ impl AuthorizationManager { } } - // for PKCE, we always send s256 since oauth 2.1 requires servers to support it, - // but warn if the server metadata suggests otherwise + // The client always sends an S256 challenge. A server that advertises + // methods without S256 can't do the flow we require, so refuse it. A + // server that omits the field is tolerated: it usually means the server + // didn't advertise PKCE, not that it lacks S256. match &metadata.code_challenge_methods_supported { Some(methods) if !methods.iter().any(|m| m == "S256") => { + return Err(AuthError::PkceUnsupported); + } + None => { warn!( - ?methods, - "server does not advertise S256 in code_challenge_methods_supported, \ - proceeding with S256 anyway as oauth 2.1 requires it. \ - The server is not compliant with the specification!" + "authorization server metadata omits code_challenge_methods_supported; \ + proceeding with an S256 challenge anyway" ); } - _ => {} + Some(_) => {} } Ok(()) @@ -4307,19 +4313,43 @@ mod tests { assert!(manager.validate_server_metadata("code").is_err()); } - #[tokio::test] - async fn test_validate_as_metadata_passes_without_pkce_s256() { - let mut manager = AuthorizationManager::new("https://example.com") - .await - .unwrap(); - let metadata = AuthorizationMetadata { + fn as_metadata_with_pkce(methods: Option>) -> AuthorizationMetadata { + AuthorizationMetadata { authorization_endpoint: "https://auth.example.com/authorize".to_string(), token_endpoint: "https://auth.example.com/token".to_string(), response_types_supported: Some(vec!["code".to_string()]), - code_challenge_methods_supported: Some(vec!["plain".to_string()]), + code_challenge_methods_supported: methods, ..Default::default() - }; - manager.set_metadata(metadata); + } + } + + #[tokio::test] + async fn test_validate_as_metadata_rejects_without_pkce_s256() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + manager.set_metadata(as_metadata_with_pkce(Some(vec!["plain".to_string()]))); + assert!(matches!( + manager.validate_server_metadata("code"), + Err(AuthError::PkceUnsupported) + )); + } + + #[tokio::test] + async fn test_validate_as_metadata_allows_absent_pkce_methods_by_default() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + manager.set_metadata(as_metadata_with_pkce(None)); + assert!(manager.validate_server_metadata("code").is_ok()); + } + + #[tokio::test] + async fn test_validate_as_metadata_passes_with_pkce_s256() { + let mut manager = AuthorizationManager::new("https://example.com") + .await + .unwrap(); + manager.set_metadata(as_metadata_with_pkce(Some(vec!["S256".to_string()]))); assert!(manager.validate_server_metadata("code").is_ok()); } From 45f2f728819cb1018973a83d16feace7174cdc82 Mon Sep 17 00:00:00 2001 From: King Star Date: Wed, 8 Jul 2026 08:06:46 +0800 Subject: [PATCH 212/333] fix: fail orphaned streamable HTTP responses on reinit (#914) * fix: fail orphaned streamable HTTP responses on reinit * fix: update crates/rmcp/src/transport/streamable_http_client.rs --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- .../src/transport/streamable_http_client.rs | 127 +++++++++- .../test_streamable_http_stale_session.rs | 223 +++++++++++++++++- 2 files changed, 343 insertions(+), 7 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index a2c1a7b19..6f6edef78 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -1,4 +1,9 @@ -use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration}; +use std::{ + borrow::Cow, + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; use futures::{Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use http::{HeaderName, HeaderValue}; @@ -12,8 +17,8 @@ use super::common::client_side_sse::{ExponentialBackoff, SseRetryPolicy, SseStre use crate::{ RoleClient, model::{ - ClientJsonRpcMessage, ClientNotification, InitializedNotification, ServerJsonRpcMessage, - ServerResult, + ClientJsonRpcMessage, ClientNotification, ErrorData, InitializedNotification, RequestId, + ServerJsonRpcMessage, ServerResult, }, transport::{ common::client_side_sse::SseAutoReconnectStream, @@ -298,6 +303,79 @@ impl StreamableHttpClientWorker { } impl StreamableHttpClientWorker { + fn client_request_id(message: &ClientJsonRpcMessage) -> Option { + match message { + ClientJsonRpcMessage::Request(request) => Some(request.id.clone()), + _ => None, + } + } + + fn server_response_id(message: &ServerJsonRpcMessage) -> Option<&RequestId> { + match message { + ServerJsonRpcMessage::Response(response) => Some(&response.id), + ServerJsonRpcMessage::Error(error) => error.id.as_ref(), + _ => None, + } + } + + fn mark_stream_response_pending( + pending_stream_response_ids: &mut HashSet, + request_id: Option, + ) { + if let Some(request_id) = request_id { + pending_stream_response_ids.insert(request_id); + } + } + + fn clear_stream_response_pending( + pending_stream_response_ids: &mut HashSet, + message: &ServerJsonRpcMessage, + ) { + if let Some(id) = Self::server_response_id(message) { + pending_stream_response_ids.remove(id); + } + } + + async fn drain_queued_stream_messages( + sse_worker_rx: &mut tokio::sync::mpsc::Receiver, + context: &mut super::worker::WorkerContext, + pending_stream_response_ids: &mut HashSet, + ) -> Result<(), WorkerQuitReason>> { + loop { + match sse_worker_rx.try_recv() { + Ok(message) => { + Self::clear_stream_response_pending(pending_stream_response_ids, &message); + context.send_to_handler(message).await?; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => return Ok(()), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return Ok(()), + } + } + } + + async fn fail_pending_stream_responses( + context: &mut super::worker::WorkerContext, + pending_stream_response_ids: &mut HashSet, + ) -> Result<(), WorkerQuitReason>> { + if pending_stream_response_ids.is_empty() { + return Ok(()); + } + + let pending_ids = std::mem::take(pending_stream_response_ids); + for id in pending_ids { + context + .send_to_handler(ServerJsonRpcMessage::error( + ErrorData::internal_error( + "streamable HTTP session was re-initialized before the response arrived", + None, + ), + Some(id), + )) + .await?; + } + Ok(()) + } + /// Convert a raw SSE stream into a JSON-RPC message stream without /// reconnection logic. fn raw_sse_to_jsonrpc( @@ -557,6 +635,7 @@ impl Worker for StreamableHttpClientWorker { StreamResult(Result<(), StreamableHttpError>), } let mut streams = tokio::task::JoinSet::new(); + let mut pending_stream_response_ids = HashSet::new(); if let Some(session_id) = &session_id { let client = self.client.clone(); let uri = config.uri.clone(); @@ -646,6 +725,7 @@ impl Worker for StreamableHttpClientWorker { match event { Event::ClientMessage(send_request) => { let WorkerSendRequest { message, responder } = send_request; + let request_id = Self::client_request_id(&message); // Pass a clone to the first attempt so `message` is retained for a // potential re-init retry. `post_message` takes ownership and the // trait cannot be changed, so the clone is unavoidable. @@ -679,9 +759,26 @@ impl Worker for StreamableHttpClientWorker { .await { Ok((new_session_id, new_protocol_headers)) => { - // Old streams hold the stale session ID; abort them - // so the new standalone SSE stream takes over. + // Old streams hold the stale session ID. Stop them first + // so no late stale-session messages can arrive after the + // pending requests below are completed. streams.abort_all(); + while streams.join_next().await.is_some() {} + + // Forward any already queued response messages and fail + // the remaining accepted requests so callers do not wait + // forever for responses that can no longer arrive. + Self::drain_queued_stream_messages( + &mut sse_worker_rx, + &mut context, + &mut pending_stream_response_ids, + ) + .await?; + Self::fail_pending_stream_responses( + &mut context, + &mut pending_stream_response_ids, + ) + .await?; session_id = new_session_id; protocol_headers = new_protocol_headers; @@ -765,6 +862,10 @@ impl Worker for StreamableHttpClientWorker { match retry_response { Err(e) => Err(e), Ok(StreamableHttpPostResponse::Accepted) => { + Self::mark_stream_response_pending( + &mut pending_stream_response_ids, + request_id, + ); tracing::trace!( "client message accepted after re-init" ); @@ -775,6 +876,10 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { + Self::mark_stream_response_pending( + &mut pending_stream_response_ids, + request_id, + ); streams.spawn(Self::execute_sse_stream( Self::raw_sse_to_jsonrpc(stream), sse_worker_tx.clone(), @@ -792,6 +897,10 @@ impl Worker for StreamableHttpClientWorker { } Err(e) => Err(e), Ok(StreamableHttpPostResponse::Accepted) => { + Self::mark_stream_response_pending( + &mut pending_stream_response_ids, + request_id, + ); tracing::trace!("client message accepted"); Ok(()) } @@ -800,6 +909,10 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { + Self::mark_stream_response_pending( + &mut pending_stream_response_ids, + request_id, + ); streams.spawn(Self::execute_sse_stream( Self::raw_sse_to_jsonrpc(stream), sse_worker_tx.clone(), @@ -813,6 +926,10 @@ impl Worker for StreamableHttpClientWorker { let _ = responder.send(send_result); } Event::ServerMessage(json_rpc_message) => { + Self::clear_stream_response_pending( + &mut pending_stream_response_ids, + &json_rpc_message, + ); // send the message to the handler if let Err(e) = context.send_to_handler(json_rpc_message).await { break 'main_loop Err(e); diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index d96d83c73..9aa0309ad 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -5,15 +5,25 @@ not(feature = "local") ))] -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, +}; +use futures::stream; +use http::{HeaderName, HeaderValue}; use rmcp::{ ServiceError, ServiceExt, - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, + model::{ + CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode, + ErrorData, InitializeResult, PingRequest, RequestId, ServerCapabilities, + ServerJsonRpcMessage, ServerResult, + }, transport::{ StreamableHttpClientTransport, streamable_http_client::{ StreamableHttpClient, StreamableHttpClientTransportConfig, StreamableHttpError, + StreamableHttpPostResponse, }, streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -25,6 +35,215 @@ use tokio_util::sync::CancellationToken; mod common; use common::calculator::Calculator; +#[derive(Debug, thiserror::Error)] +#[error("mock streamable http client error")] +struct MockClientError; + +#[derive(Clone)] +struct ReinitDropsAcceptedResponseClient { + state: Arc>, + stale_stream_cancelled: CancellationToken, + initial_request_accepted: Arc, + final_retry_accepted: Arc, +} + +struct MockState { + session_counter: usize, + posts: VecDeque, +} + +enum MockPost { + Initialize, + Initialized, + Accepted, + SessionExpired, +} + +impl ReinitDropsAcceptedResponseClient { + fn new() -> Self { + Self { + state: Arc::new(tokio::sync::Mutex::new(MockState { + session_counter: 0, + posts: VecDeque::from([ + MockPost::Initialize, + MockPost::Initialized, + MockPost::Accepted, + MockPost::SessionExpired, + MockPost::Initialize, + MockPost::Initialized, + MockPost::Accepted, + ]), + })), + stale_stream_cancelled: CancellationToken::new(), + initial_request_accepted: Arc::new(tokio::sync::Semaphore::new(0)), + final_retry_accepted: Arc::new(tokio::sync::Semaphore::new(0)), + } + } +} + +impl StreamableHttpClient for ReinitDropsAcceptedResponseClient { + type Error = MockClientError; + + async fn post_message( + &self, + _uri: Arc, + message: ClientJsonRpcMessage, + _session_id: Option>, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + let mut state = self.state.lock().await; + match state + .posts + .pop_front() + .expect("unexpected mock post_message call") + { + MockPost::Initialize => { + state.session_counter += 1; + let id = match message { + ClientJsonRpcMessage::Request(request) => request.id, + other => panic!("expected initialize request, got {other:?}"), + }; + Ok(StreamableHttpPostResponse::Json( + ServerJsonRpcMessage::response( + ServerResult::InitializeResult(InitializeResult::new( + ServerCapabilities::builder().enable_tools().build(), + )), + id, + ), + Some(format!("session-{}", state.session_counter)), + )) + } + MockPost::Initialized => { + assert!( + matches!(message, ClientJsonRpcMessage::Notification(_)), + "expected initialized notification, got {message:?}" + ); + Ok(StreamableHttpPostResponse::Accepted) + } + MockPost::Accepted => { + if state.posts.is_empty() { + self.final_retry_accepted.add_permits(1); + } else { + self.initial_request_accepted.add_permits(1); + } + Ok(StreamableHttpPostResponse::Accepted) + } + MockPost::SessionExpired => Err(StreamableHttpError::SessionExpired), + } + } + + async fn delete_session( + &self, + _uri: Arc, + _session_id: Arc, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + Ok(()) + } + + async fn get_stream( + &self, + _uri: Arc, + session_id: Arc, + _last_event_id: Option, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + StreamableHttpError, + > { + if session_id.as_ref() == "session-1" { + let cancel = self.stale_stream_cancelled.clone(); + Ok(Box::pin(stream::once(async move { + cancel.cancelled_owned().await; + Ok(sse_stream::Sse { + event: None, + data: Some( + serde_json::to_string(&ServerJsonRpcMessage::error( + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "stale stream should not deliver after re-init", + None, + ), + Some(RequestId::Number(2)), + )) + .expect("serialize stale error"), + ), + id: None, + retry: None, + }) + }))) + } else { + Ok(Box::pin(stream::pending())) + } + } +} + +#[tokio::test] +async fn test_reinitialization_completes_accepted_sse_request_instead_of_hanging() +-> anyhow::Result<()> { + let mock_client = ReinitDropsAcceptedResponseClient::new(); + let initial_request_accepted = mock_client.initial_request_accepted.clone(); + let final_retry_accepted = mock_client.final_retry_accepted.clone(); + let transport = StreamableHttpClientTransport::with_client( + mock_client, + StreamableHttpClientTransportConfig::with_uri("mock://mcp"), + ); + let mut client = ClientInfo::default().serve(transport).await?; + + let peer = client.peer().clone(); + let pending_call = tokio::spawn(async move { + peer.call_tool(CallToolRequestParams::new("slow_tool")) + .await + }); + + let _initial_permit = tokio::time::timeout( + std::time::Duration::from_secs(1), + initial_request_accepted.acquire(), + ) + .await + .expect("initial accepted request should be observed") + .expect("initial accepted request semaphore should stay open"); + + let reinit_trigger = { + let peer = client.peer().clone(); + tokio::spawn(async move { peer.list_tools(None).await }) + }; + + let _retry_permit = tokio::time::timeout( + std::time::Duration::from_secs(1), + final_retry_accepted.acquire(), + ) + .await + .expect("re-initialization retry should be accepted") + .expect("re-initialization retry semaphore should stay open"); + + let err = tokio::time::timeout(std::time::Duration::from_millis(100), pending_call) + .await + .expect("accepted SSE-backed request should complete instead of hanging")? + .expect_err( + "accepted request should fail after re-initialization drops its response stream", + ); + + match err { + ServiceError::McpError(error) => { + assert_eq!(error.code, ErrorCode::INTERNAL_ERROR); + assert!( + error.message.contains("session"), + "expected session-related error, got: {error}" + ); + } + other => panic!("expected McpError for orphaned request, got: {other:?}"), + } + + reinit_trigger.abort(); + let _ = client.close().await; + + Ok(()) +} + #[tokio::test] async fn test_stale_session_id_returns_status_aware_error() -> anyhow::Result<()> { let ct = CancellationToken::new(); From dbda50c0eb3fbecfb14e6aa4179c180458bb393a Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:24:36 -0400 Subject: [PATCH 213/333] fix: don't respond to cancelled requests (#957) * fix: don't respond to cancelled requests * chore: remove redundant comment * fix: update SSE stream constructor --- crates/rmcp/src/service.rs | 8 +- .../common/reqwest/streamable_http_client.rs | 4 +- crates/rmcp/tests/test_cancelled_response.rs | 211 ++++++++++++++++++ 3 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 crates/rmcp/tests/test_cancelled_response.rs diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 29d822a58..c94563b71 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -1103,9 +1103,11 @@ where JsonRpcMessage::Error(error) => error.id.as_ref(), _ => None, } { - if let Some(ct) = local_ct_pool.remove(id) { - ct.cancel(); - } + let Some(ct) = local_ct_pool.remove(id) else { + tracing::debug!(%id, "dropping response for cancelled request"); + continue; + }; + ct.cancel(); let send = transport.send(m); let current_span = tracing::Span::current(); response_send_tasks.spawn(async move { diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 37c2b08fe..57c12f1ca 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -84,7 +84,7 @@ impl StreamableHttpClient for reqwest::Client { return Err(StreamableHttpError::UnexpectedContentType(None)); } } - let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); + let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); Ok(event_stream) } @@ -223,7 +223,7 @@ impl StreamableHttpClient for reqwest::Client { } match content_type.as_deref() { Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { - let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); + let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) } Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { diff --git a/crates/rmcp/tests/test_cancelled_response.rs b/crates/rmcp/tests/test_cancelled_response.rs new file mode 100644 index 000000000..80cd9e8b2 --- /dev/null +++ b/crates/rmcp/tests/test_cancelled_response.rs @@ -0,0 +1,211 @@ +//! A receiver SHOULD NOT send a response for a request it has already been told +//! to cancel. This drives a real stdio server with raw JSON-RPC: the tool blocks +//! until the request is cancelled, so its result is only produced *after* the +//! cancellation — the service loop must drop it rather than write it to the wire. + +use std::{collections::BTreeSet, process::Stdio, time::Duration}; + +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}, + service::RequestContext, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}, + process::{Child, Command}, +}; + +const HELPER_ENV: &str = "RMCP_CANCELLED_RESPONSE_HELPER"; +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { + let mut child = spawn_helper(); + let mut writer = child.stdin.take().expect("helper stdin"); + let stdout = child.stdout.take().expect("helper stdout"); + let mut reader = BufReader::new(stdout); + + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "raw-test-client", "version": "0.0.0" } + } + }), + ) + .await?; + collect_ids_until(&mut reader, 1, READ_TIMEOUT).await?; + send_json( + &mut writer, + &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + ) + .await?; + + // Start a request that blocks until cancelled, then cancel it. Its response is + // produced only after the cancellation arrives, so it must be suppressed. + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "wait-for-cancel", "arguments": {} } + }), + ) + .await?; + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { "requestId": 2 } + }), + ) + .await?; + // A ping proves the server is alive past the cancellation, so the absence of + // an id=2 response is genuine suppression rather than a dead connection. + send_json( + &mut writer, + &json!({ "jsonrpc": "2.0", "id": 3, "method": "ping" }), + ) + .await?; + + let seen = collect_ids_until(&mut reader, 3, READ_TIMEOUT).await?; + assert!(seen.contains(&3)); + assert!(!seen.contains(&2)); + + drop(writer); + wait_for_child(&mut child).await; + Ok(()) +} + +struct WaitForCancelServer; + +impl ServerHandler for WaitForCancelServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + context.ct.cancelled().await; + Ok(CallToolResult::success(vec![ContentBlock::text( + "late response", + )])) + } +} + +#[tokio::test] +async fn cancelled_response_helper() -> anyhow::Result<()> { + if std::env::var(HELPER_ENV).as_deref() != Ok("1") { + return Ok(()); + } + run_helper_server().await?; + Ok(()) +} + +#[cfg(feature = "local")] +async fn run_helper_server() -> anyhow::Result<()> { + tokio::task::LocalSet::new() + .run_until(serve_helper_stdio()) + .await +} + +#[cfg(not(feature = "local"))] +async fn run_helper_server() -> anyhow::Result<()> { + serve_helper_stdio().await +} + +async fn serve_helper_stdio() -> anyhow::Result<()> { + let server = WaitForCancelServer.serve(rmcp::transport::stdio()).await?; + server.waiting().await?; + Ok(()) +} + +fn spawn_helper() -> Child { + let exe = std::env::current_exe().expect("current test exe"); + Command::new(exe) + .arg("--exact") + .arg("cancelled_response_helper") + .arg("--quiet") + .arg("--nocapture") + .arg("--test-threads") + .arg("1") + .env(HELPER_ENV, "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("spawn helper") +} + +async fn wait_for_child(child: &mut Child) { + let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await; + if child.id().is_some() { + let _ = child.kill().await; + } +} + +async fn send_json(writer: &mut W, message: &Value) -> anyhow::Result<()> +where + W: AsyncWrite + Unpin, +{ + let serialized = serde_json::to_string(message)?; + writer.write_all(serialized.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + Ok(()) +} + +/// Read response lines, collecting every message id seen, until `stop_id` is seen +/// (then a short grace read to catch any straggler) or the timeout elapses. +async fn collect_ids_until( + reader: &mut BufReader, + stop_id: u64, + timeout: Duration, +) -> anyhow::Result> +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut seen = BTreeSet::new(); + let mut deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let mut line = String::new(); + let Ok(read_result) = tokio::time::timeout(remaining, reader.read_line(&mut line)).await + else { + break; + }; + if read_result? == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + if let Some(id) = value.get("id").and_then(Value::as_u64) { + seen.insert(id); + if id == stop_id { + // Give any late (incorrectly-sent) response a brief window to arrive. + deadline = tokio::time::Instant::now() + Duration::from_millis(300); + } + } + } + Ok(seen) +} From a03793530d39d3be7beef3b44eed509d2ac04ab1 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:25:12 -0400 Subject: [PATCH 214/333] fix: pass client conformance suite (#960) * fix(auth): support oauth metadata fallbacks * ci: run client conformance scenarios * fix: pass full client conformance suite * ci: run full client conformance suite * fix: update SSE stream constructor --- .github/workflows/conformance.yml | 33 +- conformance/src/bin/client.rs | 29 +- crates/rmcp/src/transport/auth.rs | 399 +++++++++++++++++- .../src/transport/streamable_http_client.rs | 81 +++- 4 files changed, 512 insertions(+), 30 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index ac1dfe204..787dc4b0c 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -29,8 +29,7 @@ jobs: - uses: Swatinem/rust-cache@v2 # Build the whole package (server + client bins): the conformance crate is - # excluded from the workspace default-members, so this is the only CI job - # that catches compile breakage in it. + # excluded from the workspace default-members. - name: Build conformance binaries run: cargo build -p mcp-conformance @@ -75,3 +74,33 @@ jobs: with: name: conformance-server-results path: conformance-results + + client: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build conformance binaries + run: cargo build -p mcp-conformance + + - name: Run full client conformance suite + run: | + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \ + --command "$(pwd)/target/debug/conformance-client" \ + --suite all \ + --spec-version 2025-11-25 \ + -o conformance-client-results/full + + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: conformance-client-results + path: conformance-client-results diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 8dabff0ff..d34c51b06 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -180,6 +180,7 @@ impl ClientHandler for FullClientHandler { const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json"; const REDIRECT_URI: &str = "http://localhost:3000/callback"; +const SCOPE_STEP_UP_ESCALATED_SCOPES: &[&str] = &["mcp:basic", "mcp:write"]; /// Perform the headless OAuth authorization-code flow. /// @@ -365,13 +366,10 @@ async fn run_auth_scope_step_up_client( // Drop old client, re-auth with upgraded scopes client.cancel().await.ok(); - // Re-do the full flow; the server will give us the right scopes - // on the second authorization request. let mut oauth2 = OAuthState::new(server_url, None).await?; - // Pass the escalated scope hint oauth2 .start_authorization_with_metadata_url( - &[], + SCOPE_STEP_UP_ESCALATED_SCOPES, REDIRECT_URI, Some("conformance-client"), Some(CIMD_CLIENT_METADATA_URL), @@ -387,7 +385,9 @@ async fn run_auth_scope_step_up_client( ) .await?; - let am2 = oauth2.into_authorization_manager().unwrap(); + let am2 = oauth2.into_authorization_manager().ok_or_else(|| { + anyhow::anyhow!("Missing authorization manager after step-up") + })?; let auth_client2 = AuthClient::new(reqwest::Client::default(), am2); let transport2 = StreamableHttpClientTransport::with_client( auth_client2, @@ -435,7 +435,9 @@ async fn run_auth_scope_retry_limit_client( ) .await?; - let am = oauth.into_authorization_manager().unwrap(); + let am = oauth + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("Missing authorization manager"))?; let auth_client = AuthClient::new(reqwest::Client::default(), am); let transport = StreamableHttpClientTransport::with_client( auth_client, @@ -443,7 +445,18 @@ async fn run_auth_scope_retry_limit_client( ); let client = BasicClientHandler.serve(transport).await?; - let tools = client.list_tools(Default::default()).await?; + let tools = match client.list_tools(Default::default()).await { + Ok(tools) => tools, + Err(err) => { + tracing::info!( + "Scope retry limit scenario stopped after authorization attempt {}: {}", + attempt + 1, + err + ); + client.cancel().await.ok(); + return Ok(()); + } + }; let mut got_403 = false; for tool in &tools.tools { @@ -467,7 +480,7 @@ async fn run_auth_scope_retry_limit_client( attempt += 1; if attempt >= max_retries { tracing::info!("Reached retry limit ({max_retries}), giving up"); - return Err(anyhow::anyhow!("Scope retry limit reached")); + return Ok(()); } } Ok(()) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 35c4c1643..b1f505ea0 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -30,6 +30,12 @@ use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024; const MAX_OAUTH_DISCOVERY_REDIRECTS: usize = 10; +const RESOURCE_METADATA_POST_PROBE_BODY: &str = concat!( + r#"{"jsonrpc":"2.0","id":"auth-discovery","method":"initialize","params":{"#, + r#""protocolVersion":"2024-11-05","capabilities":{},"#, + r#""clientInfo":{"name":"rmcp-auth-discovery","version":"0.0.0"}}"#, + r#"}"# +); const CLOUD_METADATA_HOSTS: &[&str] = &[ "metadata", "metadata.google.internal", @@ -894,11 +900,31 @@ impl AuthorizationManager { } } - fn is_allowed_authorization_server_metadata_url(url: &Url) -> bool { - Self::is_http_url(url) - && url - .host_str() - .is_some_and(|host| !Self::is_disallowed_metadata_host(host)) + fn is_loopback_metadata_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + host == "localhost" + || host.ends_with(".localhost") + || matches!(host.parse::(), Ok(IpAddr::V4(addr)) if addr.is_loopback()) + || matches!(host.parse::(), Ok(IpAddr::V6(addr)) if addr.is_loopback()) + } + + fn is_allowed_authorization_server_metadata_url(base_url: &Url, url: &Url) -> bool { + if !Self::is_http_url(url) { + return false; + } + + let Some(host) = url.host_str() else { + return false; + }; + + if !Self::is_disallowed_metadata_host(host) { + return true; + } + + base_url + .host_str() + .is_some_and(Self::is_loopback_metadata_host) + && Self::is_loopback_metadata_host(host) } fn resolve_resource_metadata_url(value: &str, base_url: &Url) -> Option { @@ -1077,9 +1103,25 @@ impl AuthorizationManager { return Ok(metadata); } - // No valid authorization metadata found - return error instead of guessing - // OAuth endpoints must be discovered from the server, not constructed by the client - Err(AuthError::NoAuthorizationSupport) + debug!("falling back to legacy OAuth endpoints derived from the base URL"); + Ok(Self::legacy_authorization_metadata(&self.base_url)) + } + + fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata { + let endpoint = |path: &str| { + let mut url = base_url.clone(); + url.set_query(None); + url.set_fragment(None); + url.set_path(path); + url.to_string() + }; + + AuthorizationMetadata { + authorization_endpoint: endpoint("/authorize"), + token_endpoint: endpoint("/token"), + registration_endpoint: Some(endpoint("/register")), + ..Default::default() + } } /// get client id and credentials @@ -1891,7 +1933,7 @@ impl AuthorizationManager { }, }; - if !Self::is_allowed_authorization_server_metadata_url(&candidate_url) { + if !Self::is_allowed_authorization_server_metadata_url(&self.base_url, &candidate_url) { warn!("rejecting authorization server metadata URL `{candidate_url}`"); continue; } @@ -1937,6 +1979,7 @@ impl AuthorizationManager { && actual == expected.trim_end_matches('/')) || (Self::is_root_resource_identifier(actual) && expected == actual.trim_end_matches('/')) + || Self::root_resource_identifier_covers_path(actual, expected) } fn is_root_resource_identifier(value: &str) -> bool { @@ -1944,9 +1987,24 @@ impl AuthorizationManager { .is_ok_and(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none()) } + fn root_resource_identifier_covers_path(root_resource: &str, path_resource: &str) -> bool { + let Ok(root_resource) = Url::parse(root_resource) else { + return false; + }; + let Ok(path_resource) = Url::parse(path_resource) else { + return false; + }; + + root_resource.path() == "/" + && root_resource.query().is_none() + && root_resource.fragment().is_none() + && path_resource.path() != "/" + && Self::is_same_origin(&root_resource, &path_resource) + } + async fn discover_resource_metadata_url(&self) -> Result, AuthError> { if let Ok(Some(resource_metadata_url)) = - self.fetch_resource_metadata_url(&self.base_url).await + self.fetch_resource_metadata_url(&self.base_url, true).await { return Ok(Some(resource_metadata_url)); } @@ -1960,8 +2018,9 @@ impl AuthorizationManager { discovery_url.set_query(None); discovery_url.set_fragment(None); discovery_url.set_path(&candidate_path); - if let Ok(Some(resource_metadata_url)) = - self.fetch_resource_metadata_url(&discovery_url).await + if let Ok(Some(resource_metadata_url)) = self + .fetch_resource_metadata_url(&discovery_url, false) + .await { return Ok(Some(resource_metadata_url)); } @@ -1972,7 +2031,11 @@ impl AuthorizationManager { /// Extract the resource metadata url from the WWW-Authenticate header value. /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for - async fn fetch_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { + async fn fetch_resource_metadata_url( + &self, + url: &Url, + allow_post_probe: bool, + ) -> Result, AuthError> { let response = match self.discovery_get(url).await { Ok(r) => r, Err(e) => { @@ -1981,16 +2044,64 @@ impl AuthorizationManager { } }; - if response.status() == StatusCode::OK { - return Ok(Some(url.clone())); - } else if response.status() != StatusCode::UNAUTHORIZED { + match response.status() { + StatusCode::OK => Ok(Some(url.clone())), + StatusCode::UNAUTHORIZED => Ok(self + .extract_resource_metadata_url_from_www_authenticate(&response) + .await), + StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED if allow_post_probe => { + self.fetch_resource_metadata_url_with_post_probe(url).await + } + status => { + debug!("resource metadata probe returned unexpected status: {status}"); + Ok(None) + } + } + } + + async fn fetch_resource_metadata_url_with_post_probe( + &self, + url: &Url, + ) -> Result, AuthError> { + let request = oauth2::http::Request::builder() + .method("POST") + .uri(url.as_str()) + .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") + .header(CONTENT_TYPE, "application/json") + .body(RESOURCE_METADATA_POST_PROBE_BODY.as_bytes().to_vec()) + .map_err(|error| AuthError::InternalError(error.to_string()))?; + let response = match self + .http_client + .execute(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Stop, + )) + .await + { + Ok(response) => response, + Err(error) => { + debug!("resource metadata POST probe failed: {}", error); + return Ok(None); + } + }; + + if response.status() != StatusCode::UNAUTHORIZED { debug!( - "resource metadata probe returned unexpected status: {}", + "resource metadata POST probe returned unexpected status: {}", response.status() ); return Ok(None); } + Ok(self + .extract_resource_metadata_url_from_www_authenticate(&response) + .await) + } + + async fn extract_resource_metadata_url_from_www_authenticate( + &self, + response: &HttpResponse, + ) -> Option { let mut parsed_url = None; for value in response.headers().get_all(WWW_AUTHENTICATE).iter() { let Ok(value_str) = value.to_str() else { @@ -2009,7 +2120,7 @@ impl AuthorizationManager { } } - Ok(parsed_url) + parsed_url } async fn fetch_resource_metadata_from_url( @@ -3227,6 +3338,13 @@ mod tests { .unwrap() } + fn empty_response(status: u16) -> HttpResponse { + oauth2::http::Response::builder() + .status(status) + .body(Vec::new()) + .unwrap() + } + #[tokio::test] async fn custom_http_client_handles_protected_resource_discovery() { let challenge = oauth2::http::Response::builder() @@ -3290,6 +3408,181 @@ mod tests { ); } + #[tokio::test] + async fn protected_resource_metadata_supports_authorization_server_path_insertion() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(401), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com/tenant1", + "authorization_endpoint": "https://auth.example.com/tenant1/authorize", + "token_endpoint": "https://auth.example.com/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.discover_metadata().await.unwrap(); + + assert_eq!( + ( + metadata.issuer.as_deref(), + metadata.authorization_endpoint.as_str(), + client + .requests() + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + ), + ( + Some("https://auth.example.com/tenant1"), + "https://auth.example.com/tenant1/authorize", + vec![ + "https://mcp.example.com/", + "https://mcp.example.com/.well-known/oauth-protected-resource", + "https://mcp.example.com/.well-known/oauth-protected-resource", + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + ], + ) + ); + } + + #[tokio::test] + async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="/custom/metadata/location.json""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(404), + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + empty_response(404), + empty_response(404), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com/tenant1", + "authorization_endpoint": "https://auth.example.com/tenant1/authorize", + "token_endpoint": "https://auth.example.com/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.discover_metadata().await.unwrap(); + + assert_eq!( + ( + metadata.token_endpoint.as_str(), + client + .requests() + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + ), + ( + "https://auth.example.com/tenant1/token", + vec![ + "https://mcp.example.com/mcp", + "https://mcp.example.com/mcp", + "https://mcp.example.com/custom/metadata/location.json", + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + ], + ) + ); + assert_eq!( + client + .requests() + .iter() + .take(2) + .map(|request| request.method.as_str()) + .collect::>(), + vec!["GET", "POST"] + ); + } + + #[tokio::test] + async fn discover_metadata_falls_back_to_legacy_default_endpoints() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(404), + empty_response(404), + empty_response(404), + empty_response(404), + empty_response(404), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://legacy.example.com/", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.discover_metadata().await.unwrap(); + + assert_eq!( + ( + metadata.authorization_endpoint.as_str(), + metadata.token_endpoint.as_str(), + metadata.registration_endpoint.as_deref(), + client + .requests() + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + ), + ( + "https://legacy.example.com/authorize", + "https://legacy.example.com/token", + Some("https://legacy.example.com/register"), + vec![ + "https://legacy.example.com/", + "https://legacy.example.com/", + "https://legacy.example.com/.well-known/oauth-protected-resource", + "https://legacy.example.com/.well-known/oauth-authorization-server", + "https://legacy.example.com/.well-known/openid-configuration", + ], + ) + ); + } + #[tokio::test] async fn discovery_get_follows_same_origin_redirects() { let client = RecordingOAuthHttpClient::with_responses(vec![ @@ -3371,6 +3664,7 @@ mod tests { "resource": "https://mcp.example.com/mcp", "authorization_servers": [ "http://169.254.169.254/latest/meta-data/", + "http://127.0.0.1:8080/tenant1", "https://auth.example.com" ] }), @@ -3412,6 +3706,63 @@ mod tests { ); } + #[tokio::test] + async fn allows_loopback_authorization_server_when_resource_is_loopback() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="http://localhost/custom-metadata.json""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "http://localhost/mcp", + "authorization_servers": ["http://127.0.0.1:8080/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "http://127.0.0.1:8080/tenant1", + "authorization_endpoint": "http://127.0.0.1:8080/tenant1/authorize", + "token_endpoint": "http://127.0.0.1:8080/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "http://localhost/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.discover_metadata().await.unwrap(); + + assert_eq!( + ( + metadata.issuer.as_deref(), + client + .requests() + .iter() + .map(|request| request.uri.as_str()) + .collect::>(), + ), + ( + Some("http://127.0.0.1:8080/tenant1"), + vec![ + "http://localhost/mcp", + "http://localhost/custom-metadata.json", + "http://127.0.0.1:8080/.well-known/oauth-authorization-server/tenant1", + ], + ) + ); + } + #[tokio::test] async fn protected_resource_discovery_rejects_mismatched_resource() { let challenge = oauth2::http::Response::builder() @@ -3493,6 +3844,10 @@ mod tests { "https://mcp.example.com", "https://mcp.example.com/" )); + assert!(AuthorizationManager::resource_identifiers_match( + "https://mcp.example.com/mcp", + "https://mcp.example.com" + )); assert!(!AuthorizationManager::resource_identifiers_match( "https://mcp.example.com/mcp", @@ -3502,6 +3857,14 @@ mod tests { "https://mcp.example.com/mcp", "https://real.example.com/mcp" )); + assert!(!AuthorizationManager::resource_identifiers_match( + "https://mcp.example.com/mcp", + "https://real.example.com" + )); + assert!(!AuthorizationManager::resource_identifiers_match( + "https://mcp.example.com/mcp", + "https://mcp.example.com?resource=mcp" + )); } #[tokio::test] diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 6f6edef78..871667301 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -407,6 +407,65 @@ impl StreamableHttpClientWorker { }) } + /// Convert an SSE stream into JSON-RPC messages with reconnect semantics. + /// + /// This is used for request-scoped SSE responses as well as the standalone + /// GET stream. A request-scoped stream can close before its response arrives, + /// and SEP-1699 requires the client to honor `retry` and resume with + /// `Last-Event-ID` in that case. + fn reconnecting_sse_to_jsonrpc( + stream: BoxedSseStream, + client: C, + session_id: Arc, + uri: Arc, + auth_header: Option, + custom_headers: HashMap, + retry_config: Arc, + ) -> impl Stream>> + Send + 'static + { + SseAutoReconnectStream::new( + stream, + StreamableHttpClientReconnect { + client, + session_id, + uri, + auth_header, + custom_headers, + }, + retry_config, + ) + } + + /// Convert a POST response SSE stream into JSON-RPC messages. + /// + /// Stateful sessions can resume via GET when the response stream closes + /// before the server sends the matching JSON-RPC response. Stateless + /// transports do not have enough state to resume, so they keep the raw + /// SSE-to-JSON-RPC mapping. + fn response_sse_to_jsonrpc( + stream: BoxedSseStream, + session_id: Option>, + client: C, + uri: Arc, + auth_header: Option, + custom_headers: HashMap, + retry_config: Arc, + ) -> BoxStream<'static, Result>> { + match session_id { + Some(session_id) => Self::reconnecting_sse_to_jsonrpc( + stream, + client, + session_id, + uri, + auth_header, + custom_headers, + retry_config, + ) + .boxed(), + None => Self::raw_sse_to_jsonrpc(stream).boxed(), + } + } + async fn execute_sse_stream( sse_stream: impl Stream>> + Send @@ -880,8 +939,17 @@ impl Worker for StreamableHttpClientWorker { &mut pending_stream_response_ids, request_id, ); + let sse_stream = Self::response_sse_to_jsonrpc( + stream, + session_id.clone(), + self.client.clone(), + config.uri.clone(), + config.auth_header.clone(), + protocol_headers.clone(), + self.config.retry_config.clone(), + ); streams.spawn(Self::execute_sse_stream( - Self::raw_sse_to_jsonrpc(stream), + sse_stream, sse_worker_tx.clone(), true, transport_task_ct.child_token(), @@ -913,8 +981,17 @@ impl Worker for StreamableHttpClientWorker { &mut pending_stream_response_ids, request_id, ); + let sse_stream = Self::response_sse_to_jsonrpc( + stream, + session_id.clone(), + self.client.clone(), + config.uri.clone(), + config.auth_header.clone(), + protocol_headers.clone(), + self.config.retry_config.clone(), + ); streams.spawn(Self::execute_sse_stream( - Self::raw_sse_to_jsonrpc(stream), + sse_stream, sse_worker_tx.clone(), true, transport_task_ct.child_token(), From 6dd7b858c62c3aa7d339b61b7f65fe6df3572973 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:26:57 -0400 Subject: [PATCH 215/333] chore(deps): update p256 requirement from 0.13 to 0.14 (#959) * chore(deps): update p256 requirement from 0.13 to 0.14 Updates the requirements on [p256](https://github.com/RustCrypto/elliptic-curves) to permit the latest version. - [Commits](https://github.com/RustCrypto/elliptic-curves/compare/primeorder/v0.13.0...p256/v0.14.0) --- updated-dependencies: - dependency-name: p256 dependency-version: 0.14.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * fix(conformance): update p256 key parsing --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- conformance/Cargo.toml | 2 +- conformance/src/bin/client.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index 42a5e851f..de9a44dd0 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -32,5 +32,5 @@ anyhow = "1" reqwest = { version = "0.13", features = ["json"] } urlencoding = "2" url = "2" -p256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.14", features = ["ecdsa"] } base64 = "0.22" diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index d34c51b06..9885b998a 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -671,7 +671,7 @@ fn openssl_free_ec_sign(pem: &str, client_id: &str, audience: &str) -> anyhow::R let signing_input = format!("{}.{}", header, payload); // Sign with p256 - let secret_key = p256::ecdsa::SigningKey::from_bytes(raw_key.as_slice().into()) + let secret_key = p256::ecdsa::SigningKey::from_slice(raw_key.as_slice()) .map_err(|e| anyhow::anyhow!("Invalid EC key: {}", e))?; use p256::ecdsa::signature::Signer; let sig: p256::ecdsa::Signature = secret_key.sign(signing_input.as_bytes()); From 519577601db3823616dbd7c4eb84ed569d8e17d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:24:21 -0400 Subject: [PATCH 216/333] chore: release v2.2.0 (#953) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp/CHANGELOG.md | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 192022b12..d7a7caca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "2.1.0", path = "./crates/rmcp" } -rmcp-macros = { version = "2.1.0", path = "./crates/rmcp-macros" } +rmcp = { version = "2.2.0", path = "./crates/rmcp" } +rmcp-macros = { version = "2.2.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "2.1.0" +version = "2.2.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index e33b77c81..1b6c212ec 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08 + +### Added + +- reject auth servers lacking S256 PKCE support ([#955](https://github.com/modelcontextprotocol/rust-sdk/pull/955)) + +### Fixed + +- pass client conformance suite ([#960](https://github.com/modelcontextprotocol/rust-sdk/pull/960)) +- don't respond to cancelled requests ([#957](https://github.com/modelcontextprotocol/rust-sdk/pull/957)) +- fail orphaned streamable HTTP responses on reinit ([#914](https://github.com/modelcontextprotocol/rust-sdk/pull/914)) +- address 2025-11-25 conformance audit findings ([#951](https://github.com/modelcontextprotocol/rust-sdk/pull/951)) + ## [2.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.0.0...rmcp-v2.1.0) - 2026-07-02 ### Added From f4ff56b81d90178cfc44e22c15dee6f969aa3eba Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:03:33 -0400 Subject: [PATCH 217/333] feat!: add MRTR model types (SEP-2322) (#915) * feat!: add SEP-2322 MRTR model types * feat!: remove URLElicitationRequiredError (SEP-2322) --- conformance/src/bin/server.rs | 12 +- crates/rmcp-macros/src/prompt_handler.rs | 1 + crates/rmcp-macros/src/tool_handler.rs | 1 + crates/rmcp/src/handler/client.rs | 11 - crates/rmcp/src/handler/server/prompt.rs | 1 + crates/rmcp/src/handler/server/router/tool.rs | 4 + crates/rmcp/src/handler/server/tool.rs | 1 + crates/rmcp/src/model.rs | 195 +++++++-- crates/rmcp/src/model/meta.rs | 1 - crates/rmcp/src/model/mrtr.rs | 388 ++++++++++++++++++ crates/rmcp/src/model/serde_impl.rs | 12 + crates/rmcp/src/service/server.rs | 7 +- crates/rmcp/tests/test_elicitation.rs | 90 ---- .../list_tools_result.json | 1 + .../client_json_rpc_message_schema.json | 45 ++ ...lient_json_rpc_message_schema_current.json | 45 ++ .../server_json_rpc_message_schema.json | 192 +++++++-- ...erver_json_rpc_message_schema_current.json | 192 +++++++-- crates/rmcp/tests/test_tool_result_meta.rs | 1 + examples/servers/src/common/counter.rs | 6 +- examples/servers/src/elicitation_stdio.rs | 15 +- examples/servers/src/sampling_stdio.rs | 3 +- 22 files changed, 969 insertions(+), 255 deletions(-) create mode 100644 crates/rmcp/src/model/mrtr.rs diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 4fc89bfed..b0518a048 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -204,9 +204,8 @@ impl ServerHandler for ConformanceServer { ), ]; Ok(ListToolsResult { - meta: None, tools, - next_cursor: None, + ..Default::default() }) } @@ -540,7 +539,6 @@ impl ServerHandler for ConformanceServer { _cx: RequestContext, ) -> Result { Ok(ListResourcesResult { - meta: None, resources: vec![ Resource::new("test://static-text", "Static Text Resource") .with_description("A static text resource for testing") @@ -549,7 +547,7 @@ impl ServerHandler for ConformanceServer { .with_description("A static binary/blob resource for testing") .with_mime_type("image/png"), ], - next_cursor: None, + ..Default::default() }) } @@ -609,13 +607,12 @@ impl ServerHandler for ConformanceServer { _cx: RequestContext, ) -> Result { Ok(ListResourceTemplatesResult { - meta: None, resource_templates: vec![ ResourceTemplate::new("test://template/{id}/data", "Dynamic Resource") .with_description("A dynamic resource with parameter substitution") .with_mime_type("application/json"), ], - next_cursor: None, + ..Default::default() }) } @@ -645,7 +642,6 @@ impl ServerHandler for ConformanceServer { _cx: RequestContext, ) -> Result { Ok(ListPromptsResult { - meta: None, prompts: vec![ Prompt::new( "test_simple_prompt", @@ -675,7 +671,7 @@ impl ServerHandler for ConformanceServer { None, ), ], - next_cursor: None, + ..Default::default() }) } diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 19ba4388e..af4f24bd8 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -61,6 +61,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result Result { let prompts = #router_expr.list_all(); Ok(rmcp::model::ListPromptsResult { + result_type: Default::default(), prompts, meta: #meta, next_cursor: None, diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index 0cb323b5a..dc935828d 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -69,6 +69,7 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { Ok(rmcp::model::ListToolsResult{ + result_type: Default::default(), tools: #router.list_all(), meta: #result_meta, next_cursor: None, diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 90f7ed4a9..c9097e241 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -66,10 +66,6 @@ impl Service for H { ServerNotification::PromptListChangedNotification(_notification_no_param) => { self.on_prompt_list_changed(context).await } - ServerNotification::ElicitationCompleteNotification(notification) => { - self.on_url_elicitation_notification_complete(notification.params, context) - .await - } ServerNotification::TaskStatusNotification(notification) => { self.on_task_status(notification.params, context).await } @@ -242,13 +238,6 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { std::future::ready(()) } - fn on_url_elicitation_notification_complete( - &self, - params: ElicitationResponseNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } fn on_task_status( &self, params: TaskStatusNotificationParam, diff --git a/crates/rmcp/src/handler/server/prompt.rs b/crates/rmcp/src/handler/server/prompt.rs index c291ef70b..ffce6b2e0 100644 --- a/crates/rmcp/src/handler/server/prompt.rs +++ b/crates/rmcp/src/handler/server/prompt.rs @@ -103,6 +103,7 @@ impl IntoGetPromptResult for GetPromptResult { impl IntoGetPromptResult for Vec { fn into_get_prompt_result(self) -> Result { Ok(GetPromptResult { + result_type: Default::default(), description: None, messages: self, meta: None, diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index ae096c00c..dece66d95 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -669,6 +669,8 @@ mod tests { name: Cow::Borrowed("requires_params"), arguments: Some(Default::default()), task: None, + input_responses: None, + request_state: None, }, RequestContext::new(NumberOrString::Number(1), peer), ); @@ -708,6 +710,8 @@ mod tests { name: Cow::Borrowed("test_tool"), arguments: None, task: None, + input_responses: None, + request_state: None, }, RequestContext::new(NumberOrString::Number(1), peer), ); diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index 9edf5ff8a..bf350797d 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -46,6 +46,7 @@ impl<'s, S> ToolCallContext<'s, S> { name, arguments, task, + .. }: CallToolRequestParams, request_context: RequestContext, ) -> Self { diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index fc45f1efa..f6c74c133 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -12,6 +12,7 @@ mod content; mod elicitation_schema; mod extension; mod meta; +mod mrtr; mod prompt; mod resource; mod serde_impl; @@ -23,6 +24,7 @@ pub use content::*; pub use elicitation_schema::*; pub use extension::*; pub use meta::*; +pub use mrtr::*; pub use prompt::*; pub use resource::*; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -516,7 +518,6 @@ impl ErrorCode { pub const INVALID_PARAMS: Self = Self(-32602); pub const INTERNAL_ERROR: Self = Self(-32603); pub const PARSE_ERROR: Self = Self(-32700); - pub const URL_ELICITATION_REQUIRED: Self = Self(-32042); } /// Error information for JSON-RPC error responses. @@ -572,12 +573,6 @@ impl ErrorData { pub fn internal_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::INTERNAL_ERROR, message, data) } - pub fn url_elicitation_required( - message: impl Into>, - data: Option, - ) -> Self { - Self::new(ErrorCode::URL_ELICITATION_REQUIRED, message, data) - } } /// Represents any JSON-RPC message that can be sent or received. @@ -684,6 +679,71 @@ impl From for () { fn from(_value: EmptyResult) {} } +/// Indicates the type of a result object, allowing the client to +/// determine how to parse the response. +/// +/// The spec defines this as an open string (`"complete" | "input_required" | string`), +/// so unknown values are preserved rather than rejected. Servers implementing this +/// protocol version MUST include `resultType` in every result. For backward +/// compatibility, clients MUST treat an absent field as `"complete"`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ResultType(Cow<'static, str>); + +impl ResultType { + pub const COMPLETE: Self = Self(Cow::Borrowed("complete")); + pub const INPUT_REQUIRED: Self = Self(Cow::Borrowed("input_required")); + + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns `true` if this is `"input_required"`. + pub fn is_input_required(&self) -> bool { + self.0 == "input_required" + } + + /// Returns `true` if this is `"complete"`. + pub fn is_complete(&self) -> bool { + self.0 == "complete" + } +} + +impl Default for ResultType { + fn default() -> Self { + Self::COMPLETE + } +} + +impl Serialize for ResultType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ResultType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + match s.as_str() { + "complete" => Ok(Self::COMPLETE), + "input_required" => Ok(Self::INPUT_REQUIRED), + _ => Ok(Self(Cow::Owned(s))), + } + } +} + +impl std::fmt::Display for ResultType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + /// A catch-all response either side can use for custom requests. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(transparent)] @@ -1185,6 +1245,9 @@ macro_rules! paginated_result { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $t { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1197,6 +1260,7 @@ macro_rules! paginated_result { items: $t_item, ) -> Self { Self { + result_type: ResultType::default(), meta: None, next_cursor: None, $i_item: items, @@ -1240,6 +1304,13 @@ pub struct ReadResourceRequestParams { pub meta: Option, /// The URI of the resource to read pub uri: String, + /// Client responses to server-initiated input requests from a previous + /// [`InputRequiredResult`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_responses: Option, + /// Opaque request state echoed back from a previous [`InputRequiredResult`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, } impl ReadResourceRequestParams { @@ -1248,6 +1319,8 @@ impl ReadResourceRequestParams { Self { meta: None, uri: uri.into(), + input_responses: None, + request_state: None, } } @@ -1256,6 +1329,18 @@ impl ReadResourceRequestParams { self.meta = Some(meta); self } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } } impl RequestParamsMeta for ReadResourceRequestParams { @@ -1276,6 +1361,9 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct ReadResourceResult { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, /// The actual content of the resource pub contents: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] @@ -1286,6 +1374,7 @@ impl ReadResourceResult { /// Create a new ReadResourceResult with the given contents. pub fn new(contents: Vec) -> Self { Self { + result_type: ResultType::default(), contents, meta: None, } @@ -1433,6 +1522,13 @@ pub struct GetPromptRequestParams { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, + /// Client responses to server-initiated input requests from a previous + /// [`InputRequiredResult`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_responses: Option, + /// Opaque request state echoed back from a previous [`InputRequiredResult`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, } impl GetPromptRequestParams { @@ -1442,6 +1538,8 @@ impl GetPromptRequestParams { meta: None, name: name.into(), arguments: None, + input_responses: None, + request_state: None, } } @@ -1456,6 +1554,18 @@ impl GetPromptRequestParams { self.meta = Some(meta); self } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } } impl RequestParamsMeta for GetPromptRequestParams { @@ -2438,6 +2548,9 @@ impl CompletionInfo { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CompleteResult { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, pub completion: CompletionInfo, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -2447,6 +2560,7 @@ impl CompleteResult { /// Create a new CompleteResult with the given completion info. pub fn new(completion: CompletionInfo) -> Self { Self { + result_type: ResultType::default(), completion, meta: None, } @@ -2653,7 +2767,6 @@ pub type RootsListChangedNotification = NotificationNoParam, -} - -impl ElicitationResponseNotificationParam { - /// Create a new ElicitationResponseNotificationParam. - pub fn new(elicitation_id: impl Into) -> Self { - Self { - elicitation_id: elicitation_id.into(), - meta: None, - } - } -} - -/// Notification sent when an url elicitation process is completed. -pub type ElicitationCompleteNotification = - Notification; - -#[deprecated(since = "2.0.0", note = "Renamed to ElicitationCompleteNotification")] -pub type ElicitationCompletionNotification = ElicitationCompleteNotification; - // ============================================================================= // TOOL EXECUTION RESULTS // ============================================================================= @@ -2929,6 +3014,9 @@ pub type ElicitationCompletionNotification = ElicitationCompleteNotification; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CallToolResult { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, /// The content returned by the tool (text, images, etc.) #[serde(default)] pub content: Vec, @@ -2956,6 +3044,8 @@ impl<'de> Deserialize<'de> for CallToolResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Helper { + #[serde(default)] + result_type: ResultType, content: Option>, structured_content: Option, is_error: Option, @@ -2977,6 +3067,7 @@ impl<'de> Deserialize<'de> for CallToolResult { } Ok(CallToolResult { + result_type: helper.result_type, content: helper.content.unwrap_or_default(), structured_content: helper.structured_content, is_error: helper.is_error, @@ -2989,6 +3080,7 @@ impl CallToolResult { /// Create a successful tool result with unstructured content pub fn success(content: Vec) -> Self { CallToolResult { + result_type: ResultType::default(), content, structured_content: None, is_error: Some(false), @@ -3046,6 +3138,7 @@ impl CallToolResult { /// ``` pub fn error(content: Vec) -> Self { CallToolResult { + result_type: ResultType::default(), content, structured_content: None, is_error: Some(true), @@ -3068,6 +3161,7 @@ impl CallToolResult { /// ``` pub fn structured(value: Value) -> Self { CallToolResult { + result_type: ResultType::default(), content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(false), @@ -3094,6 +3188,7 @@ impl CallToolResult { /// ``` pub fn structured_error(value: Value) -> Self { CallToolResult { + result_type: ResultType::default(), content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(true), @@ -3170,6 +3265,14 @@ pub struct CallToolRequestParams { /// Task metadata for async task management (SEP-1319) #[serde(skip_serializing_if = "Option::is_none")] pub task: Option, + /// Client responses to server-initiated input requests from a previous + /// [`InputRequiredResult`]. Present only when retrying after an incomplete result. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_responses: Option, + /// Opaque request state echoed back from a previous [`InputRequiredResult`]. + /// Clients MUST return this value exactly as received. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, } impl CallToolRequestParams { @@ -3180,6 +3283,8 @@ impl CallToolRequestParams { name: name.into(), arguments: None, task: None, + input_responses: None, + request_state: None, } } @@ -3194,6 +3299,18 @@ impl CallToolRequestParams { self.task = Some(task); self } + + /// Sets the input responses for an MRTR retry. + pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { + self.input_responses = Some(input_responses); + self + } + + /// Sets the request state for an MRTR retry. + pub fn with_request_state(mut self, request_state: impl Into) -> Self { + self.request_state = Some(request_state.into()); + self + } } impl RequestParamsMeta for CallToolRequestParams { @@ -3286,6 +3403,9 @@ impl CreateMessageResult { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetPromptResult { + /// Result type discriminator. Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub messages: Vec, @@ -3297,6 +3417,7 @@ impl GetPromptResult { /// Create a new GetPromptResult with required fields. pub fn new(messages: Vec) -> Self { Self { + result_type: ResultType::default(), description: None, messages, meta: None, @@ -3662,7 +3783,6 @@ ts_union!( | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification - | ElicitationCompleteNotification | TaskStatusNotification | CustomNotification; ); @@ -3683,6 +3803,7 @@ ts_union!( | GetTaskResult | CancelTaskResult | CallToolResult + | InputRequiredResult | GetTaskPayloadResult | EmptyResult | CustomResult diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 712a439d9..bc1b94b48 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -218,7 +218,6 @@ variant_extension! { ResourceListChangedNotification ToolListChangedNotification PromptListChangedNotification - ElicitationCompleteNotification TaskStatusNotification CustomNotification } diff --git a/crates/rmcp/src/model/mrtr.rs b/crates/rmcp/src/model/mrtr.rs new file mode 100644 index 000000000..e4a5b3fb8 --- /dev/null +++ b/crates/rmcp/src/model/mrtr.rs @@ -0,0 +1,388 @@ +//! Multi Round-Trip Request (MRTR) types for SEP-2322. +//! +//! Provides [`InputRequiredResult`], [`InputRequests`], and [`InputResponses`] +//! for the stateless multi round-trip request pattern defined in the MCP spec. +//! [`ResultType`] lives in the parent [`super`] module alongside other base result types. +//! +//! # Overview +//! +//! A server may respond to `tools/call`, `prompts/get`, or `resources/read` with an +//! [`InputRequiredResult`] instead of the normal result. The client fulfills the +//! [`InputRequests`], then retries the original request with [`InputResponses`] and +//! the echoed `requestState`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{CreateMessageRequest, ElicitRequest, ListRootsRequest, Meta, ResultType}; + +/// A server-initiated request that can appear inside [`InputRequests`]. +/// +/// Per the MCP spec, only `CreateMessageRequest` (sampling), +/// `ElicitRequest` (elicitation), and `ListRootsRequest` (roots) +/// are allowed. This is modeled as an untagged enum rather than a +/// `ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from +/// being included. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub enum InputRequest { + /// A `sampling/createMessage` request. + CreateMessage(CreateMessageRequest), + /// An `elicitation/create` request. + Elicitation(ElicitRequest), + /// A `roots/list` request. + ListRoots(ListRootsRequest), +} + +/// A map of server-initiated requests that the client must fulfill. +/// +/// Keys are server-assigned string identifiers; values are request objects +/// (`ElicitRequest`, `CreateMessageRequest`, or `ListRootsRequest`). +pub type InputRequests = BTreeMap; + +/// A map of client responses to server-initiated requests. +/// +/// Keys correspond to the keys in the [`InputRequests`] map; values are the +/// client's result for each request (`ElicitResult`, `CreateMessageResult`, +/// or `ListRootsResult`), represented as opaque JSON because the +/// heterogeneous `ClientResult` union does not derive the traits required +/// for use as a `BTreeMap` value. +pub type InputResponses = BTreeMap; + +/// A result indicating that additional input is needed before the request +/// can be completed. +/// +/// At least one of [`input_requests`](Self::input_requests) or +/// [`request_state`](Self::request_state) MUST be present. +/// +/// Servers MAY send this in response to `tools/call`, `prompts/get`, or +/// `resources/read`. Servers MUST NOT send this for any other request. +/// +/// # Examples +/// +/// ``` +/// use rmcp::model::InputRequiredResult; +/// +/// let result = InputRequiredResult::from_request_state("opaque-server-state"); +/// assert!(result.input_requests.is_none()); +/// assert_eq!(result.request_state.as_deref(), Some("opaque-server-state")); +/// ``` +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct InputRequiredResult { + /// Always `"input_required"` for this result type. + pub result_type: ResultType, + + /// Server-initiated requests that the client must fulfill before retrying. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_requests: Option, + + /// Opaque request state to be echoed back by the client on retry. + /// Clients MUST NOT inspect, parse, modify, or make any assumptions + /// about the contents. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_state: Option, + + /// Optional protocol-level metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Custom deserializer that requires `resultType: "input_required"` to prevent +/// greedy matching in the untagged `ServerResult` enum (which would otherwise +/// swallow empty objects or unknown shapes). +impl<'de> Deserialize<'de> for InputRequiredResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + result_type: Option, + input_requests: Option, + request_state: Option, + #[serde(rename = "_meta")] + meta: Option, + } + + let helper = Helper::deserialize(deserializer)?; + + match &helper.result_type { + Some(rt) if rt.is_input_required() => {} + _ => { + return Err(serde::de::Error::custom( + "InputRequiredResult requires resultType to be \"input_required\"", + )); + } + } + + Ok(InputRequiredResult { + result_type: ResultType::INPUT_REQUIRED, + input_requests: helper.input_requests, + request_state: helper.request_state, + meta: helper.meta, + }) + } +} + +impl InputRequiredResult { + /// Creates a new `InputRequiredResult` with both input requests and request state. + pub fn new(input_requests: Option, request_state: Option) -> Self { + Self { + result_type: ResultType::INPUT_REQUIRED, + input_requests, + request_state, + meta: None, + } + } + + /// Creates from input requests only. + pub fn from_input_requests(input_requests: InputRequests) -> Self { + Self::new(Some(input_requests), None) + } + + /// Creates from request state only (e.g. for load shedding). + pub fn from_request_state(request_state: impl Into) -> Self { + Self::new(None, Some(request_state.into())) + } + + /// Sets optional metadata. + pub fn with_meta(mut self, meta: Meta) -> Self { + self.meta = Some(meta); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod result_type { + use super::*; + + #[test] + fn default_is_complete() { + assert_eq!(ResultType::default(), ResultType::COMPLETE); + } + + #[test] + fn serializes_complete() { + assert_eq!( + serde_json::to_value(&ResultType::COMPLETE).unwrap(), + serde_json::json!("complete") + ); + } + + #[test] + fn serializes_input_required() { + assert_eq!( + serde_json::to_value(&ResultType::INPUT_REQUIRED).unwrap(), + serde_json::json!("input_required") + ); + } + + #[test] + fn deserializes_known_values() { + let complete: ResultType = + serde_json::from_value(serde_json::json!("complete")).unwrap(); + assert_eq!(complete, ResultType::COMPLETE); + + let input_required: ResultType = + serde_json::from_value(serde_json::json!("input_required")).unwrap(); + assert_eq!(input_required, ResultType::INPUT_REQUIRED); + } + + #[test] + fn preserves_unknown_extension_values() { + let custom: ResultType = + serde_json::from_value(serde_json::json!("streaming")).unwrap(); + assert_eq!(custom.as_str(), "streaming"); + assert!(!custom.is_complete()); + assert!(!custom.is_input_required()); + + let reserialized = serde_json::to_value(&custom).unwrap(); + assert_eq!(reserialized, serde_json::json!("streaming")); + } + } + + mod input_required_result { + use super::*; + + #[test] + fn deserializes_with_requests_and_state() { + let json = serde_json::json!({ + "resultType": "input_required", + "inputRequests": { + "github_login": { + "method": "elicitation/create", + "params": { + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + }, + "capital_of_france": { + "method": "sampling/createMessage", + "params": { + "messages": [{ + "role": "user", + "content": { "type": "text", "text": "What is the capital of France?" } + }], + "maxTokens": 100 + } + } + }, + "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + + let requests = result + .input_requests + .as_ref() + .expect("should have input_requests"); + assert_eq!(requests.len(), 2); + assert!(requests.contains_key("github_login")); + assert!(requests.contains_key("capital_of_france")); + assert_eq!( + result.request_state.as_deref(), + Some("eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0") + ); + } + + #[test] + fn roundtrip_preserves_all_fields() { + let json = serde_json::json!({ + "resultType": "input_required", + "inputRequests": { + "key": { + "method": "elicitation/create", + "params": { + "message": "test", + "requestedSchema": { "type": "object", "properties": {} } + } + } + }, + "requestState": "abc123" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + let reserialized = serde_json::to_value(&result).unwrap(); + + assert_eq!(reserialized["resultType"], "input_required"); + assert!(reserialized["inputRequests"].is_object()); + assert_eq!(reserialized["requestState"], "abc123"); + } + + #[test] + fn deserializes_with_request_state_only() { + let json = serde_json::json!({ + "resultType": "input_required", + "requestState": "eyJwcm9ncmVzcyI6IjUwJSJ9" + }); + + let result: InputRequiredResult = serde_json::from_value(json).unwrap(); + + assert!(result.input_requests.is_none()); + assert_eq!( + result.request_state.as_deref(), + Some("eyJwcm9ncmVzcyI6IjUwJSJ9") + ); + } + + #[test] + fn rejects_missing_result_type() { + let json = serde_json::json!({ + "requestState": "some-state" + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("input_required"), + "error should mention the required resultType, got: {err}" + ); + } + + #[test] + fn rejects_wrong_result_type() { + let json = serde_json::json!({ + "resultType": "complete", + "requestState": "some-state" + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("input_required"), + "error should mention the required resultType, got: {err}" + ); + } + } + + mod input_responses { + use super::*; + + #[test] + fn deserializes_heterogeneous_results() { + let json = serde_json::json!({ + "github_login": { + "action": "accept", + "content": { "name": "octocat" } + }, + "capital_of_france": { + "role": "assistant", + "content": { "type": "text", "text": "Paris." }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" + } + }); + + let responses: InputResponses = serde_json::from_value(json).unwrap(); + + assert_eq!(responses.len(), 2); + assert!(responses.contains_key("github_login")); + assert!(responses.contains_key("capital_of_france")); + } + } + + mod constructors { + use super::*; + + #[test] + fn from_request_state_sets_state_only() { + let result = InputRequiredResult::from_request_state("opaque"); + + assert_eq!(result.result_type, ResultType::INPUT_REQUIRED); + assert!(result.input_requests.is_none()); + assert_eq!(result.request_state.as_deref(), Some("opaque")); + } + + #[test] + fn from_input_requests_sets_requests_only() { + let mut requests = InputRequests::new(); + requests.insert( + "key".to_string(), + serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "test", + "requestedSchema": { "type": "object", "properties": {} } + } + })) + .unwrap(), + ); + + let result = InputRequiredResult::from_input_requests(requests); + + assert!(result.input_requests.is_some()); + assert!(result.request_state.is_none()); + } + } +} diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index f8996f318..7ff91099f 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -451,6 +451,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -489,6 +491,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -510,6 +514,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -528,6 +534,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -564,6 +572,8 @@ mod test { name: "my_tool".into(), arguments: None, task: None, + input_responses: None, + request_state: None, }, }; @@ -590,6 +600,8 @@ mod test { name: "my_tool".into(), arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])), task: None, + input_responses: None, + request_state: None, }, }; diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 4f479b9e8..8c7a87dda 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -10,10 +10,7 @@ use url::Url; use super::*; #[cfg(feature = "elicitation")] -use crate::model::{ - ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction, - ElicitationCompleteNotification, ElicitationResponseNotificationParam, -}; +use crate::model::{ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction}; use crate::{ model::{ CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, @@ -474,8 +471,6 @@ impl Peer { method!(peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult); #[cfg(feature = "elicitation")] method!(peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult); - #[cfg(feature = "elicitation")] - method!(peer_not notify_url_elicitation_completed ElicitationCompleteNotification(ElicitationResponseNotificationParam)); method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index b4a163380..0c294aa49 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -1882,31 +1882,6 @@ async fn test_url_elicitation_json_rpc_protocol() { } } -/// Test ElicitationCompleteNotification serialization/deserialization -#[tokio::test] -async fn test_elicitation_completion_notification() { - let notification_params = ElicitationResponseNotificationParam::new("elicit-789"); - - // Test serialization - let json = serde_json::to_value(¬ification_params).unwrap(); - let expected = json!({ - "elicitationId": "elicit-789" - }); - assert_eq!(json, expected); - - // Test deserialization - let deserialized: ElicitationResponseNotificationParam = - serde_json::from_value(expected).unwrap(); - assert_eq!(deserialized.elicitation_id, "elicit-789"); - - // Test complete notification structure - let notification = ElicitationCompleteNotification::new(notification_params); - - let json = serde_json::to_value(¬ification).unwrap(); - assert_eq!(json["method"], "notifications/elicitation/complete"); - assert_eq!(json["params"]["elicitationId"], "elicit-789"); -} - /// Test UrlElicitationCapability structure and serialization #[tokio::test] async fn test_url_elicitation_capability() { @@ -2020,39 +1995,6 @@ async fn test_elicitation_both_modes() { assert!(url_json.get("requestedSchema").is_none()); } -/// Test URL_ELICITATION_REQUIRED error code -#[tokio::test] -async fn test_url_elicitation_required_error_code() { - // Test the error code constant - assert_eq!(ErrorCode::URL_ELICITATION_REQUIRED.0, -32042); - - // Test creating error data with URL_ELICITATION_REQUIRED - let error_data = ErrorData::url_elicitation_required( - "URL elicitation is required for this operation", - Some(json!({ - "url": "https://example.com/complete", - "elicitationId": "elicit-999" - })), - ); - - assert_eq!(error_data.code, ErrorCode::URL_ELICITATION_REQUIRED); - assert_eq!( - error_data.message, - "URL elicitation is required for this operation" - ); - assert!(error_data.data.is_some()); - - // Test serialization - let json = serde_json::to_value(&error_data).unwrap(); - assert_eq!(json["code"], -32042); - assert_eq!( - json["message"], - "URL elicitation is required for this operation" - ); - assert_eq!(json["data"]["url"], "https://example.com/complete"); - assert_eq!(json["data"]["elicitationId"], "elicit-999"); -} - /// Test ClientCapabilities with different elicitation mode combinations #[tokio::test] async fn test_client_capabilities_elicitation_modes() { @@ -2102,32 +2044,6 @@ async fn test_client_capabilities_elicitation_modes() { assert!(json["elicitation"]["url"].is_object()); } -/// Test ElicitationCompleteNotification in ServerNotification enum -#[tokio::test] -async fn test_elicitation_completion_in_server_notification() { - let notification_param = ElicitationResponseNotificationParam::new("notify-123"); - - let completion_notification = ElicitationCompleteNotification::new(notification_param.clone()); - - // Test that it's part of ServerNotification - let server_notification = - ServerNotification::ElicitationCompleteNotification(completion_notification); - - // Test serialization - let json = serde_json::to_value(&server_notification).unwrap(); - assert_eq!(json["method"], "notifications/elicitation/complete"); - assert_eq!(json["params"]["elicitationId"], "notify-123"); - - // Test deserialization - let deserialized: ServerNotification = serde_json::from_value(json).unwrap(); - match deserialized { - ServerNotification::ElicitationCompleteNotification(notif) => { - assert_eq!(notif.params.elicitation_id, "notify-123"); - } - _ => panic!("Expected ElicitationCompleteNotification variant"), - } -} - /// Test ElicitationAction with URL elicitation workflow #[tokio::test] async fn test_url_elicitation_action_workflow() { @@ -2161,10 +2077,4 @@ async fn test_elicitation_method_constants() { ElicitationResponseNotificationMethod::VALUE, "notifications/elicitation/response" ); - - // Test new completion notification method - assert_eq!( - ElicitationCompletionNotificationMethod::VALUE, - "notifications/elicitation/complete" - ); } diff --git a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json index 15325e8fa..c6616b596 100644 --- a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json +++ b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json @@ -1,5 +1,6 @@ { "result": { + "resultType": "complete", "tools": [ { "name": "add", diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 3bfdb7c43..ef19901f0 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -143,10 +143,25 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`]. Present only when retrying after an incomplete result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "description": "The name of the tool to call", "type": "string" }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].\nClients MUST return this value exactly as received.", + "type": [ + "string", + "null" + ] + }, "task": { "description": "Task metadata for async task management (SEP-1319)", "anyOf": [ @@ -726,8 +741,23 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "type": "string" + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1374,6 +1404,21 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] + }, "uri": { "description": "The URI of the resource to read", "type": "string" diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 3bfdb7c43..ef19901f0 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -143,10 +143,25 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`]. Present only when retrying after an incomplete result.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "description": "The name of the tool to call", "type": "string" }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].\nClients MUST return this value exactly as received.", + "type": [ + "string", + "null" + ] + }, "task": { "description": "Task metadata for async task management (SEP-1319)", "anyOf": [ @@ -726,8 +741,23 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "name": { "type": "string" + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1374,6 +1404,21 @@ ], "additionalProperties": true }, + "inputResponses": { + "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "requestState": { + "description": "Opaque request state echoed back from a previous [`InputRequiredResult`].", + "type": [ + "string", + "null" + ] + }, "uri": { "description": "The URI of the resource to read", "type": "string" diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index fcf821f52..e5cfc7ce5 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -178,6 +178,15 @@ "null" ] }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } @@ -292,6 +301,15 @@ }, "completion": { "$ref": "#/definitions/CompletionInfo" + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -752,35 +770,11 @@ } ] }, - "ElicitationCompletionNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/elicitation/complete" - }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", "const": "elicitation/create" }, - "ElicitationResponseNotificationParam": { - "description": "Notification parameters for an url elicitation completion notification.", - "type": "object", - "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "elicitationId": { - "type": "string" - } - }, - "required": [ - "elicitationId" - ] - }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", @@ -948,6 +942,15 @@ "items": { "$ref": "#/definitions/PromptMessage" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1205,6 +1208,77 @@ "serverInfo" ] }, + "InputRequest": { + "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", + "anyOf": [ + { + "description": "A `sampling/createMessage` request.", + "allOf": [ + { + "$ref": "#/definitions/Request" + } + ] + }, + { + "description": "An `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/definitions/Request2" + } + ] + }, + { + "description": "A `roots/list` request.", + "allOf": [ + { + "$ref": "#/definitions/RequestNoParam2" + } + ] + } + ] + }, + "InputRequiredResult": { + "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "description": "Server-initiated requests that the client must fulfill before retrying.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "requestState": { + "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Always `\"input_required\"` for this result type.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, "IntegerSchema": { "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", "type": "object", @@ -1322,9 +1396,6 @@ { "$ref": "#/definitions/Notification5" }, - { - "$ref": "#/definitions/Notification6" - }, { "$ref": "#/definitions/CustomNotification" } @@ -1456,6 +1527,15 @@ "items": { "$ref": "#/definitions/Prompt" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1483,6 +1563,15 @@ "items": { "$ref": "#/definitions/ResourceTemplate" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1510,6 +1599,15 @@ "items": { "$ref": "#/definitions/Resource" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1564,6 +1662,15 @@ "null" ] }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "tools": { "type": "array", "items": { @@ -1758,21 +1865,6 @@ ] }, "Notification5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ElicitationCompletionNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ElicitationResponseNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification6": { "type": "object", "properties": { "method": { @@ -2129,6 +2221,15 @@ "items": { "$ref": "#/definitions/ResourceContents" } + }, + "result_type": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -2442,6 +2543,10 @@ } } }, + "ResultType": { + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.", + "type": "string" + }, "Role": { "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", "oneOf": [ @@ -2738,6 +2843,9 @@ { "$ref": "#/definitions/CallToolResult" }, + { + "$ref": "#/definitions/InputRequiredResult" + }, { "$ref": "#/definitions/GetTaskPayloadResult" }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index fcf821f52..e5cfc7ce5 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -178,6 +178,15 @@ "null" ] }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" } @@ -292,6 +301,15 @@ }, "completion": { "$ref": "#/definitions/CompletionInfo" + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -752,35 +770,11 @@ } ] }, - "ElicitationCompletionNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/elicitation/complete" - }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", "const": "elicitation/create" }, - "ElicitationResponseNotificationParam": { - "description": "Notification parameters for an url elicitation completion notification.", - "type": "object", - "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "elicitationId": { - "type": "string" - } - }, - "required": [ - "elicitationId" - ] - }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", @@ -948,6 +942,15 @@ "items": { "$ref": "#/definitions/PromptMessage" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1205,6 +1208,77 @@ "serverInfo" ] }, + "InputRequest": { + "description": "A server-initiated request that can appear inside [`InputRequests`].\n\nPer the MCP spec, only `CreateMessageRequest` (sampling),\n`ElicitRequest` (elicitation), and `ListRootsRequest` (roots)\nare allowed. This is modeled as an untagged enum rather than a\n`ServerRequest` alias to prevent `PingRequest` or `CustomRequest` from\nbeing included.", + "anyOf": [ + { + "description": "A `sampling/createMessage` request.", + "allOf": [ + { + "$ref": "#/definitions/Request" + } + ] + }, + { + "description": "An `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/definitions/Request2" + } + ] + }, + { + "description": "A `roots/list` request.", + "allOf": [ + { + "$ref": "#/definitions/RequestNoParam2" + } + ] + } + ] + }, + "InputRequiredResult": { + "description": "A result indicating that additional input is needed before the request\ncan be completed.\n\nAt least one of [`input_requests`](Self::input_requests) or\n[`request_state`](Self::request_state) MUST be present.\n\nServers MAY send this in response to `tools/call`, `prompts/get`, or\n`resources/read`. Servers MUST NOT send this for any other request.\n\n# Examples\n\n```\nuse rmcp::model::InputRequiredResult;\n\nlet result = InputRequiredResult::from_request_state(\"opaque-server-state\");\nassert!(result.input_requests.is_none());\nassert_eq!(result.request_state.as_deref(), Some(\"opaque-server-state\"));\n```", + "type": "object", + "properties": { + "_meta": { + "description": "Optional protocol-level metadata.", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "description": "Server-initiated requests that the client must fulfill before retrying.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, + "requestState": { + "description": "Opaque request state to be echoed back by the client on retry.\nClients MUST NOT inspect, parse, modify, or make any assumptions\nabout the contents.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Always `\"input_required\"` for this result type.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + } + }, + "required": [ + "resultType" + ] + }, "IntegerSchema": { "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", "type": "object", @@ -1322,9 +1396,6 @@ { "$ref": "#/definitions/Notification5" }, - { - "$ref": "#/definitions/Notification6" - }, { "$ref": "#/definitions/CustomNotification" } @@ -1456,6 +1527,15 @@ "items": { "$ref": "#/definitions/Prompt" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1483,6 +1563,15 @@ "items": { "$ref": "#/definitions/ResourceTemplate" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1510,6 +1599,15 @@ "items": { "$ref": "#/definitions/Resource" } + }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -1564,6 +1662,15 @@ "null" ] }, + "resultType": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "tools": { "type": "array", "items": { @@ -1758,21 +1865,6 @@ ] }, "Notification5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ElicitationCompletionNotificationMethod" - }, - "params": { - "$ref": "#/definitions/ElicitationResponseNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, - "Notification6": { "type": "object", "properties": { "method": { @@ -2129,6 +2221,15 @@ "items": { "$ref": "#/definitions/ResourceContents" } + }, + "result_type": { + "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" } }, "required": [ @@ -2442,6 +2543,10 @@ } } }, + "ResultType": { + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.", + "type": "string" + }, "Role": { "description": "Represents the role of a participant in a conversation or message exchange.\n\nUsed in sampling and chat contexts to distinguish between different\ntypes of message senders in the conversation flow.", "oneOf": [ @@ -2738,6 +2843,9 @@ { "$ref": "#/definitions/CallToolResult" }, + { + "$ref": "#/definitions/InputRequiredResult" + }, { "$ref": "#/definitions/GetTaskPayloadResult" }, diff --git a/crates/rmcp/tests/test_tool_result_meta.rs b/crates/rmcp/tests/test_tool_result_meta.rs index a1d3d3af3..d164e843e 100644 --- a/crates/rmcp/tests/test_tool_result_meta.rs +++ b/crates/rmcp/tests/test_tool_result_meta.rs @@ -9,6 +9,7 @@ fn serialize_tool_result_with_meta() { let result = CallToolResult::success(content).with_meta(Some(meta)); let v = serde_json::to_value(&result).unwrap(); let expected = json!({ + "resultType": "complete", "content": [{"type":"text","text":"ok"}], "isError": false, "_meta": {"foo":"bar"} diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index f618fee0d..09e52f3e6 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -252,8 +252,7 @@ impl ServerHandler for Counter { self._create_resource_text("str:////Users/to/some/path/", "cwd"), self._create_resource_text("memo://insights", "memo-name"), ], - next_cursor: None, - meta: None, + ..Default::default() }) } @@ -293,9 +292,8 @@ impl ServerHandler for Counter { _: RequestContext, ) -> Result { Ok(ListResourceTemplatesResult { - next_cursor: None, resource_templates: Vec::new(), - meta: None, + ..Default::default() }) } diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index e465e4b4d..d506a9c7f 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -129,18 +129,9 @@ impl ElicitationServer { ) })?; match elicit_result { - ElicitationAction::Accept => { - // Mock notifying completion - let _ = context - .peer - .notify_url_elicitation_completed(ElicitationResponseNotificationParam::new( - "elicit_123", - )) - .await; - Ok(CallToolResult::success(vec![ContentBlock::text( - "Elicitation via URL successful".to_string(), - )])) - } + ElicitationAction::Accept => Ok(CallToolResult::success(vec![ContentBlock::text( + "Elicitation via URL successful".to_string(), + )])), ElicitationAction::Cancel => Ok(CallToolResult::success(vec![ContentBlock::text( "Elicitation via URL cancelled by user".to_string(), )])), diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index f75c445f6..2690a28f2 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -113,8 +113,7 @@ impl ServerHandler for SamplingDemoServer { .unwrap(), ), )], - meta: None, - next_cursor: None, + ..Default::default() }) } } From ba00b15097e0341f026181258b23e1f4353a99ed Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:14:07 -0400 Subject: [PATCH 218/333] feat!: relax tool result structuredContent type (SEP-2106) (#933) Re-applies #919 (reverted by #932): ToolResultContent.structured_content becomes Option so non-object structured content is accepted, matching CallToolResult and SEP-2106. BREAKING CHANGE: ToolResultContent.structured_content changes from Option to Option. --- crates/rmcp/src/model/content.rs | 4 +-- .../client_json_rpc_message_schema.json | 8 +---- ...lient_json_rpc_message_schema_current.json | 8 +---- .../server_json_rpc_message_schema.json | 8 +---- ...erver_json_rpc_message_schema_current.json | 8 +---- crates/rmcp/tests/test_sampling.rs | 34 +++++++++++++++++++ 6 files changed, 40 insertions(+), 30 deletions(-) diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index a468de8fb..d454255e3 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -10,7 +10,7 @@ // ToolUseContent/ToolResultContent are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; use super::{Annotations, Meta, resource::ResourceContents}; @@ -207,7 +207,7 @@ pub struct ToolResultContent { pub tool_use_id: String, pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub structured_content: Option, + pub structured_content: Option, #[serde(skip_serializing_if = "Option::is_none")] pub is_error: Option, } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index ef19901f0..66b0b79d9 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -2397,13 +2397,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index ef19901f0..66b0b79d9 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -2397,13 +2397,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index e5cfc7ce5..c9d1429a1 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -3562,13 +3562,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index e5cfc7ce5..c9d1429a1 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -3562,13 +3562,7 @@ "null" ] }, - "structuredContent": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, + "structuredContent": true, "toolUseId": { "type": "string" } diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index 83d3f6fb1..b108ff412 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -9,6 +9,7 @@ use rmcp::{ model::*, service::{RequestContext, Service}, }; +use rstest::rstest; #[tokio::test] async fn test_basic_sampling_message_creation() -> Result<()> { @@ -370,6 +371,39 @@ fn test_tool_result_content_requires_content() { assert!(err.to_string().contains("missing field `content`")); } +#[rstest] +#[case::array(serde_json::json!([{ "city": "SF", "temp": 72 }, { "city": "NY", "temp": 65 }]))] +#[case::string(serde_json::json!("sunny"))] +#[case::integer(serde_json::json!(42))] +#[case::float(serde_json::json!(3.14))] +#[case::boolean(serde_json::json!(true))] +fn tool_result_content_round_trips_non_object_structured_content( + #[case] structured: serde_json::Value, +) -> Result<()> { + let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("x")]); + tool_result.structured_content = Some(structured); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(tool_result, deserialized); + + Ok(()) +} + +// `null` is a valid JSON value, but `Option` deserializes JSON `null` +// as `None`, so `Some(Value::Null)` collapses to `None` on round-trip. +#[test] +fn tool_result_content_null_structured_content_round_trips_to_none() -> Result<()> { + let mut tool_result = ToolResultContent::new("call_123", vec![ContentBlock::text("x")]); + tool_result.structured_content = Some(serde_json::Value::Null); + + let json = serde_json::to_string(&tool_result)?; + let deserialized: ToolResultContent = serde_json::from_str(&json)?; + assert_eq!(deserialized.structured_content, None); + + Ok(()) +} + #[tokio::test] async fn test_sampling_message_with_tool_use() -> Result<()> { let message = SamplingMessage::assistant_tool_use( From 9e3de344f402823fe0984e4a3c7ecbec538aa26c Mon Sep 17 00:00:00 2001 From: Brandon Bennett <107384180+branben@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:19:28 +0000 Subject: [PATCH 219/333] feat: relax outputSchema to accept non-object JSON Schema types (SEP-2106) (#895) * fix: address PR review - schema_for_output no longer validates or returns Result - Add strip_output() that strips title/description without validating type (Dale #1) - Change schema_for_output to return Arc instead of Result (Dale #2) - Cache only Arc success values, not Result (Dale #3) - Remove dead unwrap_or_else panic paths in with_output_schema, ToolBase, and macros - Tighten test assertions from contains to assert_eq on type field (Dale #4) - Update test_schema_for_output_rejects_primitive to accept_primitive (SEP-2106) Co-authored-by: Orca * test(rmcp): add non-object output schema tests for SEP-2106 Add tests verifying schema_for_output accepts non-object types: - test_tool_builder_methods: primitive (i32), array (Vec), option - test_structured_output: tool returning Json> and Json - test_json_schema_detection: Json>, Result>,E>, Json - tool_traits: ToolBase::output_schema with Vec output type * test(rmcp): add missing edge case tests from code review Add tests identified during code review: - description stripping for primitive types - composition types (Option with anyOf/oneOf/null) - cache correctness (Arc::ptr_eq for repeated calls) - schema_for_input rejecting array types (not just primitives) - schema_for_output accepting unit type () * feat!: mark schema_for_output return-type change as breaking This introduces SEP-2106: schema_for_output no longer validates or returns Result. The public signature changed, so bump major. * fix: address Dale's PR review - direct schema.get assertions, remove ArrayTool - Replace loose schema_str.contains(...) assertions with direct schema.get("type") equality checks in test_tool_builder_methods.rs and test_structured_output.rs - Remove redundant ArrayTool fixture and its round-trip serde_json::from_str test from tool_traits.rs since schema is already Arc - Drop dead schema_str variable in test_structured_output.rs --------- Co-authored-by: Brandon Bennett Co-authored-by: Orca Co-authored-by: Brandon Bennett --- crates/rmcp-macros/src/tool.rs | 14 --- crates/rmcp/src/handler/server/common.rs | 100 ++++++++++++++---- .../handler/server/router/tool/tool_traits.rs | 8 +- crates/rmcp/src/model/tool.rs | 8 +- .../rmcp/tests/test_json_schema_detection.rs | 73 +++++++++++++ crates/rmcp/tests/test_structured_output.rs | 56 ++++++++++ .../rmcp/tests/test_tool_builder_methods.rs | 56 ++++++++-- 7 files changed, 262 insertions(+), 53 deletions(-) diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index 5e5044eb6..c289c32c8 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -28,13 +28,6 @@ fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option { if let Some(inner_type) = extract_json_inner_type(ret_type) { return syn::parse2::(quote! { rmcp::handler::server::tool::schema_for_output::<#inner_type>() - .unwrap_or_else(|e| { - panic!( - "Invalid output schema for Json<{}>: {}", - std::any::type_name::<#inner_type>(), - e - ) - }) }) .ok(); } @@ -65,13 +58,6 @@ fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option { syn::parse2::(quote! { rmcp::handler::server::tool::schema_for_output::<#inner_type>() - .unwrap_or_else(|e| { - panic!( - "Invalid output schema for Result, E>: {}", - std::any::type_name::<#inner_type>(), - e - ) - }) }) .ok() } diff --git a/crates/rmcp/src/handler/server/common.rs b/crates/rmcp/src/handler/server/common.rs index 0d76547db..aa1cc313a 100644 --- a/crates/rmcp/src/handler/server/common.rs +++ b/crates/rmcp/src/handler/server/common.rs @@ -106,32 +106,40 @@ pub fn schema_for_empty_input() -> Arc { EMPTY.clone() } -/// Generate a JSON schema for outputSchema (must have root type "object"; top-level "title" and "description" are removed) -pub fn schema_for_output() -> Result, String> { +/// Strip top-level `title` and `description` from a JSON schema for outputSchema. +/// Unlike `validate_and_strip`, this performs no validation — output schemas are not +/// restricted to `type: "object"` (per SEP-2106). +fn strip_output(raw: &Arc) -> Arc { + let mut object = raw.as_ref().clone(); + object.remove("title"); + object.remove("description"); + Arc::new(object) +} + +/// Generate and strip a JSON schema for outputSchema (top-level "title" and +/// "description" are removed; output schemas are not restricted to root type "object"). +pub fn schema_for_output() -> Arc { thread_local! { - static CACHE_FOR_OUTPUT: std::sync::RwLock, String>>> = Default::default(); + static CACHE_FOR_OUTPUT: std::sync::RwLock>> = Default::default(); }; CACHE_FOR_OUTPUT.with(|cache| { - // Try to get from cache first - if let Some(result) = cache + if let Some(schema) = cache .read() .expect("output schema cache lock poisoned") .get(&TypeId::of::()) { - return result.clone(); + return schema.clone(); } - // Generate, validate, and strip unnecessary top-level fields - let result = validate_and_strip(&schema_for_type::(), "outputSchema"); + let schema = strip_output(&schema_for_type::()); - // Cache the result (both success and error cases) cache .write() .expect("output schema cache lock poisoned") - .insert(TypeId::of::(), result.clone()); + .insert(TypeId::of::(), schema.clone()); - result + schema }) } @@ -305,10 +313,69 @@ mod tests { assert!(Arc::ptr_eq(&schema, &cloned)); } + #[test] + fn test_schema_for_output_accepts_primitive() { + let schema = schema_for_output::(); + assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); + } + + #[test] + fn test_schema_for_output_strips_description_for_primitive() { + let schema = schema_for_output::(); + assert!(!schema.contains_key("description")); + } + + #[test] + fn test_schema_for_output_accepts_composition() { + let schema = schema_for_output::>(); + let schema_str = serde_json::to_string(&schema).unwrap(); + assert!( + schema_str.contains("anyOf") + || schema_str.contains("oneOf") + || schema_str.contains("null"), + "Expected composition schema for Option, got: {schema_str}" + ); + } + + #[test] + fn test_schema_for_output_caches_result() { + let schema1 = schema_for_output::(); + let schema2 = schema_for_output::(); + assert!(Arc::ptr_eq(&schema1, &schema2)); + } + + #[test] + fn test_schema_for_input_rejects_array() { + let result = schema_for_input::>(); + assert!(result.is_err()); + } + + #[test] + fn test_schema_for_output_accepts_unit() { + let _schema = schema_for_output::<()>(); + } + + #[test] + fn test_schema_for_output_accepts_object() { + let schema = schema_for_output::(); + assert_eq!(schema.get("type"), Some(&serde_json::json!("object"))); + } + + #[test] + fn test_schema_for_output_strips_top_level_title() { + let schema = schema_for_output::(); + assert!(!schema.contains_key("title")); + } + + #[test] + fn test_schema_for_output_strips_top_level_description() { + let schema = schema_for_output::(); + assert!(!schema.contains_key("description")); + } + #[rstest] - #[case::output(schema_for_output::)] #[case::input(schema_for_input::)] - fn test_schema_for_object_wrappers_reject_primitives( + fn test_schema_for_input_rejects_primitives( #[case] schema_fn: fn() -> Result, String>, ) { let result = schema_fn(); @@ -316,9 +383,8 @@ mod tests { } #[rstest] - #[case::output(schema_for_output::)] #[case::input(schema_for_input::)] - fn test_schema_for_object_wrappers_accept_objects( + fn test_schema_for_input_accepts_objects( #[case] schema_fn: fn() -> Result, String>, ) { let result = schema_fn(); @@ -326,11 +392,9 @@ mod tests { } #[rstest] - #[case::output_title(schema_for_output::, "title")] - #[case::output_description(schema_for_output::, "description")] #[case::input_title(schema_for_input::, "title")] #[case::input_description(schema_for_input::, "description")] - fn test_schema_for_object_wrappers_strip_top_level_metadata( + fn test_schema_for_input_strips_top_level_metadata( #[case] schema_fn: fn() -> Result, String>, #[case] field: &str, ) { diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs index b0bf9e2dc..df6594da7 100644 --- a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -65,13 +65,7 @@ pub trait ToolBase { /// /// If the tool does not have any output, you should override this methods to return [`None`]. fn output_schema() -> Option> { - Some(schema_for_output::().unwrap_or_else(|e| { - panic!( - "Invalid output schema for ToolBase::Output type `{0}`: {1}", - std::any::type_name::(), - e, - ); - })) + Some(schema_for_output::()) } fn annotations() -> Option { diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index 2ed89d6ad..25d9def53 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -315,15 +315,9 @@ impl Tool { } /// Set the output schema using a type that implements JsonSchema - /// - /// # Panics - /// - /// Panics if the generated schema does not have root type "object" as required by MCP specification. #[cfg(feature = "server")] pub fn with_output_schema(mut self) -> Self { - let schema = crate::handler::server::tool::schema_for_output::() - .unwrap_or_else(|e| panic!("Invalid output schema for tool '{}': {}", self.name, e)); - self.output_schema = Some(schema); + self.output_schema = Some(crate::handler::server::tool::schema_for_output::()); self } diff --git a/crates/rmcp/tests/test_json_schema_detection.rs b/crates/rmcp/tests/test_json_schema_detection.rs index 5d982cd66..e13125c40 100644 --- a/crates/rmcp/tests/test_json_schema_detection.rs +++ b/crates/rmcp/tests/test_json_schema_detection.rs @@ -60,6 +60,28 @@ impl TestServer { pub async fn explicit_schema(&self) -> Result { Ok("test".to_string()) } + + /// Tool that returns Json> - array output schema + #[tool(name = "with-json-array")] + pub async fn with_json_array(&self) -> Result>, String> { + Ok(Json(vec![TestData { + value: "test".to_string(), + }])) + } + + /// Tool that returns Result>, ErrorData> - array output schema + #[tool(name = "result-with-json-array")] + pub async fn result_with_json_array(&self) -> Result>, rmcp::ErrorData> { + Ok(Json(vec![TestData { + value: "test".to_string(), + }])) + } + + /// Tool that returns Json - string output schema + #[tool(name = "with-json-string")] + pub async fn with_json_string(&self) -> Result, String> { + Ok(Json("test".to_string())) + } } #[tokio::test] @@ -113,3 +135,54 @@ async fn test_explicit_schema_override() { "Explicit output_schema attribute should work" ); } + +#[tokio::test] +async fn test_json_array_type_generates_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + let array_tool = tools.iter().find(|t| t.name == "with-json-array").unwrap(); + assert!( + array_tool.output_schema.is_some(), + "Json> return type should generate output schema" + ); + let schema = array_tool.output_schema.as_ref().unwrap(); + assert_eq!( + schema.get("type").and_then(|v| v.as_str()), + Some("array"), + "Json> should produce an array schema" + ); +} + +#[tokio::test] +async fn test_result_with_json_array_generates_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + let result_array_tool = tools + .iter() + .find(|t| t.name == "result-with-json-array") + .unwrap(); + assert!( + result_array_tool.output_schema.is_some(), + "Result>, ErrorData> return type should generate output schema" + ); +} + +#[tokio::test] +async fn test_json_string_type_generates_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + let string_tool = tools.iter().find(|t| t.name == "with-json-string").unwrap(); + assert!( + string_tool.output_schema.is_some(), + "Json return type should generate output schema" + ); + let schema = string_tool.output_schema.as_ref().unwrap(); + assert_eq!( + schema.get("type").and_then(|v| v.as_str()), + Some("string"), + "Json should produce a string schema" + ); +} diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index bb0d5e029..f82df546d 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -93,6 +93,27 @@ impl TestServer { Err("User not found".to_string()) } } + + /// Tool that returns a list of calculation results + #[tool( + name = "calculate-list", + description = "Return a list of calculation results" + )] + pub async fn calculate_list( + &self, + params: Parameters, + ) -> Result>, String> { + Ok(Json(vec![CalculationResult { + sum: params.0.a + params.0.b, + product: params.0.a * params.0.b, + }])) + } + + /// Tool that returns a count + #[tool(name = "get-count", description = "Return a count")] + pub async fn get_count(&self) -> Result, String> { + Ok(Json(42)) + } } #[tokio::test] @@ -360,3 +381,38 @@ fn test_call_tool_result_deserialize_without_content() { assert!(result.content.is_empty()); assert!(result.structured_content.is_some()); } + +#[tokio::test] +async fn test_tool_with_array_output_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the calculate-list tool + let calculate_list_tool = tools.iter().find(|t| t.name == "calculate-list").unwrap(); + + // Verify it has an output schema + assert!(calculate_list_tool.output_schema.is_some()); + + let schema = calculate_list_tool.output_schema.as_ref().unwrap(); + + // Check that the schema contains array type + let schema_str = serde_json::to_string(schema).unwrap(); + assert!(schema_str.contains("array")); +} + +#[tokio::test] +async fn test_tool_with_primitive_output_schema() { + let server = TestServer::new(); + let tools = server.tool_router.list_all(); + + // Find the get-count tool + let get_count_tool = tools.iter().find(|t| t.name == "get-count").unwrap(); + + // Verify it has an output schema + assert!(get_count_tool.output_schema.is_some()); + + let schema = get_count_tool.output_schema.as_ref().unwrap(); + + // Check that the schema contains integer type + assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); +} diff --git a/crates/rmcp/tests/test_tool_builder_methods.rs b/crates/rmcp/tests/test_tool_builder_methods.rs index 8be7e5c3e..cdb1473f5 100644 --- a/crates/rmcp/tests/test_tool_builder_methods.rs +++ b/crates/rmcp/tests/test_tool_builder_methods.rs @@ -22,10 +22,8 @@ fn test_with_output_schema() { assert!(tool.output_schema.is_some()); - // Verify the schema contains expected fields - let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); - assert!(schema_str.contains("greeting")); - assert!(schema_str.contains("is_adult")); + let schema = tool.output_schema.as_ref().unwrap(); + assert_eq!(schema.get("type"), Some(&serde_json::json!("object"))); } #[test] @@ -57,7 +55,51 @@ fn test_chained_builder_methods() { assert!(input_schema_str.contains("name")); assert!(input_schema_str.contains("age")); - let output_schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); - assert!(output_schema_str.contains("greeting")); - assert!(output_schema_str.contains("is_adult")); + let output_schema = tool.output_schema.as_ref().unwrap(); + assert_eq!( + output_schema.get("type"), + Some(&serde_json::json!("object")) + ); +} + +#[test] +fn test_with_output_schema_primitive() { + let tool = Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::(); + + assert!(tool.output_schema.is_some()); + + let schema = tool.output_schema.as_ref().unwrap(); + assert_eq!(schema.get("type"), Some(&serde_json::json!("integer"))); + // title should be stripped from output schema + assert!(schema.get("title").is_none()); +} + +#[test] +fn test_with_output_schema_array() { + let tool = + Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::>(); + + assert!(tool.output_schema.is_some()); + + let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); + assert!(schema_str.contains("\"type\":\"array\"")); + assert!(schema_str.contains("items")); + // title should be stripped from output schema + assert!(!schema_str.contains("title")); +} + +#[test] +fn test_with_output_schema_option() { + let tool = + Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::>(); + + assert!(tool.output_schema.is_some()); + + let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); + // Option generates a composition schema (anyOf/oneOf/type array with null) + assert!( + schema_str.contains("anyOf") || schema_str.contains("oneOf") || schema_str.contains("null"), + "Expected composition schema for Option, got: {schema_str}" + ); + assert!(!schema_str.contains("title")); } From 60d3e77f52316cfe606f52bf95d0afbbcb79aac0 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 9 Jul 2026 16:51:37 -0700 Subject: [PATCH 220/333] fix: flag schema derive on schemars feature (#966) --- crates/rmcp/Cargo.toml | 2 +- crates/rmcp/src/lib.rs | 2 +- crates/rmcp/src/model.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 9704bfc72..8dc496b30 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -116,7 +116,7 @@ chrono = { version = "0.4.38", default-features = false, features = [ default = ["base64", "macros", "server"] local = ["rmcp-macros?/local"] client = ["dep:tokio-stream"] -server = ["transport-async-rw", "dep:schemars", "dep:pastey"] +server = ["transport-async-rw", "schemars", "dep:pastey"] macros = ["dep:rmcp-macros", "dep:pastey"] elicitation = ["dep:url"] diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 9ae3f9586..022514c9d 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -34,7 +34,7 @@ pub mod transport; pub use pastey::paste; #[cfg(all(feature = "macros", feature = "server"))] pub use rmcp_macros::*; -#[cfg(any(feature = "server", feature = "schemars"))] +#[cfg(feature = "schemars")] pub use schemars; #[cfg(feature = "macros")] pub use serde; diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index f6c74c133..56cceb86c 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -66,7 +66,7 @@ macro_rules! object { /// without returning any specific data. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Copy, Eq)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "server", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct EmptyObject {} From 11f525c5031a4d12424d41fa838147bd8b794251 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:16:29 -0400 Subject: [PATCH 221/333] feat!: add SEP-2243 HTTP standard headers (#907) * feat: add SEP-2243 HTTP standard headers * feat!: validate Mcp-Param-* headers on server * fix: emit Mcp-Method on reinit initialized POST * fix: align header mismatch error code with draft spec * chore: remove redundant streamable HTTP client comments --- crates/rmcp/Cargo.toml | 8 +- crates/rmcp/src/model.rs | 8 +- crates/rmcp/src/transport/common.rs | 3 + .../rmcp/src/transport/common/http_header.rs | 9 + .../rmcp/src/transport/common/mcp_headers.rs | 654 ++++++++++++++++++ .../src/transport/streamable_http_client.rs | 143 +++- .../transport/streamable_http_server/tower.rs | 106 ++- .../test_streamable_http_standard_headers.rs | 292 ++++++++ 8 files changed, 1183 insertions(+), 40 deletions(-) create mode 100644 crates/rmcp/src/transport/common/mcp_headers.rs create mode 100644 crates/rmcp/tests/test_streamable_http_standard_headers.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 8dc496b30..c7b3b7709 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -139,12 +139,13 @@ server-side-http = [ "dep:bytes", "dep:sse-stream", "tower", + "base64", ] transport-worker = ["dep:tokio-stream"] # SSE stream parsing utilities (used by streamable HTTP client for SSE-formatted responses) -client-side-sse = ["dep:sse-stream", "dep:http"] +client-side-sse = ["dep:sse-stream", "dep:http", "base64"] # Streamable HTTP client transport-streamable-http-client = ["client-side-sse", "transport-worker"] @@ -293,6 +294,11 @@ name = "test_protocol_version_negotiation" required-features = ["server", "client"] path = "tests/test_protocol_version_negotiation.rs" +[[test]] +name = "test_streamable_http_standard_headers" +required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] +path = "tests/test_streamable_http_standard_headers.rs" + [[test]] name = "test_streamable_http_4xx_error_body" required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"] diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 56cceb86c..3f1774716 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -168,6 +168,9 @@ impl ProtocolVersion { pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); pub const LATEST: Self = Self::V_2025_11_25; + /// First protocol version that requires SEP-2243 standard HTTP headers. + pub const STANDARD_HEADERS: Self = Self::V_2026_07_28; + /// All protocol versions known to this SDK. pub const KNOWN_VERSIONS: &[Self] = &[ Self::V_2024_11_05, @@ -512,6 +515,7 @@ pub struct JsonRpcNotification { pub struct ErrorCode(pub i32); impl ErrorCode { + pub const HEADER_MISMATCH: Self = Self(-32020); pub const RESOURCE_NOT_FOUND: Self = Self(-32002); pub const INVALID_REQUEST: Self = Self(-32600); pub const METHOD_NOT_FOUND: Self = Self(-32601); @@ -557,7 +561,9 @@ impl ErrorData { pub fn resource_not_found(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data) } - + pub fn header_mismatch(message: impl Into>, data: Option) -> Self { + Self::new(ErrorCode::HEADER_MISMATCH, message, data) + } pub fn parse_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::PARSE_ERROR, message, data) } diff --git a/crates/rmcp/src/transport/common.rs b/crates/rmcp/src/transport/common.rs index 3691602b1..8cf00c02f 100644 --- a/crates/rmcp/src/transport/common.rs +++ b/crates/rmcp/src/transport/common.rs @@ -3,6 +3,9 @@ pub mod server_side_http; pub mod http_header; +#[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] +pub mod mcp_headers; + #[cfg(feature = "__reqwest")] mod reqwest; diff --git a/crates/rmcp/src/transport/common/http_header.rs b/crates/rmcp/src/transport/common/http_header.rs index 283f0daa7..6c2abbc8d 100644 --- a/crates/rmcp/src/transport/common/http_header.rs +++ b/crates/rmcp/src/transport/common/http_header.rs @@ -4,6 +4,15 @@ pub const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version"; pub const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; pub const JSON_MIME_TYPE: &str = "application/json"; +// SEP-2243 standard headers, gated on protocol version >= 2026-07-28. +pub const HEADER_MCP_METHOD: &str = "Mcp-Method"; +pub const HEADER_MCP_NAME: &str = "Mcp-Name"; +pub const HEADER_MCP_PARAM_PREFIX: &str = "Mcp-Param-"; + +/// Sentinel wrapping a Base64-encoded SEP-2243 header value (`=?base64??=`). +pub const BASE64_HEADER_PREFIX: &str = "=?base64?"; +pub const BASE64_HEADER_SUFFIX: &str = "?="; + /// Reserved headers that must not be overridden by user-supplied custom headers. /// `MCP-Protocol-Version` is in this list but is allowed through because the worker /// injects it after initialization. diff --git a/crates/rmcp/src/transport/common/mcp_headers.rs b/crates/rmcp/src/transport/common/mcp_headers.rs new file mode 100644 index 000000000..0b38981a4 --- /dev/null +++ b/crates/rmcp/src/transport/common/mcp_headers.rs @@ -0,0 +1,654 @@ +//! SEP-2243 HTTP header standardization. +//! +//! Builds and validates the `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*` headers +//! so middle boxes can route Streamable HTTP traffic without parsing the body. +//! All emission/validation is gated by the negotiated protocol version +//! (`>= ProtocolVersion::STANDARD_HEADERS`) at the call sites. + +// Which helpers are reachable depends on the client/server feature combination, +// mirroring `server_side_http`. +#![allow(dead_code)] + +use serde_json::Value; + +use super::http_header::{ + BASE64_HEADER_PREFIX, BASE64_HEADER_SUFFIX, HEADER_MCP_METHOD, HEADER_MCP_NAME, + HEADER_MCP_PARAM_PREFIX, +}; +use crate::model::JsonObject; + +/// Methods whose `Mcp-Name` is sourced from `params.name`. +const NAME_FROM_NAME: &[&str] = &["tools/call", "prompts/get"]; +/// Methods whose `Mcp-Name` is sourced from `params.uri`. +const NAME_FROM_URI: &[&str] = &[ + "resources/read", + "resources/subscribe", + "resources/unsubscribe", +]; + +/// Returns the `Mcp-Name` value for a request, if the method carries one. +fn extract_name(method: &str, params: Option<&Value>) -> Option { + let params = params?; + let key = if NAME_FROM_NAME.contains(&method) { + "name" + } else if NAME_FROM_URI.contains(&method) { + "uri" + } else { + return None; + }; + params.get(key)?.as_str().map(str::to_owned) +} + +/// Converts a JSON primitive to its SEP-2243 string form. Non-primitives yield `None`. +fn primitive_to_string(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Bool(b) => Some(b.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// True if `value` must be Base64-wrapped to survive as an HTTP header value: +/// leading/trailing space or tab, control/non-ASCII characters, or a value that +/// already looks like the `=?base64?...?=` sentinel. +#[cfg(feature = "client-side-sse")] +fn requires_base64(value: &str) -> bool { + if value.is_empty() { + return false; + } + let bytes = value.as_bytes(); + if matches!(bytes.first(), Some(b' ' | b'\t')) || matches!(bytes.last(), Some(b' ' | b'\t')) { + return true; + } + if value + .chars() + .any(|c| (c as u32) < 0x20 || (c as u32) > 0x7E) + { + return true; + } + value.starts_with(BASE64_HEADER_PREFIX) && value.ends_with(BASE64_HEADER_SUFFIX) +} + +/// RFC 9110 §5.6.2 token character. +#[cfg(feature = "client-side-sse")] +fn is_tchar(c: char) -> bool { + c.is_ascii_alphanumeric() + || matches!( + c, + '!' | '#' + | '$' + | '%' + | '&' + | '\'' + | '*' + | '+' + | '-' + | '.' + | '^' + | '_' + | '`' + | '|' + | '~' + ) +} + +/// Top-level properties carrying an `x-mcp-header` annotation, as `(property, header)` pairs. +fn param_header_annotations(input_schema: &JsonObject) -> Vec<(String, String)> { + let mut out = Vec::new(); + if let Some(Value::Object(props)) = input_schema.get("properties") { + for (prop, schema) in props { + if let Some(Value::String(header)) = schema.get("x-mcp-header") { + if !header.is_empty() { + out.push((prop.clone(), header.clone())); + } + } + } + } + out +} + +/// Validates the `x-mcp-header` annotations in a tool input schema. +/// +/// Annotations must be non-empty RFC 9110 tokens, case-insensitively unique, +/// applied only to top-level primitive (`string`/`integer`/`boolean`) properties. +/// Returns the offending reason on the first violation. +#[cfg(feature = "client-side-sse")] +pub(crate) fn validate_param_header_annotations(input_schema: &JsonObject) -> Result<(), String> { + let Some(Value::Object(props)) = input_schema.get("properties") else { + return Ok(()); + }; + let mut seen = std::collections::HashSet::new(); + for (prop, schema) in props { + reject_nested_annotations(schema, prop)?; + let Some(raw) = schema.get("x-mcp-header") else { + continue; + }; + let Value::String(header) = raw else { + return Err(format!("property `{prop}`: x-mcp-header must be a string")); + }; + if header.is_empty() { + return Err(format!("property `{prop}`: x-mcp-header must not be empty")); + } + if !header.chars().all(is_tchar) { + return Err(format!( + "property `{prop}`: x-mcp-header `{header}` is not a valid HTTP token" + )); + } + if !seen.insert(header.to_ascii_lowercase()) { + return Err(format!( + "property `{prop}`: duplicate x-mcp-header `{header}` (case-insensitive)" + )); + } + match schema.get("type").and_then(Value::as_str) { + Some("string" | "integer" | "boolean") => {} + other => { + return Err(format!( + "property `{prop}`: x-mcp-header requires a primitive type \ + (string/integer/boolean), got {other:?}" + )); + } + } + } + Ok(()) +} + +/// Rejects `x-mcp-header` on nested properties (only top-level promotion is supported). +#[cfg(feature = "client-side-sse")] +fn reject_nested_annotations(schema: &Value, path: &str) -> Result<(), String> { + if let Some(Value::Object(nested)) = schema.get("properties") { + for (key, value) in nested { + if value.get("x-mcp-header").is_some() { + return Err(format!( + "property `{path}.{key}`: x-mcp-header is not supported on nested properties" + )); + } + reject_nested_annotations(value, &format!("{path}.{key}"))?; + } + } + Ok(()) +} + +/// Wraps a value as `=?base64??=` when it cannot travel as a bare header value. +#[cfg(feature = "client-side-sse")] +fn encode_header_value(value: &str) -> String { + use base64::{Engine, prelude::BASE64_STANDARD}; + if requires_base64(value) { + format!( + "{BASE64_HEADER_PREFIX}{}{BASE64_HEADER_SUFFIX}", + BASE64_STANDARD.encode(value) + ) + } else { + value.to_owned() + } +} + +/// Reverses [`encode_header_value`]. Returns `None` if the sentinel wraps invalid Base64/UTF-8. +#[cfg(feature = "server-side-http")] +fn decode_header_value(value: &str) -> Option { + use base64::{Engine, prelude::BASE64_STANDARD}; + match value + .strip_prefix(BASE64_HEADER_PREFIX) + .and_then(|inner| inner.strip_suffix(BASE64_HEADER_SUFFIX)) + { + Some(inner) => { + let bytes = BASE64_STANDARD.decode(inner).ok()?; + String::from_utf8(bytes).ok() + } + None => Some(value.to_owned()), + } +} + +/// Builds the SEP-2243 headers for an outgoing request from its JSON form. +/// +/// `tool_schema` is the cached input schema of the called tool, used to promote +/// annotated `tools/call` arguments to `Mcp-Param-*` headers. +#[cfg(feature = "client-side-sse")] +pub(crate) fn standard_request_headers( + request: &Value, + tool_schema: Option<&JsonObject>, +) -> Vec<(http::HeaderName, http::HeaderValue)> { + use http::{HeaderName, HeaderValue}; + + let mut out = Vec::new(); + let Some(method) = request.get("method").and_then(Value::as_str) else { + return out; + }; + let params = request.get("params"); + + let mut push = |name: &str, value: &str| { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + out.push((name, value)); + } + }; + + push(HEADER_MCP_METHOD, method); + if let Some(name) = extract_name(method, params) { + push(HEADER_MCP_NAME, &encode_header_value(&name)); + } + + if method == "tools/call" { + if let (Some(schema), Some(arguments)) = + (tool_schema, params.and_then(|p| p.get("arguments"))) + { + for (prop, header) in param_header_annotations(schema) { + let Some(arg) = arguments.get(&prop) else { + continue; + }; + let Some(encoded) = primitive_to_string(arg).map(|s| encode_header_value(&s)) + else { + continue; + }; + push(&format!("{HEADER_MCP_PARAM_PREFIX}{header}"), &encoded); + } + } + } + out +} + +/// Validates incoming SEP-2243 headers against the request body. +/// +/// Returns `Err(reason)` when a required header is missing or its value does not +/// match the body; the caller maps this to a JSON-RPC `-32020` error (HTTP 400). +#[cfg(feature = "server-side-http")] +pub(crate) fn validate_request_headers( + headers: &http::HeaderMap, + request: &Value, + tool_schema: Option<&JsonObject>, +) -> Result<(), String> { + let Some(method) = request.get("method").and_then(Value::as_str) else { + return Ok(()); + }; + let params = request.get("params"); + + let header_method = header_str(headers, HEADER_MCP_METHOD); + match header_method { + None => return Err("missing required Mcp-Method header".to_owned()), + Some(value) if value != method => { + return Err(format!( + "Mcp-Method header `{value}` does not match body method `{method}`" + )); + } + Some(_) => {} + } + + if let Some(expected) = extract_name(method, params) { + match header_str(headers, HEADER_MCP_NAME) { + None => return Err(format!("missing required Mcp-Name header for `{method}`")), + Some(raw) => { + let decoded = decode_header_value(raw) + .ok_or_else(|| "Mcp-Name header is not valid Base64".to_owned())?; + if decoded != expected { + return Err(format!( + "Mcp-Name header `{decoded}` does not match body value `{expected}`" + )); + } + } + } + } + + if method == "tools/call" { + if let Some(schema) = tool_schema { + let arguments = params.and_then(|p| p.get("arguments")); + for (prop, header) in param_header_annotations(schema) { + let full = format!("{HEADER_MCP_PARAM_PREFIX}{header}"); + let header_value = header_str(headers, &full); + let arg = arguments.and_then(|a| a.get(&prop)); + let body_value = arg.filter(|v| !v.is_null()).and_then(primitive_to_string); + + match (header_value, body_value) { + (None, None) => {} + (Some(_), None) => { + return Err(format!( + "unexpected {full} header for absent or null `{prop}`" + )); + } + (None, Some(_)) => { + return Err(format!("missing {full} header for `{prop}`")); + } + (Some(raw), Some(expected)) => { + let decoded = decode_header_value(raw) + .ok_or_else(|| format!("{full} header is not valid Base64"))?; + if decoded != expected { + return Err(format!( + "{full} header `{decoded}` does not match body value `{expected}`" + )); + } + } + } + } + } + } + Ok(()) +} + +/// Case-insensitive header lookup returning the value as `&str`, if present and valid UTF-8. +#[cfg(feature = "server-side-http")] +fn header_str<'a>(headers: &'a http::HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +#[cfg(all(test, feature = "client-side-sse", feature = "server-side-http"))] +mod tests { + use std::collections::HashMap; + + use http::{HeaderMap, HeaderName, HeaderValue}; + use serde_json::json; + + use super::*; + + fn schema_with(properties: serde_json::Value) -> JsonObject { + json!({ "type": "object", "properties": properties }) + .as_object() + .unwrap() + .clone() + } + + fn header_map(pairs: &[(&str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.insert( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + map + } + + fn assert_wrapped(value: &str) { + let encoded = encode_header_value(value); + assert!( + encoded.starts_with(BASE64_HEADER_PREFIX), + "expected {value:?} to be Base64-wrapped, got {encoded:?}" + ); + } + + mod encode_header_value { + use super::*; + + #[test] + fn passes_plain_ascii_through() { + assert_eq!(encode_header_value("us-west1"), "us-west1"); + } + + #[test] + fn passes_internal_spaces_through() { + assert_eq!(encode_header_value("a b c"), "a b c"); + } + + #[test] + fn wraps_non_ascii() { + assert_wrapped("café"); + } + + #[test] + fn wraps_leading_whitespace() { + assert_wrapped(" padded"); + } + + #[test] + fn wraps_trailing_whitespace() { + assert_wrapped("trailing "); + } + + #[test] + fn wraps_control_characters() { + assert_wrapped("line1\nline2"); + } + + #[test] + fn wraps_crlf_injection_attempt() { + assert_wrapped("a\r\nEvil: 1"); + } + + #[test] + fn wraps_sentinel_collision() { + assert_wrapped(&format!("{BASE64_HEADER_PREFIX}x{BASE64_HEADER_SUFFIX}")); + } + } + + mod decode_header_value { + use super::*; + + #[test] + fn round_trips_with_encode() { + for value in ["us-west1", "café", " padded ", "line1\nline2", "true", "42"] { + let encoded = encode_header_value(value); + assert_eq!( + decode_header_value(&encoded).as_deref(), + Some(value), + "round-trip failed for {value:?}" + ); + } + } + + #[test] + fn rejects_invalid_base64() { + let bad = format!("{BASE64_HEADER_PREFIX}!!!not-base64!!!{BASE64_HEADER_SUFFIX}"); + assert_eq!(decode_header_value(&bad), None); + } + } + + mod extract_name { + use super::*; + + #[test] + fn from_name_for_tools_call() { + let params = json!({ "name": "my_tool" }); + assert_eq!( + extract_name("tools/call", Some(¶ms)).as_deref(), + Some("my_tool") + ); + } + + #[test] + fn from_name_for_prompts_get() { + let params = json!({ "name": "my_prompt" }); + assert_eq!( + extract_name("prompts/get", Some(¶ms)).as_deref(), + Some("my_prompt") + ); + } + + #[test] + fn from_uri_for_resources_read() { + let params = json!({ "uri": "file:///x" }); + assert_eq!( + extract_name("resources/read", Some(¶ms)).as_deref(), + Some("file:///x") + ); + } + + #[test] + fn none_for_unrelated_method() { + let params = json!({ "name": "my_tool" }); + assert_eq!(extract_name("ping", Some(¶ms)), None); + } + + #[test] + fn none_when_params_absent() { + assert_eq!(extract_name("tools/call", None), None); + } + } + + mod validate_param_header_annotations { + use super::*; + + #[test] + fn accepts_primitive_types() { + let schema = schema_with(json!({ + "region": { "type": "string", "x-mcp-header": "Region" }, + "count": { "type": "integer", "x-mcp-header": "Count" }, + "flag": { "type": "boolean", "x-mcp-header": "Flag" }, + })); + assert!(validate_param_header_annotations(&schema).is_ok()); + } + + #[test] + fn rejects_number_type() { + let schema = schema_with(json!({ "n": { "type": "number", "x-mcp-header": "N" } })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_complex_type() { + let schema = schema_with(json!({ "a": { "type": "array", "x-mcp-header": "A" } })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_empty_header_name() { + let schema = schema_with(json!({ "r": { "type": "string", "x-mcp-header": "" } })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_non_token_header_name() { + let schema = + schema_with(json!({ "r": { "type": "string", "x-mcp-header": "bad:name" } })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_case_insensitive_duplicate() { + let schema = schema_with(json!({ + "a": { "type": "string", "x-mcp-header": "Region" }, + "b": { "type": "string", "x-mcp-header": "region" }, + })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + + #[test] + fn rejects_nested_annotation() { + let schema = schema_with(json!({ + "outer": { + "type": "object", + "properties": { "inner": { "type": "string", "x-mcp-header": "Inner" } } + } + })); + assert!(validate_param_header_annotations(&schema).is_err()); + } + } + + mod standard_request_headers { + use super::*; + + fn tools_call_headers() -> HashMap { + let schema = schema_with(json!({ + "region": { "type": "string", "x-mcp-header": "Region" }, + })); + let request = json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "deploy", "arguments": { "region": "us-west1" } } + }); + super::super::standard_request_headers(&request, Some(&schema)) + .into_iter() + .map(|(name, value)| (name.as_str().to_owned(), value.to_str().unwrap().to_owned())) + .collect() + } + + #[test] + fn sets_method_header() { + assert_eq!( + tools_call_headers().get("mcp-method").map(String::as_str), + Some("tools/call") + ); + } + + #[test] + fn sets_name_header() { + assert_eq!( + tools_call_headers().get("mcp-name").map(String::as_str), + Some("deploy") + ); + } + + #[test] + fn sets_annotated_param_header() { + assert_eq!( + tools_call_headers() + .get("mcp-param-region") + .map(String::as_str), + Some("us-west1") + ); + } + } + + mod validate_request_headers { + use super::*; + + fn tools_call_request() -> Value { + json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "deploy" } + }) + } + + #[test] + fn accepts_matching_method_and_name() { + let headers = header_map(&[("Mcp-Method", "tools/call"), ("Mcp-Name", "deploy")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_ok()); + } + + #[test] + fn rejects_method_mismatch() { + let headers = header_map(&[("Mcp-Method", "tools/list"), ("Mcp-Name", "deploy")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); + } + + #[test] + fn rejects_missing_method() { + let headers = header_map(&[("Mcp-Name", "deploy")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); + } + + #[test] + fn rejects_name_mismatch() { + let headers = header_map(&[("Mcp-Method", "tools/call"), ("Mcp-Name", "other")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); + } + + #[test] + fn rejects_missing_name() { + let headers = header_map(&[("Mcp-Method", "tools/call")]); + assert!(validate_request_headers(&headers, &tools_call_request(), None).is_err()); + } + + #[test] + fn accepts_matching_param() { + let schema = schema_with(json!({ + "region": { "type": "string", "x-mcp-header": "Region" }, + })); + let request = json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "deploy", "arguments": { "region": "us-west1" } } + }); + let headers = header_map(&[ + ("Mcp-Method", "tools/call"), + ("Mcp-Name", "deploy"), + ("Mcp-Param-Region", "us-west1"), + ]); + assert!(validate_request_headers(&headers, &request, Some(&schema)).is_ok()); + } + + #[test] + fn rejects_param_mismatch() { + let schema = schema_with(json!({ + "region": { "type": "string", "x-mcp-header": "Region" }, + })); + let request = json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "deploy", "arguments": { "region": "us-west1" } } + }); + let headers = header_map(&[ + ("Mcp-Method", "tools/call"), + ("Mcp-Name", "deploy"), + ("Mcp-Param-Region", "eu-central1"), + ]); + assert!(validate_request_headers(&headers, &request, Some(&schema)).is_err()); + } + } +} diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 871667301..2379baf95 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -17,17 +17,82 @@ use super::common::client_side_sse::{ExponentialBackoff, SseRetryPolicy, SseStre use crate::{ RoleClient, model::{ - ClientJsonRpcMessage, ClientNotification, ErrorData, InitializedNotification, RequestId, - ServerJsonRpcMessage, ServerResult, + ClientJsonRpcMessage, ClientNotification, ErrorData, InitializedNotification, JsonObject, + ProtocolVersion, RequestId, ServerJsonRpcMessage, ServerResult, }, transport::{ - common::client_side_sse::SseAutoReconnectStream, + common::{client_side_sse::SseAutoReconnectStream, mcp_headers}, worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport}, }, }; type BoxedSseStream = BoxStream<'static, Result>; +fn build_request_headers( + base: &HashMap, + message: &ClientJsonRpcMessage, + tool_cache: &HashMap>, + version: &ProtocolVersion, +) -> HashMap { + use serde_json::Value; + + let mut headers = base.clone(); + if *version >= ProtocolVersion::STANDARD_HEADERS { + if let Ok(value) = serde_json::to_value(message) { + let schema = value + .get("method") + .and_then(Value::as_str) + .filter(|method| *method == "tools/call") + .and_then(|_| value.get("params")) + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + .and_then(|name| tool_cache.get(name)) + .map(Arc::as_ref); + for (name, val) in mcp_headers::standard_request_headers(&value, schema) { + headers.insert(name, val); + } + } + } + headers +} + +fn cache_tools_from_response( + cache: &mut HashMap>, + message: &ServerJsonRpcMessage, +) { + if let ServerJsonRpcMessage::Response(response) = message { + if let ServerResult::ListToolsResult(list) = &response.result { + for tool in &list.tools { + if let Err(reason) = + mcp_headers::validate_param_header_annotations(&tool.input_schema) + { + tracing::warn!(tool = %tool.name, "ignoring x-mcp-header annotations: {reason}"); + continue; + } + cache.insert(tool.name.to_string(), tool.input_schema.clone()); + } + } + } +} + +fn negotiate_version_headers( + init_response: &ServerJsonRpcMessage, + base: HashMap, +) -> (ProtocolVersion, HashMap) { + let mut version = ProtocolVersion::default(); + let mut headers = base; + if let ServerJsonRpcMessage::Response(response) = init_response { + if let ServerResult::InitializeResult(init_result) = &response.result { + version = init_result.protocol_version.clone(); + // HeaderName::from_static requires lowercase + if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { + headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); + } + } + } + (version, headers) +} + #[derive(Debug)] #[non_exhaustive] pub struct AuthRequiredError { @@ -542,17 +607,8 @@ impl StreamableHttpClientWorker { let new_session_id: Option> = new_session_id_str.map(|s| Arc::from(s.as_str())); - // Start from custom_headers, then inject the negotiated MCP-Protocol-Version - // so all subsequent requests carry the right version (MCP 2025-06-18 spec). - let mut new_protocol_headers = custom_headers; - if let ServerJsonRpcMessage::Response(response) = &init_msg { - if let ServerResult::InitializeResult(init_result) = &response.result { - if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { - new_protocol_headers - .insert(HeaderName::from_static("mcp-protocol-version"), hv); - } - } - } + let (negotiated_version, new_protocol_headers) = + negotiate_version_headers(&init_msg, custom_headers); let initialized_notification = ClientJsonRpcMessage::notification( ClientNotification::InitializedNotification(InitializedNotification { @@ -560,13 +616,20 @@ impl StreamableHttpClientWorker { extensions: Default::default(), }), ); + // SEP-2243: notifications carry no Mcp-Param-*, so an empty tool cache suffices. + let initialized_headers = build_request_headers( + &new_protocol_headers, + &initialized_notification, + &HashMap::new(), + &negotiated_version, + ); client .post_message( uri, initialized_notification, new_session_id.clone(), auth_header, - new_protocol_headers.clone(), + initialized_headers, ) .await? .expect_accepted_or_json::()?; @@ -642,21 +705,11 @@ impl Worker for StreamableHttpClientWorker { } None }; - // Extract the negotiated protocol version from the init response - // and build a custom headers map that includes MCP-Protocol-Version - // for all subsequent HTTP requests (per MCP 2025-06-18 spec). - let mut protocol_headers = { - let mut headers = config.custom_headers.clone(); - if let ServerJsonRpcMessage::Response(response) = &message { - if let ServerResult::InitializeResult(init_result) = &response.result { - if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { - // HeaderName::from_static requires lowercase - headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); - } - } - } - headers - }; + let (negotiated_version, mut protocol_headers) = + negotiate_version_headers(&message, config.custom_headers.clone()); + // SEP-2243: tool input schemas (name -> schema) cached from tools/list responses, + // used to promote annotated tools/call arguments to Mcp-Param-* headers. + let mut tool_header_cache: HashMap> = HashMap::new(); // Store session info for cleanup when run() exits (not spawned, so cleanup completes before close() returns) let mut session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { @@ -670,13 +723,19 @@ impl Worker for StreamableHttpClientWorker { context.send_to_handler(message).await?; let initialized_notification = context.recv_from_handler().await?; // expect a initialized response + let initialized_headers = build_request_headers( + &protocol_headers, + &initialized_notification.message, + &tool_header_cache, + &negotiated_version, + ); self.client .post_message( config.uri.clone(), initialized_notification.message, session_id.clone(), config.auth_header.clone(), - protocol_headers.clone(), + initialized_headers, ) .await .map_err(WorkerQuitReason::fatal_context( @@ -788,6 +847,12 @@ impl Worker for StreamableHttpClientWorker { // Pass a clone to the first attempt so `message` is retained for a // potential re-init retry. `post_message` takes ownership and the // trait cannot be changed, so the clone is unavoidable. + let request_headers = build_request_headers( + &protocol_headers, + &message, + &tool_header_cache, + &negotiated_version, + ); let response = self .client .post_message( @@ -795,7 +860,7 @@ impl Worker for StreamableHttpClientWorker { message.clone(), session_id.clone(), config.auth_header.clone(), - protocol_headers.clone(), + request_headers, ) .await; let send_result = match response { @@ -908,6 +973,12 @@ impl Worker for StreamableHttpClientWorker { }); } + let retry_headers = build_request_headers( + &protocol_headers, + &message, + &tool_header_cache, + &negotiated_version, + ); let retry_response = self .client .post_message( @@ -915,7 +986,7 @@ impl Worker for StreamableHttpClientWorker { message, session_id.clone(), config.auth_header.clone(), - protocol_headers.clone(), + retry_headers, ) .await; match retry_response { @@ -931,6 +1002,10 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } Ok(StreamableHttpPostResponse::Json(msg, ..)) => { + cache_tools_from_response( + &mut tool_header_cache, + &msg, + ); context.send_to_handler(msg).await?; Ok(()) } @@ -973,6 +1048,7 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } Ok(StreamableHttpPostResponse::Json(message, ..)) => { + cache_tools_from_response(&mut tool_header_cache, &message); context.send_to_handler(message).await?; Ok(()) } @@ -1007,6 +1083,7 @@ impl Worker for StreamableHttpClientWorker { &mut pending_stream_response_ids, &json_rpc_message, ); + cache_tools_from_response(&mut tool_header_cache, &json_rpc_message); // send the message to the handler if let Err(e) = context.send_to_handler(json_rpc_message).await { break 'main_loop Err(e); diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 22be73798..d8fe2d426 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -18,7 +18,7 @@ use crate::{ model::{ ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, Implementation, InitializeRequest, InitializeRequestParams, - InitializedNotification, JsonRpcError, ProtocolVersion, RequestId, + InitializedNotification, JsonObject, JsonRpcError, ProtocolVersion, RequestId, }, serve_server, service::serve_directly, @@ -29,6 +29,7 @@ use crate::{ EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION, HEADER_SESSION_ID, JSON_MIME_TYPE, }, + mcp_headers, server_side_http::{ BoxResponse, ServerSseMessage, accepted_response, expect_json, internal_error_response, sse_stream_response, unexpected_message_response, @@ -260,6 +261,71 @@ fn validate_header_matches_init_body( Ok(()) } +fn header_mismatch_jsonrpc_response( + id: Option, + message: impl Into>, +) -> BoxResponse { + let err = JsonRpcError::new(id, ErrorData::header_mismatch(message, None)); + let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response") +} + +/// Validates SEP-2243 `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers against the body. +/// +/// Only enforced when the request declares a protocol version `>= STANDARD_HEADERS`. +/// The `initialize` handshake is exempt: clients emit these headers only after the +/// version has been negotiated. `tool_schema` supplies the called tool's input schema +/// so annotated `Mcp-Param-*` headers can be checked (no schema => those are skipped). +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_standard_headers( + headers: &HeaderMap, + message: &ClientJsonRpcMessage, + tool_schema: impl Fn(&str) -> Option>, +) -> Result<(), BoxResponse> { + let version_requires_headers = headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS.as_str()); + if !version_requires_headers { + return Ok(()); + } + + let request_id = match message { + ClientJsonRpcMessage::Request(req) => { + if matches!(&req.request, ClientRequest::InitializeRequest(_)) { + return Ok(()); + } + Some(req.id.clone()) + } + ClientJsonRpcMessage::Notification(_) => None, + _ => return Ok(()), + }; + + let Ok(value) = serde_json::to_value(message) else { + return Ok(()); + }; + // For tools/call, look up the tool schema so Mcp-Param-* headers are validated. + let schema = value + .get("method") + .and_then(|method| method.as_str()) + .filter(|method| *method == "tools/call") + .and_then(|_| value.get("params")) + .and_then(|params| params.get("name")) + .and_then(|name| name.as_str()) + .and_then(tool_schema); + if let Err(reason) = mcp_headers::validate_request_headers(headers, &value, schema.as_deref()) { + return Err(header_mismatch_jsonrpc_response(request_id, reason)); + } + Ok(()) +} + fn forbidden_response(message: impl Into) -> BoxResponse { Response::builder() .status(http::StatusCode::FORBIDDEN) @@ -555,6 +621,10 @@ pub struct StreamableHttpService { pending_restores: Option< Arc>>>>, >, + /// Caches tool input schemas by name for SEP-2243 `Mcp-Param-*` validation. + /// Populated lazily via `get_tool` so the service factory runs at most once + /// per tool name. `None` value means the tool exposes no schema. + tool_schemas: Arc>>>>, } impl Clone for StreamableHttpService { @@ -564,6 +634,7 @@ impl Clone for StreamableHttpService { session_manager: self.session_manager.clone(), service_factory: self.service_factory.clone(), pending_restores: self.pending_restores.clone(), + tool_schemas: self.tool_schemas.clone(), } } } @@ -571,7 +642,7 @@ impl Clone for StreamableHttpService { impl tower_service::Service> for StreamableHttpService where RequestBody: Body + Send + 'static, - S: crate::Service + Send + 'static, + S: crate::ServerHandler + Send + 'static, M: SessionManager, RequestBody::Error: Display, RequestBody::Data: Send + 'static, @@ -625,7 +696,7 @@ impl Drop for PendingRestoreGuard { impl StreamableHttpService where - S: crate::Service + Send + 'static, + S: crate::ServerHandler + Send + 'static, M: SessionManager, { pub fn new( @@ -644,12 +715,33 @@ where session_manager, service_factory: Arc::new(service_factory), pending_restores, + tool_schemas: Arc::new(std::sync::RwLock::new(HashMap::new())), } } fn get_service(&self) -> Result { (self.service_factory)() } + /// Returns the cached input schema for `name`, constructing a service once + /// per name to read its `ServerHandler::get_tool` definition. Used to + /// validate SEP-2243 `Mcp-Param-*` headers against the request body. + fn tool_schema(&self, name: &str) -> Option> { + if let Ok(cache) = self.tool_schemas.read() { + if let Some(schema) = cache.get(name) { + return schema.clone(); + } + } + let schema = self + .get_service() + .ok() + .and_then(|service| service.get_tool(name)) + .map(|tool| tool.input_schema); + if let Ok(mut cache) = self.tool_schemas.write() { + cache.insert(name.to_owned(), schema.clone()); + } + schema + } + /// Spawn a task that runs `serve_server` for the given session, waits for /// it to finish, and then calls `close_session`. /// @@ -664,7 +756,7 @@ where transport: M::Transport, init_done_tx: Option>, ) where - S: crate::Service + Send + 'static, + S: crate::ServerHandler + Send + 'static, M: SessionManager, { tokio::spawn(async move { @@ -707,7 +799,7 @@ where parts: &http::request::Parts, ) -> Result where - S: crate::Service + Send + 'static, + S: crate::ServerHandler + Send + 'static, M: SessionManager, { // Both fields are Some iff a session store is configured. @@ -1083,6 +1175,8 @@ where // Validate MCP-Protocol-Version header (per 2025-06-18 spec) validate_protocol_version_header(&part.headers)?; + // Validate SEP-2243 standard headers against the body + validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; // inject request part to extensions match &mut message { @@ -1235,6 +1329,8 @@ where validate_protocol_version_header(&part.headers)?; } } + // Validate SEP-2243 standard headers against the body + validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; let service = self .get_service() .map_err(internal_error_response("get service"))?; diff --git a/crates/rmcp/tests/test_streamable_http_standard_headers.rs b/crates/rmcp/tests/test_streamable_http_standard_headers.rs new file mode 100644 index 000000000..5316725c0 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_standard_headers.rs @@ -0,0 +1,292 @@ +#![cfg(not(feature = "local"))] +//! SEP-2243 server-side validation of `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers. +use std::sync::Arc; + +use rmcp::{ + ServerHandler, + model::{ServerCapabilities, ServerInfo, Tool}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio_util::sync::CancellationToken; + +const SEP_VERSION: &str = "2026-07-28"; + +/// Server exposing one tool whose `region` argument is promoted to `Mcp-Param-Region`. +#[derive(Clone, Default)] +struct HeaderValidationServer; + +impl ServerHandler for HeaderValidationServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + fn get_tool(&self, name: &str) -> Option { + if name != "deploy" { + return None; + } + let schema = serde_json::json!({ + "type": "object", + "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } + }); + let schema = schema.as_object().expect("object schema").clone(); + Some(Tool::new("deploy", "deploy a thing", Arc::new(schema))) + } +} + +async fn spawn_server() -> (reqwest::Client, String, CancellationToken) { + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()); + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(HeaderValidationServer), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + (reqwest::Client::new(), format!("http://{addr}/mcp"), ct) +} + +/// POSTs a `tools/call` with the given protocol-version and optional SEP-2243 headers. +async fn post_tool_call( + client: &reqwest::Client, + url: &str, + version: &str, + tool_name: &str, + arguments: serde_json::Value, + mcp_method: Option<&str>, + mcp_name: Option<&str>, + param_region: Option<&str>, +) -> reqwest::Response { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + } + }); + let mut req = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", version) + .body(body.to_string()); + if let Some(method) = mcp_method { + req = req.header("Mcp-Method", method); + } + if let Some(name) = mcp_name { + req = req.header("Mcp-Name", name); + } + if let Some(region) = param_region { + req = req.header("Mcp-Param-Region", region); + } + req.send().await.expect("send tools/call request") +} + +#[tokio::test] +async fn accepts_matching_standard_headers() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + // Matching headers pass validation and reach dispatch. (Stateless mode without a + // prior initialize yields an unrelated -32601, which still proves -32020 was not raised.) + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + Some("tools/call"), + Some("sum"), + None, + ) + .await; + let body: serde_json::Value = response.json().await?; + assert_ne!( + body["error"]["code"], -32020, + "matching headers must not be rejected as a header mismatch, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_method_mismatch_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + Some("tools/list"), + Some("sum"), + None, + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_missing_method_header_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + None, + Some("sum"), + None, + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_name_mismatch_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + Some("tools/call"), + Some("product"), + None, + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn skips_validation_for_pre_sep_version() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + // Older version: headers are not enforced even when absent. + let response = post_tool_call( + &client, + &url, + "2025-11-25", + "sum", + serde_json::json!({ "a": 1, "b": 2 }), + None, + None, + None, + ) + .await; + let body: serde_json::Value = response.json().await?; + assert_ne!( + body["error"]["code"], -32020, + "pre-SEP versions must skip header validation, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn accepts_matching_param_header() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "deploy", + serde_json::json!({ "region": "us-west1" }), + Some("tools/call"), + Some("deploy"), + Some("us-west1"), + ) + .await; + let body: serde_json::Value = response.json().await?; + assert_ne!( + body["error"]["code"], -32020, + "matching Mcp-Param-* must not be rejected, got: {body}" + ); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_param_mismatch_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "deploy", + serde_json::json!({ "region": "us-west1" }), + Some("tools/call"), + Some("deploy"), + Some("eu-central1"), + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn rejects_missing_param_header_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + // `region` argument is present but the annotated `Mcp-Param-Region` header is absent. + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "deploy", + serde_json::json!({ "region": "us-west1" }), + Some("tools/call"), + Some("deploy"), + None, + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} From a7194590ffb9e538e967b04bde7ec2250fe34bd8 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:47:16 -0400 Subject: [PATCH 222/333] feat!: type Annotations.lastModified as a string (#956) --- crates/rmcp/src/model/annotated.rs | 72 +++++++++++++++++-- .../client_json_rpc_message_schema.json | 3 +- ...lient_json_rpc_message_schema_current.json | 3 +- .../server_json_rpc_message_schema.json | 3 +- ...erver_json_rpc_message_schema_current.json | 3 +- 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/crates/rmcp/src/model/annotated.rs b/crates/rmcp/src/model/annotated.rs index 06800d1f9..ac3dbd3ed 100644 --- a/crates/rmcp/src/model/annotated.rs +++ b/crates/rmcp/src/model/annotated.rs @@ -20,20 +20,23 @@ pub struct Annotations { #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "lastModified")] - pub last_modified: Option>, + pub last_modified: Option, } impl Annotations { - /// Creates a new Annotations instance specifically for resources - /// optional priority, and a timestamp (defaults to now if None) + /// Creates annotations for a resource with priority and an RFC 3339 timestamp. + /// + /// # Panics + /// + /// Panics if `priority` is not in the inclusive range `0.0..=1.0`. pub fn for_resource(priority: f32, timestamp: DateTime) -> Self { assert!( (0.0..=1.0).contains(&priority), "Priority {priority} must be between 0.0 and 1.0" ); - Annotations { + Self { priority: Some(priority), - last_modified: Some(timestamp), + last_modified: Some(timestamp.to_rfc3339()), audience: None, } } @@ -48,12 +51,69 @@ impl Annotations { self } + /// Sets `lastModified` from a typed timestamp, serialized as RFC 3339. pub fn with_timestamp(mut self, timestamp: DateTime) -> Self { - self.last_modified = Some(timestamp); + self.last_modified = Some(timestamp.to_rfc3339()); self } + /// Sets `lastModified` to the current time, serialized as RFC 3339. pub fn with_timestamp_now(self) -> Self { self.with_timestamp(Utc::now()) } } + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + use serde_json::json; + + use super::*; + + fn annotations_of(value: serde_json::Value) -> Annotations { + serde_json::from_value::(value).unwrap() + } + + #[test] + fn preserves_date_only_last_modified_verbatim() { + let annotations = annotations_of(json!({ "lastModified": "2025-01-12" })); + assert_eq!(annotations.last_modified.as_deref(), Some("2025-01-12")); + } + + #[test] + fn preserves_rfc3339_last_modified_verbatim() { + let value = "2025-01-12T15:00:58Z"; + let annotations = annotations_of(json!({ "lastModified": value })); + assert_eq!(annotations.last_modified.as_deref(), Some(value)); + } + + #[test] + fn missing_last_modified_is_none() { + let annotations = annotations_of(json!({})); + assert_eq!(annotations.last_modified, None); + } + + #[test] + fn null_last_modified_is_none() { + let annotations = annotations_of(json!({ "lastModified": null })); + assert_eq!(annotations.last_modified, None); + } + + #[test] + fn invalid_last_modified_string_is_preserved() { + let annotations = annotations_of(json!({ "lastModified": "not-a-date" })); + assert_eq!(annotations.last_modified.as_deref(), Some("not-a-date")); + } + + #[test] + fn timestamp_round_trips_as_rfc3339_string() { + let timestamp = Utc.with_ymd_and_hms(2025, 1, 12, 15, 0, 58).unwrap(); + let annotations = Annotations::default().with_timestamp(timestamp); + + let value = serde_json::to_value(&annotations).unwrap(); + assert_eq!(value["lastModified"], json!(timestamp.to_rfc3339())); + + let round_tripped: Annotations = serde_json::from_value(value).unwrap(); + assert_eq!(round_tripped, annotations); + } +} diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 66b0b79d9..81a5ab250 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -54,8 +54,7 @@ "type": [ "string", "null" - ], - "format": "date-time" + ] }, "priority": { "type": [ diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 66b0b79d9..81a5ab250 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -54,8 +54,7 @@ "type": [ "string", "null" - ], - "format": "date-time" + ] }, "priority": { "type": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index c9d1429a1..29b3624ee 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -54,8 +54,7 @@ "type": [ "string", "null" - ], - "format": "date-time" + ] }, "priority": { "type": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index c9d1429a1..29b3624ee 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -54,8 +54,7 @@ "type": [ "string", "null" - ], - "format": "date-time" + ] }, "priority": { "type": [ From dd30a70f848e50dc7766c8a4eb616936f88e9afe Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:02:26 -0400 Subject: [PATCH 223/333] feat!: add MRTR behavior support (SEP-2322) (#929) * feat!: add MRTR behavior support * feat: harden SEP-2322 MRTR support * ci: diff public API only on features common to base and head cargo public-api builds both revisions with the same feature set, so a feature introduced (or removed) by a PR broke the build of the other revision. Restrict the all-features diff to features present in both the base and head revisions. --- .github/workflows/ci.yml | 24 +- conformance/src/bin/server.rs | 21 +- crates/rmcp-macros/src/prompt_handler.rs | 2 +- crates/rmcp-macros/src/task_handler.rs | 11 +- crates/rmcp-macros/src/tool_handler.rs | 2 +- crates/rmcp/Cargo.toml | 13 + crates/rmcp/src/handler/server.rs | 32 +- crates/rmcp/src/handler/server/prompt.rs | 39 +- crates/rmcp/src/handler/server/router.rs | 6 +- .../rmcp/src/handler/server/router/prompt.rs | 7 +- crates/rmcp/src/handler/server/router/tool.rs | 18 +- crates/rmcp/src/handler/server/tool.rs | 65 +- .../rmcp/src/handler/server/wrapper/json.rs | 9 +- crates/rmcp/src/model.rs | 4 + crates/rmcp/src/model/mrtr.rs | 131 +++- crates/rmcp/src/model/request_state.rs | 577 +++++++++++++++++ crates/rmcp/src/service.rs | 3 + crates/rmcp/src/service/client.rs | 381 ++++++++++- crates/rmcp/tests/test_cancelled_response.rs | 11 +- crates/rmcp/tests/test_mrtr_behavior.rs | 594 ++++++++++++++++++ .../tests/test_resource_not_found_version.rs | 4 +- crates/rmcp/tests/test_result_type_wire.rs | 29 + .../tests/test_stdio_response_concurrency.rs | 11 +- crates/rmcp/tests/test_structured_output.rs | 14 +- .../tests/test_tool_disable_notification.rs | 8 +- examples/servers/Cargo.toml | 5 + examples/servers/README.md | 11 + examples/servers/src/common/counter.rs | 12 +- examples/servers/src/mrtr.rs | 199 ++++++ examples/servers/src/sampling_stdio.rs | 5 +- 30 files changed, 2121 insertions(+), 127 deletions(-) create mode 100644 crates/rmcp/src/model/request_state.rs create mode 100644 crates/rmcp/tests/test_mrtr_behavior.rs create mode 100644 crates/rmcp/tests/test_result_type_wire.rs create mode 100644 examples/servers/src/mrtr.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbe6c2618..61ff2c058 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,10 +170,24 @@ jobs: - name: Check rmcp (all features except local) run: | - FEATURES=$(cargo metadata --no-deps --format-version 1 \ - | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[] - | select(startswith("__") | not) - | select(. != "local")] | join(",")') + BASE_SHA=${{ github.event.pull_request.base.sha }} + # `cargo public-api diff` builds both revisions with the same feature + # set, so a feature that exists on only one side (e.g. a feature added + # or removed by this PR) would fail to build the other revision. Diff + # only the features present in BOTH the base and the head; features + # unique to one side are necessarily pure additions/removals, which the + # release-type deny flags already govern. + list_features() { + cargo metadata --no-deps --format-version 1 --manifest-path "$1/Cargo.toml" \ + | jq -r '.packages[] | select(.name == "rmcp") | .features | keys[] + | select(startswith("__") | not) + | select(. != "local")' + } + list_features "." | sort -u > "$RUNNER_TEMP/head_features" + git worktree add --detach "$RUNNER_TEMP/rmcp-base" "$BASE_SHA" + list_features "$RUNNER_TEMP/rmcp-base" | sort -u > "$RUNNER_TEMP/base_features" + git worktree remove --force "$RUNNER_TEMP/rmcp-base" + FEATURES=$(comm -12 "$RUNNER_TEMP/head_features" "$RUNNER_TEMP/base_features" | paste -sd, -) cargo public-api \ --package rmcp \ --features "$FEATURES" \ @@ -181,7 +195,7 @@ jobs: diff \ $DENY \ --force \ - ${{ github.event.pull_request.base.sha }}..${{ github.sha }} + "$BASE_SHA"..${{ github.sha }} spelling: name: spell check with typos diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index b0518a048..2c2f63d41 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -213,9 +213,9 @@ impl ServerHandler for ConformanceServer { &self, request: CallToolRequestParams, cx: RequestContext, - ) -> Result { + ) -> Result { let args = request.arguments.unwrap_or_default(); - match request.name.as_ref() { + let result = match request.name.as_ref() { "test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text( "This is a simple text response for testing.", )])), @@ -530,7 +530,8 @@ impl ServerHandler for ConformanceServer { format!("Unknown tool: {}", request.name), None, )), - } + }; + result.map(Into::into) } async fn list_resources( @@ -555,9 +556,9 @@ impl ServerHandler for ConformanceServer { &self, request: ReadResourceRequestParams, _cx: RequestContext, - ) -> Result { + ) -> Result { let uri = request.uri.as_str(); - match uri { + let result = match uri { "test://static-text" => Ok(ReadResourceResult::new(vec![ ResourceContents::TextResourceContents { uri: uri.into(), @@ -598,7 +599,8 @@ impl ServerHandler for ConformanceServer { )) } } - } + }; + result.map(Into::into) } async fn list_resource_templates( @@ -679,8 +681,8 @@ impl ServerHandler for ConformanceServer { &self, request: GetPromptRequestParams, _cx: RequestContext, - ) -> Result { - match request.name.as_str() { + ) -> Result { + let result = match request.name.as_str() { "test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text( Role::User, "This is a simple test prompt.", @@ -721,7 +723,8 @@ impl ServerHandler for ConformanceServer { format!("Unknown prompt: {}", request.name), None, )), - } + }; + result.map(Into::into) } async fn complete( diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index af4f24bd8..24032eaab 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -35,7 +35,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - ) -> Result { + ) -> Result { let prompt_context = rmcp::handler::server::prompt::PromptContext::new( self, request.name, diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index 5815fd35f..c743463cb 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -77,7 +77,16 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result Ok(result), + _ => Err(rmcp::ErrorData::internal_error( + "input_required is not supported for task-based tool calls", + None, + )), + }); Ok( Box::new(ToolCallTaskResult::new(task_result_id, result)) as Box, diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index dc935828d..1e39eb5f3 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -47,7 +47,7 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - ) -> Result { + ) -> Result { let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); #router.call(tcc).await } diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index c7b3b7709..4f4e23073 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -22,6 +22,7 @@ features = [ "client-side-sse", "elicitation", "macros", + "request-state", "reqwest", "reqwest-native-tls", "reqwest-tls-no-provider", @@ -64,6 +65,10 @@ schemars = { version = "1.0", optional = true, features = ["chrono04"] } # for image encoding base64 = { version = "0.22", optional = true } +# for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature) +hmac = { version = "0.12", optional = true } +sha2 = { version = "0.10", optional = true } + # for HTTP client reqwest = { version = "0.13.2", default-features = false, features = [ "json", @@ -120,6 +125,9 @@ server = ["transport-async-rw", "schemars", "dep:pastey"] macros = ["dep:rmcp-macros", "dep:pastey"] elicitation = ["dep:url"] +# SEP-2322 requestState integrity helper (HMAC-SHA256 seal/open codec) +request-state = ["dep:hmac", "dep:sha2", "base64"] + # reqwest http client __reqwest = ["dep:reqwest"] @@ -315,6 +323,11 @@ name = "test_trace_context" required-features = ["server", "client"] path = "tests/test_trace_context.rs" +[[test]] +name = "test_mrtr_behavior" +required-features = ["server", "client"] +path = "tests/test_mrtr_behavior.rs" + [[test]] name = "test_prompt_macros" required-features = ["server", "client"] diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 3cec563e8..779901764 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -27,6 +27,9 @@ impl Service for H { ) -> Result<::Resp, McpError> { // `context` is moved into the dispatch below, so read the negotiated version first. let protocol_version = context.protocol_version(); + let mrtr_supported = protocol_version + .as_ref() + .is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); let result = match request { ClientRequest::InitializeRequest(request) => self .initialize(request.params, context) @@ -46,7 +49,7 @@ impl Service for H { ClientRequest::GetPromptRequest(request) => self .get_prompt(request.params, context) .await - .map(ServerResult::GetPromptResult), + .map(ServerResult::from), ClientRequest::ListPromptsRequest(request) => self .list_prompts(request.params, context) .await @@ -62,7 +65,7 @@ impl Service for H { ClientRequest::ReadResourceRequest(request) => self .read_resource(request.params, context) .await - .map(ServerResult::ReadResourceResult), + .map(ServerResult::from), ClientRequest::SubscribeRequest(request) => self .subscribe(request.params, context) .await @@ -105,7 +108,7 @@ impl Service for H { } else { self.call_tool(request.params, context) .await - .map(ServerResult::CallToolResult) + .map(ServerResult::from) } } ClientRequest::ListToolsRequest(request) => self @@ -133,6 +136,17 @@ impl Service for H { .await .map(ServerResult::CancelTaskResult), }; + let result = result.and_then(|result| { + if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported { + Err(McpError::invalid_request( + "InputRequiredResult requires negotiated protocol version 2026-07-28 or newer", + None, + )) + } else { + Ok(result) + } + }); + // SEP-2164: peers negotiating 2026-07-28+ get the standard INVALID_PARAMS code for // resource-not-found; older peers keep RESOURCE_NOT_FOUND. ISO `YYYY-MM-DD` versions // compare lexically the same as chronologically. @@ -229,7 +243,7 @@ macro_rules! server_handler_methods { &self, request: GetPromptRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn list_prompts( @@ -259,7 +273,7 @@ macro_rules! server_handler_methods { &self, request: ReadResourceRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err( McpError::method_not_found::(), )) @@ -312,7 +326,7 @@ macro_rules! server_handler_methods { &self, request: CallToolRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } fn list_tools( @@ -485,7 +499,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: GetPromptRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).get_prompt(request, context) } @@ -518,7 +532,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: ReadResourceRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).read_resource(request, context) } @@ -542,7 +556,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: CallToolRequestParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).call_tool(request, context) } diff --git a/crates/rmcp/src/handler/server/prompt.rs b/crates/rmcp/src/handler/server/prompt.rs index ffce6b2e0..a75e02713 100644 --- a/crates/rmcp/src/handler/server/prompt.rs +++ b/crates/rmcp/src/handler/server/prompt.rs @@ -15,7 +15,7 @@ pub use super::common::{Extension, RequestId}; use crate::{ RoleServer, handler::server::wrapper::Parameters, - model::{GetPromptResult, PromptMessage}, + model::{GetPromptResponse, GetPromptResult, InputRequiredResult, PromptMessage}, service::{MaybeBoxFuture, MaybeSend, MaybeSendFuture, RequestContext}, }; @@ -59,12 +59,12 @@ pub trait GetPromptHandler { fn handle( self, context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result>; + ) -> MaybeBoxFuture<'_, Result>; } /// Type alias for dynamic prompt handlers #[cfg(not(feature = "local"))] -pub type DynGetPromptHandler = dyn for<'a> Fn(PromptContext<'a, S>) -> BoxFuture<'a, Result> +pub type DynGetPromptHandler = dyn for<'a> Fn(PromptContext<'a, S>) -> BoxFuture<'a, Result> + Send + Sync; @@ -73,7 +73,7 @@ pub type DynGetPromptHandler = dyn for<'a> Fn( PromptContext<'a, S>, ) -> futures::future::LocalBoxFuture< 'a, - Result, + Result, >; /// Adapter type for async methods that return `Vec` @@ -91,28 +91,35 @@ pub struct SyncPromptMethodAdapter(PhantomData R>); /// Trait for types that can be converted into GetPromptResult pub trait IntoGetPromptResult { - fn into_get_prompt_result(self) -> Result; + fn into_get_prompt_result(self) -> Result; } impl IntoGetPromptResult for GetPromptResult { - fn into_get_prompt_result(self) -> Result { - Ok(self) + fn into_get_prompt_result(self) -> Result { + Ok(self.into()) + } +} + +impl IntoGetPromptResult for InputRequiredResult { + fn into_get_prompt_result(self) -> Result { + Ok(self.into()) } } impl IntoGetPromptResult for Vec { - fn into_get_prompt_result(self) -> Result { + fn into_get_prompt_result(self) -> Result { Ok(GetPromptResult { result_type: Default::default(), description: None, messages: self, meta: None, - }) + } + .into()) } } impl IntoGetPromptResult for Result { - fn into_get_prompt_result(self) -> Result { + fn into_get_prompt_result(self) -> Result { self.and_then(|v| v.into_get_prompt_result()) } } @@ -129,7 +136,7 @@ pin_project_lite::pin_project! { }, Ready { #[pin] - result: futures::future::Ready>, + result: futures::future::Ready>, } } } @@ -139,7 +146,7 @@ where F: Future, R: IntoGetPromptResult, { - type Output = Result; + type Output = Result; fn poll( self: std::pin::Pin<&mut Self>, @@ -216,7 +223,7 @@ macro_rules! impl_prompt_handler_for { fn handle( self, mut context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result> + ) -> MaybeBoxFuture<'_, Result> { $( let result = $Tn::from_context_part(&mut context); @@ -249,7 +256,7 @@ macro_rules! impl_prompt_handler_for { fn handle( self, mut context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result> + ) -> MaybeBoxFuture<'_, Result> { $( let result = $Tn::from_context_part(&mut context); @@ -280,7 +287,7 @@ macro_rules! impl_prompt_handler_for { fn handle( self, mut context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result> + ) -> MaybeBoxFuture<'_, Result> { // Extract all parameters before moving into the async block $( @@ -315,7 +322,7 @@ macro_rules! impl_prompt_handler_for { fn handle( self, mut context: PromptContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result> + ) -> MaybeBoxFuture<'_, Result> { $( let result = $Tn::from_context_part(&mut context); diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index 45ff9a586..e934137b8 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -106,7 +106,7 @@ where context, ); let result = self.tool_router.call(tool_call_context).await?; - Ok(ServerResult::CallToolResult(result)) + Ok(ServerResult::from(result)) } else { self.service .handle_request(ClientRequest::CallToolRequest(request), context) @@ -129,7 +129,7 @@ where context, ); let result = self.prompt_router.get_prompt(prompt_context).await?; - Ok(ServerResult::GetPromptResult(result)) + Ok(ServerResult::from(result)) } else { self.service .handle_request(ClientRequest::GetPromptRequest(request), context) @@ -193,7 +193,7 @@ mod tests { async fn test_router_deferred_notifier_e2e() { let mut router = Router::new(DummyHandler).with_tool(tool::ToolRoute::new_dyn( Tool::new("my_tool", "test", Arc::new(Default::default())), - |_ctx| Box::pin(async { Ok(CallToolResult::default()) }), + |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), )); let id_provider: Arc = diff --git a/crates/rmcp/src/handler/server/router/prompt.rs b/crates/rmcp/src/handler/server/router/prompt.rs index e952b2a39..509fb4287 100644 --- a/crates/rmcp/src/handler/server/router/prompt.rs +++ b/crates/rmcp/src/handler/server/router/prompt.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, sync::Arc}; use crate::{ handler::server::prompt::{DynGetPromptHandler, GetPromptHandler, PromptContext}, - model::{GetPromptResult, Prompt}, + model::{GetPromptResponse, Prompt}, service::{MaybeBoxFuture, MaybeSend}, }; @@ -50,7 +50,8 @@ impl PromptRoute { where H: for<'a> Fn( PromptContext<'a, S>, - ) -> MaybeBoxFuture<'a, Result> + ) + -> MaybeBoxFuture<'a, Result> + MaybeSend + 'static, { @@ -175,7 +176,7 @@ where pub async fn get_prompt( &self, context: PromptContext<'_, S>, - ) -> Result { + ) -> Result { let item = self.map.get(context.name.as_str()).ok_or_else(|| { crate::ErrorData::invalid_params( format!("prompt '{}' not found", context.name), diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index dece66d95..215116250 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -137,21 +137,19 @@ use crate::{ tool::{CallToolHandler, DynCallToolHandler, ToolCallContext}, tool_name_validation::validate_and_warn_tool_name, }, - model::{CallToolResult, ContentBlock, ErrorCode, Tool, ToolAnnotations}, + model::{CallToolResponse, CallToolResult, ContentBlock, ErrorCode, Tool, ToolAnnotations}, service::{MaybeBoxFuture, MaybeSend}, }; const TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX: &str = "failed to deserialize parameters:"; -fn into_tool_argument_error(error: crate::ErrorData) -> Result { +fn into_tool_argument_error(error: crate::ErrorData) -> Result { if error.code == ErrorCode::INVALID_PARAMS && error .message .starts_with(TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX) { - return Ok(CallToolResult::error(vec![ContentBlock::text( - error.message, - )])); + return Ok(CallToolResult::error(vec![ContentBlock::text(error.message)]).into()); } Err(error) @@ -200,7 +198,8 @@ impl ToolRoute { where C: for<'a> Fn( ToolCallContext<'a, S>, - ) -> MaybeBoxFuture<'a, Result> + ) + -> MaybeBoxFuture<'a, Result> + MaybeSend + 'static, { @@ -561,7 +560,7 @@ where pub async fn call( &self, context: ToolCallContext<'_, S>, - ) -> Result { + ) -> Result { let name = context.name(); if self.disabled.contains(name) { return Err(crate::ErrorData::invalid_params("tool not found", None)); @@ -679,6 +678,9 @@ mod tests { .call(ctx) .await .expect("argument validation should be a tool result"); + let CallToolResponse::Complete(result) = result else { + panic!("expected complete CallToolResult"); + }; assert_eq!(result.is_error, Some(true)); let text = result @@ -696,7 +698,7 @@ mod tests { let service = DummyService; let mut router = ToolRouter::new().with_route(ToolRoute::new_dyn( crate::model::Tool::new("test_tool", "a test tool", Arc::new(Default::default())), - |_ctx| Box::pin(async { Ok(CallToolResult::default()) }), + |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), )); router.disable_route("test_tool"); diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index bf350797d..cb4966df0 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -16,7 +16,10 @@ pub use super::{ use crate::{ RoleServer, handler::server::wrapper::Parameters, - model::{CallToolRequestParams, CallToolResult, IntoContents, JsonObject}, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, InputRequiredResult, IntoContents, + JsonObject, + }, service::{MaybeBoxFuture, MaybeSend, MaybeSendFuture, RequestContext}, }; @@ -77,36 +80,46 @@ impl AsRequestContext for ToolCallContext<'_, S> { } pub trait IntoCallToolResult { - fn into_call_tool_result(self) -> Result; + fn into_call_tool_result(self) -> Result; } impl IntoCallToolResult for T { - fn into_call_tool_result(self) -> Result { - Ok(CallToolResult::success(self.into_contents())) + fn into_call_tool_result(self) -> Result { + Ok(CallToolResult::success(self.into_contents()).into()) } } impl IntoCallToolResult for CallToolResult { - fn into_call_tool_result(self) -> Result { - Ok(self) + fn into_call_tool_result(self) -> Result { + Ok(self.into()) + } +} + +impl IntoCallToolResult for InputRequiredResult { + fn into_call_tool_result(self) -> Result { + Ok(self.into()) } } impl IntoCallToolResult for crate::ErrorData { - fn into_call_tool_result(self) -> Result { + fn into_call_tool_result(self) -> Result { Err(self) } } impl IntoCallToolResult for Result { - fn into_call_tool_result(self) -> Result { + fn into_call_tool_result(self) -> Result { match self { Ok(value) => value.into_call_tool_result(), Err(error) => match error.into_call_tool_result() { - Ok(mut result) => { + Ok(CallToolResponse::Complete(mut result)) => { result.is_error = Some(true); - Ok(result) + Ok(result.into()) } + Ok(CallToolResponse::InputRequired(_)) => Err(crate::ErrorData::internal_error( + "InputRequiredResult cannot be returned from a tool error branch", + None, + )), Err(e) => Err(e), }, } @@ -124,7 +137,7 @@ pin_project_lite::pin_project! { }, Ready { #[pin] - result: Ready>, + result: Ready>, } } } @@ -134,7 +147,7 @@ where F: Future, R: IntoCallToolResult, { - type Output = Result; + type Output = Result; fn poll( self: std::pin::Pin<&mut Self>, @@ -153,20 +166,21 @@ pub trait CallToolHandler { fn call( self, context: ToolCallContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result>; + ) -> MaybeBoxFuture<'_, Result>; } #[cfg(not(feature = "local"))] -pub type DynCallToolHandler = dyn for<'s> Fn(ToolCallContext<'s, S>) -> BoxFuture<'s, Result> +pub type DynCallToolHandler = dyn for<'s> Fn(ToolCallContext<'s, S>) -> BoxFuture<'s, Result> + Send + Sync; #[cfg(feature = "local")] -pub type DynCallToolHandler = - dyn for<'s> Fn( - ToolCallContext<'s, S>, - ) - -> futures::future::LocalBoxFuture<'s, Result>; +pub type DynCallToolHandler = dyn for<'s> Fn( + ToolCallContext<'s, S>, +) -> futures::future::LocalBoxFuture< + 's, + Result, +>; // Tool-specific extractor for tool name #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] @@ -205,7 +219,10 @@ impl FromContextPart> for JsonObject { } impl<'s, S> ToolCallContext<'s, S> { - pub fn invoke(self, h: H) -> MaybeBoxFuture<'s, Result> + pub fn invoke( + self, + h: H, + ) -> MaybeBoxFuture<'s, Result> where H: CallToolHandler, { @@ -248,7 +265,7 @@ macro_rules! impl_for { fn call( self, mut context: ToolCallContext<'_, S>, - ) -> MaybeBoxFuture<'_, Result>{ + ) -> MaybeBoxFuture<'_, Result>{ $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { @@ -279,7 +296,7 @@ macro_rules! impl_for { fn call( self, mut context: ToolCallContext, - ) -> MaybeBoxFuture<'static, Result>{ + ) -> MaybeBoxFuture<'static, Result>{ $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { @@ -308,7 +325,7 @@ macro_rules! impl_for { fn call( self, mut context: ToolCallContext, - ) -> MaybeBoxFuture<'static, Result> { + ) -> MaybeBoxFuture<'static, Result> { $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { @@ -333,7 +350,7 @@ macro_rules! impl_for { fn call( self, mut context: ToolCallContext, - ) -> MaybeBoxFuture<'static, Result> { + ) -> MaybeBoxFuture<'static, Result> { $( let result = $Tn::from_context_part(&mut context); let $Tn = match result { diff --git a/crates/rmcp/src/handler/server/wrapper/json.rs b/crates/rmcp/src/handler/server/wrapper/json.rs index c03fbd032..7c5297963 100644 --- a/crates/rmcp/src/handler/server/wrapper/json.rs +++ b/crates/rmcp/src/handler/server/wrapper/json.rs @@ -3,7 +3,10 @@ use std::borrow::Cow; use schemars::JsonSchema; use serde::Serialize; -use crate::{handler::server::tool::IntoCallToolResult, model::CallToolResult}; +use crate::{ + handler::server::tool::IntoCallToolResult, + model::{CallToolResponse, CallToolResult}, +}; /// Json wrapper for structured output /// @@ -27,7 +30,7 @@ impl JsonSchema for Json { // Implementation for Json to create structured content impl IntoCallToolResult for Json { - fn into_call_tool_result(self) -> Result { + fn into_call_tool_result(self) -> Result { let value = serde_json::to_value(self.0).map_err(|e| { crate::ErrorData::internal_error( format!("Failed to serialize structured content: {}", e), @@ -35,6 +38,6 @@ impl IntoCallToolResult for Json { ) })?; - Ok(CallToolResult::structured(value)) + Ok(CallToolResult::structured(value).into()) } } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 3f1774716..44f15f446 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -14,6 +14,8 @@ mod extension; mod meta; mod mrtr; mod prompt; +#[cfg(feature = "request-state")] +mod request_state; mod resource; mod serde_impl; mod task; @@ -26,6 +28,8 @@ pub use extension::*; pub use meta::*; pub use mrtr::*; pub use prompt::*; +#[cfg(feature = "request-state")] +pub use request_state::*; pub use resource::*; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; diff --git a/crates/rmcp/src/model/mrtr.rs b/crates/rmcp/src/model/mrtr.rs index e4a5b3fb8..2b8ba71be 100644 --- a/crates/rmcp/src/model/mrtr.rs +++ b/crates/rmcp/src/model/mrtr.rs @@ -10,13 +10,47 @@ //! [`InputRequiredResult`] instead of the normal result. The client fulfills the //! [`InputRequests`], then retries the original request with [`InputResponses`] and //! the echoed `requestState`. +//! +//! # Using MRTR +//! +//! **Server:** return an [`InputRequiredResult`] from a tool/prompt/resource +//! handler via the matching outcome enum ([`CallToolResponse`], +//! [`GetPromptResponse`], [`ReadResourceResponse`]). The SDK only lets an +//! `InputRequiredResult` reach a peer that negotiated protocol version +//! `2026-07-28` or newer; older peers get a protocol error instead. +//! +//! **Client:** the high-level `RunningService` helpers — `call_tool`, +//! `get_prompt`, and `read_resource` — automatically fulfil each +//! [`InputRequest`] through the local `ClientHandler` and retry, up to +//! [`DEFAULT_MRTR_MAX_ROUNDS`]. Use the `*_once` variants (e.g. +//! `call_tool_once`) to receive an [`InputRequiredResult`] directly and drive +//! the rounds yourself. +//! +//! # `requestState` is untrusted +//! +//! The client echoes `requestState` back verbatim, so a stateless server that +//! stores meaningful data in it MUST verify integrity before trusting the echoed +//! value. Enable the `request-state` feature and use `RequestStateCodec` to seal +//! and open it, or keep the state server-side and use `requestState` only as an +//! opaque handle. +//! +//! A complete runnable walkthrough lives in the `servers_mrtr` example. use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::{CreateMessageRequest, ElicitRequest, ListRootsRequest, Meta, ResultType}; +use super::{ + CallToolResult, CreateMessageRequest, ElicitRequest, GetPromptResult, ListRootsRequest, Meta, + ReadResourceResult, ResultType, ServerResult, +}; + +/// Default maximum number of MRTR rounds a high-level client call will drive. +/// +/// This matches the default used by other Tier 1 SDKs and prevents a +/// misbehaving peer from keeping a request alive indefinitely. +pub const DEFAULT_MRTR_MAX_ROUNDS: usize = 10; /// A server-initiated request that can appear inside [`InputRequests`]. /// @@ -53,6 +87,101 @@ pub type InputRequests = BTreeMap; /// for use as a `BTreeMap` value. pub type InputResponses = BTreeMap; +/// Result of a `tools/call` request, including the MRTR intermediate result. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum CallToolResponse { + /// The server completed the tool call. + Complete(CallToolResult), + /// The server requires client-side input before the tool call can complete. + InputRequired(InputRequiredResult), +} + +impl From for CallToolResponse { + fn from(result: CallToolResult) -> Self { + Self::Complete(result) + } +} + +impl From for CallToolResponse { + fn from(result: InputRequiredResult) -> Self { + Self::InputRequired(result) + } +} + +impl From for ServerResult { + fn from(response: CallToolResponse) -> Self { + match response { + CallToolResponse::Complete(result) => ServerResult::CallToolResult(result), + CallToolResponse::InputRequired(result) => ServerResult::InputRequiredResult(result), + } + } +} + +/// Result of a `prompts/get` request, including the MRTR intermediate result. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum GetPromptResponse { + /// The server completed the prompt request. + Complete(GetPromptResult), + /// The server requires client-side input before the prompt can be returned. + InputRequired(InputRequiredResult), +} + +impl From for GetPromptResponse { + fn from(result: GetPromptResult) -> Self { + Self::Complete(result) + } +} + +impl From for GetPromptResponse { + fn from(result: InputRequiredResult) -> Self { + Self::InputRequired(result) + } +} + +impl From for ServerResult { + fn from(response: GetPromptResponse) -> Self { + match response { + GetPromptResponse::Complete(result) => ServerResult::GetPromptResult(result), + GetPromptResponse::InputRequired(result) => ServerResult::InputRequiredResult(result), + } + } +} + +/// Result of a `resources/read` request, including the MRTR intermediate result. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum ReadResourceResponse { + /// The server completed the resource read. + Complete(ReadResourceResult), + /// The server requires client-side input before the resource can be returned. + InputRequired(InputRequiredResult), +} + +impl From for ReadResourceResponse { + fn from(result: ReadResourceResult) -> Self { + Self::Complete(result) + } +} + +impl From for ReadResourceResponse { + fn from(result: InputRequiredResult) -> Self { + Self::InputRequired(result) + } +} + +impl From for ServerResult { + fn from(response: ReadResourceResponse) -> Self { + match response { + ReadResourceResponse::Complete(result) => ServerResult::ReadResourceResult(result), + ReadResourceResponse::InputRequired(result) => { + ServerResult::InputRequiredResult(result) + } + } + } +} + /// A result indicating that additional input is needed before the request /// can be completed. /// diff --git a/crates/rmcp/src/model/request_state.rs b/crates/rmcp/src/model/request_state.rs new file mode 100644 index 000000000..76d922663 --- /dev/null +++ b/crates/rmcp/src/model/request_state.rs @@ -0,0 +1,577 @@ +//! Integrity protection for SEP-2322 `requestState`. +//! +//! In the multi round-trip request (MRTR) flow, a server places an opaque +//! `requestState` string in an [`InputRequiredResult`](super::InputRequiredResult) +//! and the client echoes it back verbatim on retry. From the server's point of +//! view the echoed value is **untrusted, attacker-controlled input**: a client +//! can send back anything it likes. Per SEP-2322, a server that lets +//! `requestState` influence authorization, resource access, or business logic +//! MUST protect its integrity and reject values that fail verification. +//! +//! [`RequestStateCodec`] provides an opt-in way to do this. It seals a payload +//! into an opaque string with an HMAC-SHA256 tag and opens it again, rejecting +//! any value that was forged or tampered with. +//! +//! To follow the spec's replay-prevention guidance without hand-rolling the +//! checks, the codec supports two bindings via [`SealOptions`]: +//! +//! * **Associated data** — arbitrary context (e.g. the authenticated principal +//! plus a digest of the originating request) that is mixed into the tag but +//! not stored in the token. [`open_with`](RequestStateCodec::open_with) only +//! succeeds when the caller supplies the same context, so a value cannot be +//! replayed by a different principal or against a different request. This is +//! *fail-closed*: forgetting to pass the context makes verification fail. +//! * **TTL** — a relative expiry stamped into the token; opening a value past +//! its expiry fails with [`RequestStateError::Expired`]. +//! +//! Single-use/nonce enforcement (for one-time redemptions) still has to be done +//! server-side, as the spec notes. +//! +//! This helper is only about *integrity*, not *confidentiality*: the sealed +//! payload is signed, not encrypted, so it is base64url-readable by anyone. Do +//! not put secrets in it. +//! +//! Using the codec is entirely optional. A server that keeps its state +//! server-side, or that does not trust `requestState` for anything security +//! sensitive, can keep building the string by hand via +//! [`InputRequiredResult::from_request_state`](super::InputRequiredResult::from_request_state). +//! +//! # Examples +//! +//! ``` +//! use rmcp::model::{RequestStateCodec, SealOptions}; +//! +//! // Derive the key from a per-process secret; keep it out of client reach. +//! let codec = RequestStateCodec::new(b"a-32-byte-or-longer-secret-key!!!"); +//! +//! // Bind the state to the caller and the originating request. +//! let context = b"user:alice|tools/call:weather"; +//! let sealed = codec.seal_with( +//! b"step=2", +//! &SealOptions::new().associated_data(context), +//! ); +//! +//! // On retry the client echoes `sealed` back untouched; the server re-derives +//! // the same context and opens it. +//! let opened = codec.open_with(&sealed, context).expect("integrity check passes"); +//! assert_eq!(opened, b"step=2"); +//! +//! // A different principal (different context) is rejected. +//! assert!(codec.open_with(&sealed, b"user:bob|tools/call:weather").is_err()); +//! ``` + +use std::time::Duration; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, Mac}; +use serde::{Serialize, de::DeserializeOwned}; +use sha2::Sha256; +use thiserror::Error; + +type HmacSha256 = Hmac; + +/// Version tag prefixing every sealed value, so the wire format can evolve. +const VERSION: &str = "rs1"; + +/// Domain-separation label mixed into the HMAC so a `requestState` tag can never +/// be confused with an HMAC computed for some other purpose using the same key. +const DOMAIN: &[u8] = b"rmcp/mrtr/request-state/v1"; + +/// Length of the big-endian expiry prefix (unix milliseconds) stored at the +/// front of every sealed body. `0` means "no expiry". +const EXPIRY_LEN: usize = 8; + +/// Errors returned when opening a sealed [`RequestStateCodec`] value. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum RequestStateError { + /// The value is not a well-formed sealed request state (wrong prefix or + /// missing sections). + #[error("request state is malformed or uses an unsupported format")] + MalformedFormat, + + /// A section of the value was not valid base64url. + #[error("request state is not valid base64url")] + InvalidEncoding, + + /// The HMAC tag did not match; the value was forged, tampered with, or + /// opened with the wrong associated data. + #[error("request state failed integrity verification")] + IntegrityCheckFailed, + + /// The value carried a TTL that has already elapsed. + #[error("request state has expired")] + Expired, + + /// The sealed payload could not be serialized to JSON. + #[error("failed to serialize request state payload: {0}")] + Serialization(#[source] serde_json::Error), + + /// The opened payload could not be deserialized from JSON. + #[error("failed to deserialize request state payload: {0}")] + Deserialization(#[source] serde_json::Error), +} + +/// Options controlling how a value is sealed by [`RequestStateCodec`]. +/// +/// Defaults to no associated data and no expiry, which is equivalent to the +/// bare [`seal`](RequestStateCodec::seal) / [`open`](RequestStateCodec::open) +/// methods. +#[derive(Clone, Copy, Debug, Default)] +pub struct SealOptions<'a> { + associated_data: &'a [u8], + ttl: Option, +} + +impl<'a> SealOptions<'a> { + /// Creates empty options (no associated data, no expiry). + pub fn new() -> Self { + Self::default() + } + + /// Binds the sealed value to `associated_data`. The same bytes must be + /// supplied to [`open_with`](RequestStateCodec::open_with); the data is + /// authenticated but not stored in the token. + /// + /// Use this to bind the state to the authenticated principal and/or the + /// originating request (e.g. method name plus a digest of its parameters). + pub fn associated_data(mut self, associated_data: &'a [u8]) -> Self { + self.associated_data = associated_data; + self + } + + /// Sets a relative time-to-live after which opening the value fails with + /// [`RequestStateError::Expired`]. + pub fn ttl(mut self, ttl: Duration) -> Self { + self.ttl = Some(ttl); + self + } +} + +/// A keyed codec that seals and opens SEP-2322 `requestState` values with +/// HMAC-SHA256 integrity protection. +/// +/// Construct one codec per signing key and reuse it for the lifetime of the +/// key. The same key must be used to [`seal`](Self::seal) and +/// [`open`](Self::open) a value, so it has to survive across the rounds of a +/// single MRTR exchange (e.g. a stable per-process or per-deployment secret). +/// +/// The key may be any length; HMAC internally normalizes it. For meaningful +/// security use a high-entropy key of at least 32 bytes. +#[derive(Clone)] +pub struct RequestStateCodec { + key: Box<[u8]>, +} + +impl std::fmt::Debug for RequestStateCodec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never leak the signing key through Debug output. + f.debug_struct("RequestStateCodec") + .field("key", &"") + .finish() + } +} + +impl RequestStateCodec { + /// Creates a codec from a signing key. + pub fn new(key: impl Into>) -> Self { + Self { + key: key.into().into_boxed_slice(), + } + } + + /// Seals raw bytes into an opaque, integrity-protected string suitable for + /// use as `requestState`. + pub fn seal(&self, payload: &[u8]) -> String { + self.seal_with(payload, &SealOptions::default()) + } + + /// Seals raw bytes with [`SealOptions`] (associated data and/or TTL). + pub fn seal_with(&self, payload: &[u8], options: &SealOptions<'_>) -> String { + self.seal_at(payload, options, Self::now_ms()) + } + + /// Seals a serializable value by encoding it as JSON before sealing. + /// + /// # Errors + /// + /// Returns [`RequestStateError::Serialization`] if `value` cannot be encoded + /// as JSON. + pub fn seal_json(&self, value: &T) -> Result { + self.seal_json_with(value, &SealOptions::default()) + } + + /// Seals a serializable value with [`SealOptions`]. + /// + /// # Errors + /// + /// Returns [`RequestStateError::Serialization`] if `value` cannot be encoded + /// as JSON. + pub fn seal_json_with( + &self, + value: &T, + options: &SealOptions<'_>, + ) -> Result { + let payload = serde_json::to_vec(value).map_err(RequestStateError::Serialization)?; + Ok(self.seal_with(&payload, options)) + } + + /// Opens a sealed value that was sealed without associated data, verifying + /// its integrity and expiry and returning the original bytes. + /// + /// # Errors + /// + /// See [`open_with`](Self::open_with). + pub fn open(&self, sealed: &str) -> Result, RequestStateError> { + self.open_with(sealed, &[]) + } + + /// Opens a sealed value, verifying its integrity against `associated_data` + /// and checking its expiry. + /// + /// `associated_data` must match the bytes passed to + /// [`SealOptions::associated_data`] when the value was sealed (use `&[]` for + /// values sealed without it). + /// + /// # Errors + /// + /// - [`RequestStateError::IntegrityCheckFailed`] if the value was not + /// produced by this key or the associated data differs. + /// - [`RequestStateError::Expired`] if the value's TTL has elapsed. + /// - [`RequestStateError::MalformedFormat`] or + /// [`RequestStateError::InvalidEncoding`] if it is not a well-formed sealed + /// value. + pub fn open_with( + &self, + sealed: &str, + associated_data: &[u8], + ) -> Result, RequestStateError> { + self.open_at(sealed, associated_data, Self::now_ms()) + } + + /// Opens a sealed value (no associated data) and deserializes its JSON + /// payload. + /// + /// # Errors + /// + /// See [`open_json_with`](Self::open_json_with). + pub fn open_json(&self, sealed: &str) -> Result { + self.open_json_with(sealed, &[]) + } + + /// Opens a sealed value against `associated_data` and deserializes its JSON + /// payload. + /// + /// # Errors + /// + /// Returns the same integrity, expiry, and format errors as + /// [`open_with`](Self::open_with), plus [`RequestStateError::Deserialization`] + /// if the payload is not valid JSON for `T`. + pub fn open_json_with( + &self, + sealed: &str, + associated_data: &[u8], + ) -> Result { + let payload = self.open_with(sealed, associated_data)?; + serde_json::from_slice(&payload).map_err(RequestStateError::Deserialization) + } + + fn seal_at(&self, payload: &[u8], options: &SealOptions<'_>, now_ms: i64) -> String { + let expiry = match options.ttl { + Some(ttl) => now_ms.saturating_add(ttl.as_millis().min(i64::MAX as u128) as i64), + None => 0, + }; + + // body = big-endian expiry (0 = none) followed by the caller payload. + let mut body = Vec::with_capacity(EXPIRY_LEN + payload.len()); + body.extend_from_slice(&expiry.to_be_bytes()); + body.extend_from_slice(payload); + + let tag = self + .mac_for(options.associated_data, &body) + .finalize() + .into_bytes(); + + // base64url without padding encodes 3 bytes as 4 chars, rounding up. + let b64_len = |n: usize| n.div_ceil(3) * 4; + let mut out = + String::with_capacity(VERSION.len() + 2 + b64_len(body.len()) + b64_len(tag.len())); + out.push_str(VERSION); + out.push('.'); + URL_SAFE_NO_PAD.encode_string(&body, &mut out); + out.push('.'); + URL_SAFE_NO_PAD.encode_string(tag.as_slice(), &mut out); + out + } + + fn open_at( + &self, + sealed: &str, + associated_data: &[u8], + now_ms: i64, + ) -> Result, RequestStateError> { + let mut parts = sealed.split('.'); + let version = parts.next().ok_or(RequestStateError::MalformedFormat)?; + let body_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?; + let tag_b64 = parts.next().ok_or(RequestStateError::MalformedFormat)?; + if parts.next().is_some() || version != VERSION { + return Err(RequestStateError::MalformedFormat); + } + + let body = URL_SAFE_NO_PAD + .decode(body_b64) + .map_err(|_| RequestStateError::InvalidEncoding)?; + let tag = URL_SAFE_NO_PAD + .decode(tag_b64) + .map_err(|_| RequestStateError::InvalidEncoding)?; + + // `verify_slice` compares in constant time and rejects wrong-length tags. + self.mac_for(associated_data, &body) + .verify_slice(&tag) + .map_err(|_| RequestStateError::IntegrityCheckFailed)?; + + // The body is now authenticated, so its framing can be trusted. + if body.len() < EXPIRY_LEN { + return Err(RequestStateError::MalformedFormat); + } + let expiry = i64::from_be_bytes(body[..EXPIRY_LEN].try_into().expect("checked length")); + if expiry != 0 && now_ms > expiry { + return Err(RequestStateError::Expired); + } + + Ok(body[EXPIRY_LEN..].to_vec()) + } + + /// Builds an HMAC keyed for request-state tags, pre-fed with the + /// domain-separation label, a length-prefixed `associated_data`, and the + /// body. The length prefix keeps the `associated_data`/`body` boundary + /// unambiguous so distinct inputs cannot collide. + fn mac_for(&self, associated_data: &[u8], body: &[u8]) -> HmacSha256 { + let mut mac = + HmacSha256::new_from_slice(&self.key).expect("HMAC accepts keys of any length"); + mac.update(DOMAIN); + mac.update(&(associated_data.len() as u64).to_be_bytes()); + mac.update(associated_data); + mac.update(body); + mac + } + + fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seal_open_roundtrips_bytes() { + let codec = RequestStateCodec::new(b"test-key-test-key-test-key-32byte".to_vec()); + let sealed = codec.seal(b"hello world"); + assert!(sealed.starts_with("rs1.")); + assert_eq!(codec.open(&sealed).unwrap(), b"hello world"); + } + + #[test] + fn seal_open_roundtrips_json() { + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct State { + tool: String, + round: u32, + } + let codec = RequestStateCodec::new(b"another-strong-signing-key-here!!".to_vec()); + let state = State { + tool: "weather".into(), + round: 3, + }; + let sealed = codec.seal_json(&state).unwrap(); + let opened: State = codec.open_json(&sealed).unwrap(); + assert_eq!(opened, state); + } + + #[test] + fn empty_payload_roundtrips() { + let codec = RequestStateCodec::new(b"k".to_vec()); + let sealed = codec.seal(b""); + assert_eq!(codec.open(&sealed).unwrap(), b""); + } + + #[test] + fn tampered_payload_is_rejected() { + let codec = RequestStateCodec::new(b"signing-key-signing-key-signing!!".to_vec()); + let sealed = codec.seal(b"amount=100"); + + // Replace the body section but keep the original tag. + let mut parts: Vec<&str> = sealed.split('.').collect(); + let forged_body = URL_SAFE_NO_PAD.encode(b"amount=999"); + parts[1] = &forged_body; + let forged = parts.join("."); + + assert!(matches!( + codec.open(&forged), + Err(RequestStateError::IntegrityCheckFailed) + )); + } + + #[test] + fn different_key_is_rejected() { + let signer = RequestStateCodec::new(b"the-real-signing-key-value-here!!".to_vec()); + let attacker = RequestStateCodec::new(b"a-totally-different-forged-key!!!".to_vec()); + let sealed = signer.seal(b"trusted"); + assert!(matches!( + attacker.open(&sealed), + Err(RequestStateError::IntegrityCheckFailed) + )); + } + + #[test] + fn appended_bytes_are_rejected() { + let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); + let mut sealed = codec.seal(b"state"); + sealed.push('x'); + assert!(codec.open(&sealed).is_err()); + } + + #[test] + fn wrong_version_prefix_is_malformed() { + let codec = RequestStateCodec::new(b"key".to_vec()); + let sealed = codec.seal(b"state"); + let bumped = sealed.replacen("rs1.", "rs2.", 1); + assert!(matches!( + codec.open(&bumped), + Err(RequestStateError::MalformedFormat) + )); + } + + #[test] + fn missing_sections_are_malformed() { + let codec = RequestStateCodec::new(b"key".to_vec()); + assert!(matches!( + codec.open("rs1"), + Err(RequestStateError::MalformedFormat) + )); + assert!(matches!( + codec.open("rs1.onlybody"), + Err(RequestStateError::MalformedFormat) + )); + assert!(matches!( + codec.open("rs1.a.b.c"), + Err(RequestStateError::MalformedFormat) + )); + } + + #[test] + fn non_base64_sections_are_invalid_encoding() { + let codec = RequestStateCodec::new(b"key".to_vec()); + assert!(matches!( + codec.open("rs1.!!!!.!!!!"), + Err(RequestStateError::InvalidEncoding) + )); + } + + #[test] + fn debug_does_not_leak_key() { + let codec = RequestStateCodec::new(b"super-secret-key".to_vec()); + let rendered = format!("{codec:?}"); + assert!(!rendered.contains("super-secret-key")); + assert!(rendered.contains("redacted")); + } + + mod associated_data { + use super::*; + + #[test] + fn matching_context_opens() { + let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); + let ctx = b"user:alice|tools/call:weather"; + let sealed = codec.seal_with(b"state", &SealOptions::new().associated_data(ctx)); + assert_eq!(codec.open_with(&sealed, ctx).unwrap(), b"state"); + } + + #[test] + fn different_context_is_rejected() { + let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); + let sealed = + codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice")); + assert!(matches!( + codec.open_with(&sealed, b"user:bob"), + Err(RequestStateError::IntegrityCheckFailed) + )); + } + + #[test] + fn missing_context_is_rejected() { + let codec = RequestStateCodec::new(b"key-key-key-key-key-key-key-key!!".to_vec()); + let sealed = + codec.seal_with(b"state", &SealOptions::new().associated_data(b"user:alice")); + // Opening without the associated data must fail closed. + assert!(matches!( + codec.open(&sealed), + Err(RequestStateError::IntegrityCheckFailed) + )); + } + } + + mod ttl { + use super::*; + + const KEY: &[u8] = b"ttl-signing-key-ttl-signing-key!!"; + + #[test] + fn within_ttl_opens() { + let codec = RequestStateCodec::new(KEY.to_vec()); + let sealed = codec.seal_at( + b"state", + &SealOptions::new().ttl(Duration::from_secs(60)), + 1_000, + ); + // 30s later, still valid. + assert_eq!(codec.open_at(&sealed, &[], 31_000).unwrap(), b"state"); + } + + #[test] + fn past_ttl_is_expired() { + let codec = RequestStateCodec::new(KEY.to_vec()); + let sealed = codec.seal_at( + b"state", + &SealOptions::new().ttl(Duration::from_secs(60)), + 1_000, + ); + // 61s later, expired. + assert!(matches!( + codec.open_at(&sealed, &[], 62_000), + Err(RequestStateError::Expired) + )); + } + + #[test] + fn no_ttl_never_expires() { + let codec = RequestStateCodec::new(KEY.to_vec()); + let sealed = codec.seal_at(b"state", &SealOptions::new(), 1_000); + assert_eq!(codec.open_at(&sealed, &[], i64::MAX).unwrap(), b"state"); + } + + #[test] + fn ttl_and_associated_data_combine() { + let codec = RequestStateCodec::new(KEY.to_vec()); + let ctx = b"user:alice"; + let sealed = codec.seal_at( + b"state", + &SealOptions::new() + .associated_data(ctx) + .ttl(Duration::from_secs(60)), + 1_000, + ); + assert_eq!(codec.open_at(&sealed, ctx, 10_000).unwrap(), b"state"); + assert!(matches!( + codec.open_at(&sealed, b"user:bob", 10_000), + Err(RequestStateError::IntegrityCheckFailed) + )); + assert!(matches!( + codec.open_at(&sealed, ctx, 99_000), + Err(RequestStateError::Expired) + )); + } + } +} diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index c94563b71..2345a5e45 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -86,6 +86,9 @@ pub enum ServiceError { Cancelled { reason: Option }, #[error("request timeout after {}", chrono::Duration::from_std(*timeout).unwrap_or_default())] Timeout { timeout: Duration }, + /// The peer kept returning `input_required` beyond the configured round cap. + #[error("input_required did not complete within {max_rounds} MRTR rounds")] + InputRequiredRoundsExceeded { max_rounds: usize }, } trait TransferObject: diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 05c2749fe..929512615 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1,24 +1,26 @@ // Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] -use std::borrow::Cow; +use std::{borrow::Cow, sync::Arc, time::Duration}; use thiserror::Error; use super::*; use crate::{ model::{ - ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResult, + ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResponse, CallToolResult, CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, - CompleteResult, CompletionContext, CompletionInfo, ErrorData, GetPromptRequest, - GetPromptRequestParams, GetPromptResult, InitializeRequest, InitializedNotification, - JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, - ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest, - ListToolsResult, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, - ReadResourceRequest, ReadResourceRequestParams, ReadResourceResult, Reference, RequestId, - RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification, - ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SubscribeRequest, - SubscribeRequestParams, UnsubscribeRequest, UnsubscribeRequestParams, + CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, ErrorData, + GetExtensions, GetMeta, GetPromptRequest, GetPromptRequestParams, GetPromptResponse, + GetPromptResult, InitializeRequest, InitializedNotification, InputRequest, + InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, + ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, + ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, + NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, + ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, + Reference, RequestId, RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, + ServerNotification, ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, + SubscribeRequest, SubscribeRequestParams, UnsubscribeRequest, UnsubscribeRequestParams, }, transport::DynamicTransportError, }; @@ -361,6 +363,72 @@ macro_rules! method { } impl Peer { + /// Send one `tools/call` request and return either a final result or an MRTR + /// `InputRequiredResult` without driving any follow-up rounds. + pub async fn call_tool_once( + &self, + params: CallToolRequestParams, + ) -> Result { + let result = self + .send_request(ClientRequest::CallToolRequest(CallToolRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::CallToolResult(result) => Ok(CallToolResponse::Complete(result)), + ServerResult::InputRequiredResult(result) => { + Ok(CallToolResponse::InputRequired(result)) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// Send one `prompts/get` request and return either a final result or an MRTR + /// `InputRequiredResult` without driving any follow-up rounds. + pub async fn get_prompt_once( + &self, + params: GetPromptRequestParams, + ) -> Result { + let result = self + .send_request(ClientRequest::GetPromptRequest(GetPromptRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::GetPromptResult(result) => Ok(GetPromptResponse::Complete(result)), + ServerResult::InputRequiredResult(result) => { + Ok(GetPromptResponse::InputRequired(result)) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// Send one `resources/read` request and return either a final result or an + /// MRTR `InputRequiredResult` without driving any follow-up rounds. + pub async fn read_resource_once( + &self, + params: ReadResourceRequestParams, + ) -> Result { + let result = self + .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await?; + match result { + ServerResult::ReadResourceResult(result) => Ok(ReadResourceResponse::Complete(result)), + ServerResult::InputRequiredResult(result) => { + Ok(ReadResourceResponse::InputRequired(result)) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + method!(peer_req complete CompleteRequest(CompleteRequestParams) => CompleteResult); method!( #[deprecated( @@ -558,3 +626,294 @@ impl Peer { Ok(completion.values) } } + +impl RunningService +where + S: Service, +{ + /// Send one `tools/call` request without driving MRTR follow-up rounds. + pub async fn call_tool_once( + &self, + params: CallToolRequestParams, + ) -> Result { + self.peer.call_tool_once(params).await + } + + /// Send one `prompts/get` request without driving MRTR follow-up rounds. + pub async fn get_prompt_once( + &self, + params: GetPromptRequestParams, + ) -> Result { + self.peer.get_prompt_once(params).await + } + + /// Send one `resources/read` request without driving MRTR follow-up rounds. + pub async fn read_resource_once( + &self, + params: ReadResourceRequestParams, + ) -> Result { + self.peer.read_resource_once(params).await + } + + /// High-level `tools/call` helper that automatically fulfils SEP-2322 + /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does + /// not produce a final [`CallToolResult`] within the default MRTR round cap. + /// Other transport, protocol, and local input-handler errors are propagated. + pub async fn call_tool( + &self, + params: CallToolRequestParams, + ) -> Result { + self.call_tool_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) + .await + } + + /// Same as [`Self::call_tool`], with an explicit MRTR round cap. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` + /// `input_required` responses have been driven without receiving a final + /// [`CallToolResult`]. Other transport, protocol, and local input-handler + /// errors are propagated. + pub async fn call_tool_with_mrtr_max_rounds( + &self, + mut params: CallToolRequestParams, + max_rounds: usize, + ) -> Result { + let mut state_only_rounds = 0usize; + for _round in 0..max_rounds { + match self.peer.call_tool_once(params.clone()).await? { + CallToolResponse::Complete(result) => return Ok(result), + CallToolResponse::InputRequired(result) => { + let (input_responses, request_state) = self + .prepare_input_required_retry(result, &mut state_only_rounds) + .await?; + params.input_responses = input_responses; + params.request_state = request_state; + } + } + } + Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) + } + + /// High-level `prompts/get` helper that automatically fulfils SEP-2322 + /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does + /// not produce a final [`GetPromptResult`] within the default MRTR round cap. + /// Other transport, protocol, and local input-handler errors are propagated. + pub async fn get_prompt( + &self, + params: GetPromptRequestParams, + ) -> Result { + self.get_prompt_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) + .await + } + + /// Same as [`Self::get_prompt`], with an explicit MRTR round cap. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` + /// `input_required` responses have been driven without receiving a final + /// [`GetPromptResult`]. Other transport, protocol, and local input-handler + /// errors are propagated. + pub async fn get_prompt_with_mrtr_max_rounds( + &self, + mut params: GetPromptRequestParams, + max_rounds: usize, + ) -> Result { + let mut state_only_rounds = 0usize; + for _round in 0..max_rounds { + match self.peer.get_prompt_once(params.clone()).await? { + GetPromptResponse::Complete(result) => return Ok(result), + GetPromptResponse::InputRequired(result) => { + let (input_responses, request_state) = self + .prepare_input_required_retry(result, &mut state_only_rounds) + .await?; + params.input_responses = input_responses; + params.request_state = request_state; + } + } + } + Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) + } + + /// High-level `resources/read` helper that automatically fulfils SEP-2322 + /// `input_required` rounds through the local [`ClientHandler`](crate::ClientHandler) service. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] if the peer does + /// not produce a final [`ReadResourceResult`] within the default MRTR round + /// cap. Other transport, protocol, and local input-handler errors are + /// propagated. + pub async fn read_resource( + &self, + params: ReadResourceRequestParams, + ) -> Result { + self.read_resource_with_mrtr_max_rounds(params, DEFAULT_MRTR_MAX_ROUNDS) + .await + } + + /// Same as [`Self::read_resource`], with an explicit MRTR round cap. + /// + /// # Errors + /// + /// Returns [`ServiceError::InputRequiredRoundsExceeded`] once `max_rounds` + /// `input_required` responses have been driven without receiving a final + /// [`ReadResourceResult`]. Other transport, protocol, and local input-handler + /// errors are propagated. + pub async fn read_resource_with_mrtr_max_rounds( + &self, + mut params: ReadResourceRequestParams, + max_rounds: usize, + ) -> Result { + let mut state_only_rounds = 0usize; + for _round in 0..max_rounds { + match self.peer.read_resource_once(params.clone()).await? { + ReadResourceResponse::Complete(result) => return Ok(result), + ReadResourceResponse::InputRequired(result) => { + let (input_responses, request_state) = self + .prepare_input_required_retry(result, &mut state_only_rounds) + .await?; + params.input_responses = input_responses; + params.request_state = request_state; + } + } + } + Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) + } + + async fn prepare_input_required_retry( + &self, + result: InputRequiredResult, + state_only_rounds: &mut usize, + ) -> Result<(Option, Option), ServiceError> { + let had_input_requests = result + .input_requests + .as_ref() + .is_some_and(|requests| !requests.is_empty()); + if !had_input_requests && result.request_state.is_none() { + return Err(ServiceError::UnexpectedResponse); + } + + let responses = self + .fulfill_input_requests(result.input_requests.unwrap_or_default()) + .await?; + if had_input_requests { + *state_only_rounds = 0; + } else { + Self::sleep_state_only_round(*state_only_rounds).await; + *state_only_rounds += 1; + } + + Ok(( + (!responses.is_empty()).then_some(responses), + result.request_state, + )) + } + + async fn fulfill_input_requests( + &self, + requests: crate::model::InputRequests, + ) -> Result { + let responses = futures::future::try_join_all( + requests + .into_iter() + .map(|(key, request)| self.fulfill_input_request(key, request)), + ) + .await?; + Ok(responses.into_iter().collect()) + } + + async fn fulfill_input_request( + &self, + key: String, + request: InputRequest, + ) -> Result<(String, serde_json::Value), ServiceError> { + let response = match request { + InputRequest::CreateMessage(request) => { + let mut request = ServerRequest::CreateMessageRequest(request); + let context = self.input_request_context(&key, &mut request); + match self + .service + .handle_request(request, context) + .await + .map_err(ServiceError::McpError)? + { + ClientResult::CreateMessageResult(result) => { + serde_json::to_value(result).map_err(Self::serde_to_service_error)? + } + _ => return Err(ServiceError::UnexpectedResponse), + } + } + InputRequest::Elicitation(request) => { + let mut request = ServerRequest::ElicitRequest(request); + let context = self.input_request_context(&key, &mut request); + match self + .service + .handle_request(request, context) + .await + .map_err(ServiceError::McpError)? + { + ClientResult::ElicitResult(result) => { + serde_json::to_value(result).map_err(Self::serde_to_service_error)? + } + _ => return Err(ServiceError::UnexpectedResponse), + } + } + InputRequest::ListRoots(request) => { + let mut request = ServerRequest::ListRootsRequest(request); + let context = self.input_request_context(&key, &mut request); + match self + .service + .handle_request(request, context) + .await + .map_err(ServiceError::McpError)? + { + ClientResult::ListRootsResult(result) => { + serde_json::to_value(result).map_err(Self::serde_to_service_error)? + } + _ => return Err(ServiceError::UnexpectedResponse), + } + } + }; + Ok((key, response)) + } + + fn input_request_context(&self, key: &str, request: &mut T) -> RequestContext + where + T: GetMeta + GetExtensions, + { + let mut meta = Default::default(); + let mut extensions = Default::default(); + std::mem::swap(&mut meta, request.get_meta_mut()); + std::mem::swap(&mut extensions, request.extensions_mut()); + RequestContext { + ct: tokio_util::sync::CancellationToken::new(), + id: NumberOrString::String(Arc::from(key)), + peer: self.peer.clone(), + meta, + extensions, + } + } + + async fn sleep_state_only_round(state_only_rounds: usize) { + let millis = (50u64.saturating_mul(1_u64 << state_only_rounds.min(3))).min(250); + tokio::time::sleep(Duration::from_millis(millis)).await; + } + + fn serde_to_service_error(error: serde_json::Error) -> ServiceError { + ServiceError::McpError(ErrorData::internal_error( + format!("failed to serialize MRTR input response: {error}"), + None, + )) + } +} diff --git a/crates/rmcp/tests/test_cancelled_response.rs b/crates/rmcp/tests/test_cancelled_response.rs index 80cd9e8b2..5961a7b5d 100644 --- a/crates/rmcp/tests/test_cancelled_response.rs +++ b/crates/rmcp/tests/test_cancelled_response.rs @@ -7,7 +7,10 @@ use std::{collections::BTreeSet, process::Stdio, time::Duration}; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, - model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerInfo, + }, service::RequestContext, }; use serde_json::{Value, json}; @@ -96,11 +99,9 @@ impl ServerHandler for WaitForCancelServer { &self, _request: CallToolRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { context.ct.cancelled().await; - Ok(CallToolResult::success(vec![ContentBlock::text( - "late response", - )])) + Ok(CallToolResult::success(vec![ContentBlock::text("late response")]).into()) } } diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs new file mode 100644 index 000000000..b9087f706 --- /dev/null +++ b/crates/rmcp/tests/test_mrtr_behavior.rs @@ -0,0 +1,594 @@ +//! Behavior and edge-case coverage for SEP-2322 multi round-trip requests (MRTR). +//! +//! These tests drive a real client/server pair over an in-memory duplex stream +//! and exercise the auto fulfill/retry loop, the manual `*_once` escape hatch, +//! and the server-side version gating. + +// Sampling/Roots are SEP-2577-deprecated but still used to model MRTR input requests. +#![allow(deprecated)] +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use rmcp::{ + ClientHandler, ServerHandler, + model::*, + service::{RequestContext, RoleClient, RoleServer, ServiceError, serve_directly}, +}; +use serde_json::json; + +/// A `requestState` value with characters that must survive a byte-exact echo: +/// dots (the codec delimiter), base64 punctuation, whitespace, and quotes. +const TRICKY_STATE: &str = "st.ate/with+special=chars and spaces \"quotes\"\n\ttab"; + +// ============================================================================= +// Test handlers +// ============================================================================= + +/// A stateless MRTR server whose behavior is selected by the tool/prompt/resource +/// name. Round progression is derived entirely from `request_state` and +/// `input_responses`, as required by the stateless MRTR pattern. +#[derive(Clone, Default)] +struct MrtrServer { + calls: Arc, +} + +fn elicitation_request(message: &str) -> InputRequest { + InputRequest::Elicitation(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: message.into(), + requested_schema: serde_json::from_value(json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + })) + .unwrap(), + }, + )) +} + +fn sampling_request() -> InputRequest { + InputRequest::CreateMessage(CreateMessageRequest::new(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("What is the capital of France?")], + 100, + ))) +} + +fn roots_request() -> InputRequest { + InputRequest::ListRoots(ListRootsRequest::default()) +} + +fn single_elicitation(state: &str) -> InputRequiredResult { + let mut requests = InputRequests::new(); + requests.insert("answer".to_string(), elicitation_request("Name?")); + InputRequiredResult::new(Some(requests), Some(state.into())) +} + +impl MrtrServer { + fn call_tool_impl( + &self, + request: CallToolRequestParams, + ) -> Result { + let responses = request.input_responses.as_ref(); + let state = request.request_state.as_deref(); + match request.name.as_ref() { + // Single round: one elicitation, then complete. + "single" => match responses { + None => Ok(single_elicitation("state-single").into()), + Some(map) => { + if state != Some("state-single") { + return Err(ErrorData::internal_error("request_state not echoed", None)); + } + let answer = map + .get("answer") + .ok_or_else(|| ErrorData::internal_error("missing answer", None))?; + if answer["action"] != "accept" || answer["content"]["name"] != "Ferris" { + return Err(ErrorData::internal_error("unexpected elicit result", None)); + } + Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) + } + }, + // Two elicitation rounds before completing. + "multi_round" => match state { + None => Ok(single_elicitation("round-1").into()), + Some("round-1") => Ok(single_elicitation("round-2").into()), + Some("round-2") => { + Ok(CallToolResult::success(vec![ContentBlock::text("multi-done")]).into()) + } + Some(other) => Err(ErrorData::internal_error( + format!("unexpected round state {other:?}"), + None, + )), + }, + // Several input requests fulfilled concurrently in a single round. + "multi_request" => match responses { + None => { + let mut requests = InputRequests::new(); + requests.insert("form".to_string(), elicitation_request("Name?")); + requests.insert("sample".to_string(), sampling_request()); + requests.insert("roots".to_string(), roots_request()); + Ok(InputRequiredResult::new(Some(requests), Some("multi-req".into())).into()) + } + Some(map) => { + for key in ["form", "sample", "roots"] { + if !map.contains_key(key) { + return Err(ErrorData::internal_error( + format!("missing response for {key}"), + None, + )); + } + } + Ok(CallToolResult::success(vec![ContentBlock::text("multi-req-done")]).into()) + } + }, + // State-only load shedding: two state-only rounds, then complete. + "state_only" => match state { + None => Ok(InputRequiredResult::from_request_state("so-1").into()), + Some("so-1") => Ok(InputRequiredResult::from_request_state("so-2").into()), + Some("so-2") => { + Ok(CallToolResult::success(vec![ContentBlock::text("state-done")]).into()) + } + Some(other) => Err(ErrorData::internal_error( + format!("unexpected state {other:?}"), + None, + )), + }, + // Never completes: used to exercise the max-rounds cap. + "loops" => Ok(single_elicitation("loop").into()), + // Triggers a failure inside the client's elicitation handler. + "handler_error" => { + let mut requests = InputRequests::new(); + requests.insert("answer".to_string(), elicitation_request("FAIL")); + Ok(InputRequiredResult::new(Some(requests), Some("state".into())).into()) + } + // Verifies the client echoes `request_state` byte-for-byte. + "echo_state" => match responses { + None => { + let mut requests = InputRequests::new(); + requests.insert("answer".to_string(), elicitation_request("Name?")); + Ok(InputRequiredResult::new(Some(requests), Some(TRICKY_STATE.into())).into()) + } + Some(_) => { + if state != Some(TRICKY_STATE) { + return Err(ErrorData::internal_error( + "request_state was not echoed byte-exact", + None, + )); + } + Ok(CallToolResult::success(vec![ContentBlock::text("echo-ok")]).into()) + } + }, + _ => Ok(CallToolResult::success(vec![ContentBlock::text("noop")]).into()), + } + } +} + +impl ServerHandler for MrtrServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_prompts() + .build(), + ); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.call_tool_impl(request) + } + + async fn get_prompt( + &self, + request: GetPromptRequestParams, + _context: RequestContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + match request.request_state.as_deref() { + None => Ok(single_elicitation("prompt-1").into()), + Some("prompt-1") => Ok(GetPromptResult::new(vec![PromptMessage::new_text( + Role::Assistant, + "prompt-done", + )]) + .into()), + Some(other) => Err(ErrorData::internal_error( + format!("unexpected prompt state {other:?}"), + None, + )), + } + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + match request.request_state.as_deref() { + None => Ok(single_elicitation("res-1").into()), + Some("res-1") => Ok(ReadResourceResult::new(vec![ResourceContents::text( + "resource-done", + request.uri, + )]) + .into()), + Some(other) => Err(ErrorData::internal_error( + format!("unexpected resource state {other:?}"), + None, + )), + } + } +} + +/// A client that fulfills every kind of MRTR input request. Elicitation fails +/// deliberately when the prompt message is `"FAIL"`. +#[derive(Clone, Default)] +struct MrtrClient; + +impl ClientHandler for MrtrClient { + async fn create_elicitation( + &self, + request: ElicitRequestParams, + _context: RequestContext, + ) -> Result { + if let ElicitRequestParams::FormElicitationParams { message, .. } = &request { + if message == "FAIL" { + return Err(ErrorData::internal_error( + "elicitation handler failed", + None, + )); + } + } + Ok(ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "name": "Ferris" }))) + } + + async fn create_message( + &self, + _request: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text("Paris."), + "test-model".into(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) + } + + async fn list_roots( + &self, + _context: RequestContext, + ) -> Result { + Ok(ListRootsResult::new(vec![Root::new("file:///workspace")])) + } +} + +// ============================================================================= +// Harness +// ============================================================================= + +fn client_info(protocol_version: ProtocolVersion) -> ClientInfo { + ClientInfo::new( + ClientCapabilities::builder().enable_elicitation().build(), + Implementation::new("mrtr-test-client", "0.0.0"), + ) + .with_protocol_version(protocol_version) +} + +fn server_info_2026() -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info +} + +/// Runs `body` inside a `LocalSet` so `spawn_local` (used when the `local` +/// feature is active) is available, wiring up a connected client/server pair. +async fn with_pair( + server: MrtrServer, + client_protocol: ProtocolVersion, + body: F, +) -> anyhow::Result<()> +where + F: FnOnce(rmcp::service::RunningService) -> Fut, + Fut: std::future::Future>, +{ + tokio::task::LocalSet::new() + .run_until(async move { + let (server_transport, client_transport) = tokio::io::duplex(8192); + let server_peer_info = client_info(client_protocol); + let server_task = tokio::task::spawn_local(async move { + let running = serve_directly::( + server, + server_transport, + Some(server_peer_info), + ); + running.waiting().await?; + anyhow::Ok(()) + }); + + let client = serve_directly::( + MrtrClient, + client_transport, + Some(server_info_2026()), + ); + + let result = body(client).await; + + server_task.abort(); + result + }) + .await +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[tokio::test(flavor = "current_thread")] +async fn client_auto_fulfills_input_required_tool_call() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool(CallToolRequestParams::new("single")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "done"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn manual_once_returns_input_required_without_retry() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool_once(CallToolRequestParams::new("single")) + .await?; + assert!(matches!(result, CallToolResponse::InputRequired(_))); + // A manual round makes exactly one server call and never retries. + assert_eq!(calls.load(Ordering::SeqCst), 1); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn multi_round_input_required_completes() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool(CallToolRequestParams::new("multi_round")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "multi-done"); + // round 0 + two retries = 3 server calls. + assert_eq!(calls.load(Ordering::SeqCst), 3); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn multiple_input_requests_fulfilled_in_one_round() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool(CallToolRequestParams::new("multi_request")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "multi-req-done"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn state_only_input_required_completes() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .call_tool(CallToolRequestParams::new("state_only")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "state-done"); + // round 0 + two state-only retries = 3 server calls. + assert_eq!(calls.load(Ordering::SeqCst), 3); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn max_rounds_exceeded_returns_error() -> anyhow::Result<()> { + let server = MrtrServer::default(); + let calls = server.calls.clone(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let err = client + .call_tool_with_mrtr_max_rounds(CallToolRequestParams::new("loops"), 3) + .await + .expect_err("a tool that never completes must exhaust the round cap"); + assert!(matches!( + err, + ServiceError::InputRequiredRoundsExceeded { max_rounds: 3 } + )); + assert_eq!(calls.load(Ordering::SeqCst), 3); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn client_handler_error_propagates() -> anyhow::Result<()> { + let server = MrtrServer::default(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let err = client + .call_tool(CallToolRequestParams::new("handler_error")) + .await + .expect_err("a failing input handler must fail the whole call"); + assert!(matches!(err, ServiceError::McpError(_))); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn request_state_is_echoed_byte_exact() -> anyhow::Result<()> { + let server = MrtrServer::default(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + // The server returns an error result unless it sees TRICKY_STATE echoed + // back unchanged, so a successful completion proves the byte-exact echo. + let result = client + .call_tool(CallToolRequestParams::new("echo_state")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "echo-ok"); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn get_prompt_auto_fulfills_input_required() -> anyhow::Result<()> { + let server = MrtrServer::default(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client.get_prompt(GetPromptRequestParams::new("p")).await?; + assert_eq!( + result.messages[0].content.as_text().unwrap().text, + "prompt-done" + ); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn read_resource_auto_fulfills_input_required() -> anyhow::Result<()> { + let server = MrtrServer::default(); + with_pair(server, ProtocolVersion::V_2026_07_28, |client| async move { + let result = client + .read_resource(ReadResourceRequestParams::new("res://x")) + .await?; + let text = match &result.contents[0] { + ResourceContents::TextResourceContents { text, .. } => text.clone(), + _ => panic!("expected text resource"), + }; + assert_eq!(text, "resource-done"); + Ok(()) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn old_protocol_rejects_input_required() -> anyhow::Result<()> { + let server = MrtrServer::default(); + // The client negotiated 2025-11-25, so the server must refuse to emit an + // InputRequiredResult and return a protocol error instead. + with_pair(server, ProtocolVersion::V_2025_11_25, |client| async move { + let err = client + .call_tool_once(CallToolRequestParams::new("single")) + .await + .expect_err("MRTR must be rejected for pre-2026 peers"); + match err { + ServiceError::McpError(error) => { + assert!( + error.message.contains("2026-07-28"), + "unexpected error message: {}", + error.message + ); + } + other => panic!("expected an McpError, got {other:?}"), + } + Ok(()) + }) + .await +} + +#[cfg(feature = "request-state")] +#[tokio::test(flavor = "current_thread")] +async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Result<()> { + use std::sync::OnceLock; + + use rmcp::model::RequestStateCodec; + + // A shared per-process signing key, mirroring how a real server would derive one. + static KEY: &[u8] = b"integration-signing-key-32-bytes!"; + + fn codec() -> &'static RequestStateCodec { + static CODEC: OnceLock = OnceLock::new(); + CODEC.get_or_init(|| RequestStateCodec::new(KEY)) + } + + #[derive(Clone, Default)] + struct SealingServer; + + impl ServerHandler for SealingServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + match request.request_state { + None => { + let sealed = codec() + .seal_json(&json!({ "step": 1, "tool": request.name })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert("answer".to_string(), elicitation_request("Name?")); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + Some(sealed) => { + // The echoed state is untrusted; verify it before use. + let state: serde_json::Value = codec() + .open_json(&sealed) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + assert_eq!(state["step"], 1); + Ok(CallToolResult::success(vec![ContentBlock::text("sealed-done")]).into()) + } + } + } + } + + tokio::task::LocalSet::new() + .run_until(async move { + let (server_transport, client_transport) = tokio::io::duplex(8192); + let server_task = tokio::task::spawn_local(async move { + let running = serve_directly::( + SealingServer, + server_transport, + Some(client_info(ProtocolVersion::V_2026_07_28)), + ); + running.waiting().await?; + anyhow::Ok(()) + }); + + let client = serve_directly::( + MrtrClient, + client_transport, + Some(server_info_2026()), + ); + + let result = client + .call_tool(CallToolRequestParams::new("sealed")) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "sealed-done"); + + server_task.abort(); + anyhow::Ok(()) + }) + .await +} diff --git a/crates/rmcp/tests/test_resource_not_found_version.rs b/crates/rmcp/tests/test_resource_not_found_version.rs index 255accb8c..44eb3631f 100644 --- a/crates/rmcp/tests/test_resource_not_found_version.rs +++ b/crates/rmcp/tests/test_resource_not_found_version.rs @@ -9,7 +9,7 @@ use rmcp::{ ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt, model::{ ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams, - ReadResourceResult, + ReadResourceResponse, }, service::RequestContext, }; @@ -22,7 +22,7 @@ impl ServerHandler for ResourceServer { &self, _request: ReadResourceRequestParams, _context: RequestContext, - ) -> Result { + ) -> Result { Err(ErrorData::resource_not_found("resource not found", None)) } } diff --git a/crates/rmcp/tests/test_result_type_wire.rs b/crates/rmcp/tests/test_result_type_wire.rs new file mode 100644 index 000000000..9c340ff88 --- /dev/null +++ b/crates/rmcp/tests/test_result_type_wire.rs @@ -0,0 +1,29 @@ +//! Wire-shape regression guards for the SEP-2322 `resultType` discriminator. +//! +//! These pin the behavior that keeps older/strict peers working: +//! - `EmptyResult` stays a bare `{}` (some peers strict-validate empty results +//! and reject extra keys), and +//! - ordinary results carry `resultType: "complete"`. + +use rmcp::model::{CallToolResult, ContentBlock, EmptyResult, ListToolsResult}; +use serde_json::json; + +#[test] +fn empty_result_serializes_without_result_type() { + let value = serde_json::to_value(EmptyResult {}).expect("serialize EmptyResult"); + assert_eq!(value, json!({})); +} + +#[test] +fn call_tool_result_serializes_complete_result_type() { + let value = serde_json::to_value(CallToolResult::success(vec![ContentBlock::text("ok")])) + .expect("serialize CallToolResult"); + assert_eq!(value["resultType"], "complete"); +} + +#[test] +fn paginated_result_serializes_complete_result_type() { + let value = + serde_json::to_value(ListToolsResult::default()).expect("serialize ListToolsResult"); + assert_eq!(value["resultType"], "complete"); +} diff --git a/crates/rmcp/tests/test_stdio_response_concurrency.rs b/crates/rmcp/tests/test_stdio_response_concurrency.rs index 563b0b379..2e1e40a22 100644 --- a/crates/rmcp/tests/test_stdio_response_concurrency.rs +++ b/crates/rmcp/tests/test_stdio_response_concurrency.rs @@ -4,7 +4,10 @@ use std::{collections::BTreeSet, process::Stdio, time::Duration}; use rmcp::{ ErrorData as McpError, ServerHandler, ServiceExt, - model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerInfo, + }, }; use serde_json::{Value, json}; use tokio::{ @@ -87,11 +90,9 @@ impl ServerHandler for LargeResponseServer { &self, request: CallToolRequestParams, _context: rmcp::service::RequestContext, - ) -> Result { + ) -> Result { assert_eq!("large-response", request.name.as_ref()); - Ok(CallToolResult::success(vec![ContentBlock::text( - "x".repeat(RESPONSE_BYTES), - )])) + Ok(CallToolResult::success(vec![ContentBlock::text("x".repeat(RESPONSE_BYTES))]).into()) } } diff --git a/crates/rmcp/tests/test_structured_output.rs b/crates/rmcp/tests/test_structured_output.rs index f82df546d..f3416e519 100644 --- a/crates/rmcp/tests/test_structured_output.rs +++ b/crates/rmcp/tests/test_structured_output.rs @@ -3,7 +3,7 @@ use rmcp::{ Json, ServerHandler, handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters}, - model::{CallToolResult, ContentBlock, ServerResult, Tool}, + model::{CallToolResponse, CallToolResult, ContentBlock, ServerResult, Tool}, tool, tool_handler, tool_router, }; use schemars::JsonSchema; @@ -245,11 +245,13 @@ async fn test_structured_return_conversion() { }; let structured = Json(calc_result); - let result: Result = + let result: Result = rmcp::handler::server::tool::IntoCallToolResult::into_call_tool_result(structured); assert!(result.is_ok()); - let call_result = result.unwrap(); + let CallToolResponse::Complete(call_result) = result.unwrap() else { + panic!("expected complete CallToolResult"); + }; // Tools which return structured content should also return a serialized version as // Content::text for backwards compatibility. @@ -306,11 +308,13 @@ async fn test_output_schema_requires_structured_content() { let result = server.calculate(params).await.unwrap(); // Convert the Json to CallToolResult - let call_result: Result = + let call_result: Result = IntoCallToolResult::into_call_tool_result(result); assert!(call_result.is_ok()); - let call_result = call_result.unwrap(); + let CallToolResponse::Complete(call_result) = call_result.unwrap() else { + panic!("expected complete CallToolResult"); + }; // Verify it has structured_content and content assert!(call_result.structured_content.is_some()); diff --git a/crates/rmcp/tests/test_tool_disable_notification.rs b/crates/rmcp/tests/test_tool_disable_notification.rs index b30a58e18..cd8780591 100644 --- a/crates/rmcp/tests/test_tool_disable_notification.rs +++ b/crates/rmcp/tests/test_tool_disable_notification.rs @@ -9,7 +9,7 @@ use std::sync::{ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRoute, tool::ToolCallContext}, - model::{CallToolResult, ServerCapabilities, ServerInfo, Tool}, + model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool}, service::{MaybeSendFuture, NotificationContext}, }; use tokio::sync::{Notify, RwLock}; @@ -26,11 +26,11 @@ impl TestToolServer { let mut tool_router = rmcp::handler::server::router::tool::ToolRouter::::new(); tool_router.add_route(ToolRoute::new_dyn( Tool::new("tool_a", "Tool A", Arc::new(Default::default())), - |_ctx| Box::pin(async { Ok(CallToolResult::default()) }), + |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), )); tool_router.add_route(ToolRoute::new_dyn( Tool::new("tool_b", "Tool B", Arc::new(Default::default())), - |_ctx| Box::pin(async { Ok(CallToolResult::default()) }), + |_ctx| Box::pin(async { Ok(CallToolResult::default().into()) }), )); Self { router: Arc::new(RwLock::new(tool_router)), @@ -49,7 +49,7 @@ impl ServerHandler for TestToolServer { &self, request: rmcp::model::CallToolRequestParams, context: rmcp::service::RequestContext, - ) -> Result { + ) -> Result { let router = self.router.read().await; let tcc = ToolCallContext::new(self, request, context); router.call(tcc).await diff --git a/examples/servers/Cargo.toml b/examples/servers/Cargo.toml index a544f3ea1..f189c9e7f 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -13,6 +13,7 @@ rmcp = { workspace = true, features = [ "transport-streamable-http-server", "auth", "elicitation", + "request-state", "schemars", ] } tokio = { version = "1", features = [ @@ -113,3 +114,7 @@ path = "src/elicitation_enum_inference.rs" [[example]] name = "servers_task_stdio" path = "src/task_stdio.rs" + +[[example]] +name = "servers_mrtr" +path = "src/mrtr.rs" diff --git a/examples/servers/README.md b/examples/servers/README.md index 69126519e..a6f0dcf76 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -72,6 +72,17 @@ A minimal stdio server demonstrating task-based tool invocation per - Wires up `enqueue_task` / `tasks/get` / `tasks/result` / `tasks/cancel` via `#[task_handler]` - Pair with `examples/clients/src/task_stdio.rs` to see the full lifecycle (create → poll → fetch result) +### MRTR Demo (`mrtr.rs`) + +An end-to-end walkthrough of SEP-2322 Multi Round-Trip Requests, running a +server and client in one process over an in-memory stream. + +- Server answers `tools/call` with an `InputRequiredResult` asking the client to elicit a value +- Client uses `call_tool` to auto-fulfil the elicitation and retry, then `call_tool_once` for manual control +- Seals/opens the untrusted `requestState` with `RequestStateCodec` (HMAC integrity) +- Both sides negotiate `2026-07-28`, the minimum version for MRTR +- Run with `cargo run -p mcp-server-examples --example servers_mrtr` + ### Progress Demo Server (`progress_demo.rs`) A server that demonstrates progress notifications during long-running operations. diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index 09e52f3e6..29258a981 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -260,22 +260,16 @@ impl ServerHandler for Counter { &self, request: ReadResourceRequestParams, _: RequestContext, - ) -> Result { + ) -> Result { let uri = &request.uri; match uri.as_str() { "str:////Users/to/some/path/" => { let cwd = "/Users/to/some/path/"; - Ok(ReadResourceResult::new(vec![ResourceContents::text( - cwd, - uri.clone(), - )])) + Ok(ReadResourceResult::new(vec![ResourceContents::text(cwd, uri.clone())]).into()) } "memo://insights" => { let memo = "Business Intelligence Memo\n\nAnalysis has revealed 5 key insights ..."; - Ok(ReadResourceResult::new(vec![ResourceContents::text( - memo, - uri.clone(), - )])) + Ok(ReadResourceResult::new(vec![ResourceContents::text(memo, uri.clone())]).into()) } _ => Err(McpError::resource_not_found( "resource_not_found", diff --git a/examples/servers/src/mrtr.rs b/examples/servers/src/mrtr.rs new file mode 100644 index 000000000..40b53c3e3 --- /dev/null +++ b/examples/servers/src/mrtr.rs @@ -0,0 +1,199 @@ +//! SEP-2322 Multi Round-Trip Request (MRTR) end-to-end example. +//! +//! This runs a server and a client in the same process, connected over an +//! in-memory duplex stream, to show the full MRTR flow: +//! +//! * The **server** answers `tools/call` with an [`InputRequiredResult`] instead +//! of a final result, asking the client to elicit a value first. It stores its +//! progress in an opaque, integrity-protected `requestState` produced by a +//! [`RequestStateCodec`], and verifies that state when the client retries. +//! * The **client** uses the high-level [`RunningService::call_tool`] helper, +//! which automatically fulfils the elicitation through the local +//! [`ClientHandler`] and retries the original request. The example then repeats +//! the call with [`RunningService::call_tool_once`] to show the manual escape +//! hatch that returns the intermediate result without retrying. +//! +//! ## Version gating +//! +//! `InputRequiredResult` is only valid once the peers have negotiated protocol +//! version `2026-07-28` or newer. Both sides advertise that version below. If a +//! server emits an `InputRequiredResult` to an older client, the SDK turns it +//! into a protocol error instead of sending it on the wire. +//! +//! ## `requestState` is untrusted input +//! +//! The client echoes `requestState` back verbatim, so from the server's point of +//! view it is attacker-controlled. A stateless server that puts meaningful data +//! in `requestState` MUST verify it. This example uses [`RequestStateCodec`] to +//! seal and open it with an HMAC tag; tampered values are rejected. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p mcp-server-examples --example servers_mrtr +//! ``` + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::*, + service::{RequestContext, RoleClient, RoleServer}, +}; +use serde_json::json; + +/// A stable, high-entropy secret. In a real deployment, load this from your +/// secret manager and keep it out of clients' reach. It must stay constant for +/// the lifetime of any in-flight MRTR exchange. +const REQUEST_STATE_KEY: &[u8] = b"example-request-state-signing-key-32b!"; + +/// A server that needs a city name before it can answer a weather query. +#[derive(Clone)] +struct WeatherServer { + codec: RequestStateCodec, +} + +impl Default for WeatherServer { + fn default() -> Self { + Self { + codec: RequestStateCodec::new(REQUEST_STATE_KEY), + } + } +} + +impl ServerHandler for WeatherServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + // MRTR requires 2026-07-28 or newer. + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + match request.request_state { + // First round: ask the client to provide a city, and remember where + // we are by sealing our progress into `requestState`. + None => { + let sealed = self + .codec + .seal_json(&json!({ "awaiting": "city" })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + + let mut input_requests = InputRequests::new(); + input_requests.insert( + "city".to_string(), + InputRequest::Elicitation(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Which city do you want the weather for?".into(), + requested_schema: serde_json::from_value(json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"] + })) + .expect("valid schema"), + }, + )), + ); + + Ok(InputRequiredResult::new(Some(input_requests), Some(sealed)).into()) + } + // Retry round: verify the echoed state before trusting it, read the + // elicited city, and return the final result. + Some(sealed) => { + let _state: serde_json::Value = self.codec.open_json(&sealed).map_err(|_| { + ErrorData::invalid_params("tampered or unknown request state", None) + })?; + + let city = request + .input_responses + .as_ref() + .and_then(|r| r.get("city")) + .and_then(|v| v["content"]["city"].as_str()) + .unwrap_or("your area"); + + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "It is sunny in {city}." + ))]) + .into()) + } + } + } +} + +/// A client that fulfils elicitation requests. A real client would prompt a user. +#[derive(Clone, Default)] +struct InteractiveClient; + +impl ClientHandler for InteractiveClient { + fn get_info(&self) -> ClientInfo { + ClientInfo::new( + ClientCapabilities::builder().enable_elicitation().build(), + Implementation::new("mrtr-example-client", env!("CARGO_PKG_VERSION")), + ) + .with_protocol_version(ProtocolVersion::V_2026_07_28) + } + + async fn create_elicitation( + &self, + request: ElicitRequestParams, + _context: RequestContext, + ) -> Result { + if let ElicitRequestParams::FormElicitationParams { message, .. } = &request { + println!(" [client] server asked: {message}"); + } + // Pretend the user typed "Paris". + Ok(ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "city": "Paris" }))) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(8192); + + // Spin up the server side. + tokio::spawn(async move { + let server = WeatherServer::default() + .serve(server_transport) + .await + .expect("server should start"); + let _ = server.waiting().await; + }); + + // Connect the client (this performs the initialize handshake). + let client = InteractiveClient::default().serve(client_transport).await?; + + // 1. High-level auto mode: the SDK fulfils the elicitation and retries for us. + println!("== auto mode (call_tool) =="); + let result = client + .call_tool(CallToolRequestParams::new("weather")) + .await?; + println!( + " [client] final result: {}\n", + result.content[0].as_text().unwrap().text + ); + + // 2. Manual mode: get the intermediate InputRequiredResult without retrying. + println!("== manual mode (call_tool_once) =="); + match client + .call_tool_once(CallToolRequestParams::new("weather")) + .await? + { + CallToolResponse::InputRequired(input_required) => { + let requests = input_required.input_requests.unwrap_or_default(); + println!( + " [client] server requested {} input(s); handling them yourself is up to you.", + requests.len() + ); + } + CallToolResponse::Complete(result) => { + println!(" [client] completed immediately: {result:?}"); + } + _ => println!(" [client] unhandled response variant"), + } + + client.cancel().await?; + Ok(()) +} diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 2690a28f2..be230add0 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -31,7 +31,7 @@ impl ServerHandler for SamplingDemoServer { &self, request: CallToolRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { match request.name.as_ref() { "ask_llm" => { // Get the question from arguments @@ -79,7 +79,8 @@ impl ServerHandler for SamplingDemoServer { .and_then(|c| c.as_text()) .map(|t| &t.text) .unwrap_or(&"No text response".to_string()) - ))])) + ))]) + .into()) } _ => Err(ErrorData::new( From 2d4c29fb02aea184f167e30bb7f7aa5a5e48610c Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 10 Jul 2026 13:18:55 -0400 Subject: [PATCH 224/333] fix: specify compatible sse-stream version (#968) --- crates/rmcp/Cargo.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 4f4e23073..5ab429abc 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -75,7 +75,7 @@ reqwest = { version = "0.13.2", default-features = false, features = [ "stream", ], optional = true } -sse-stream = { version = "0.2", optional = true } +sse-stream = { version = "0.2.4", optional = true } http = { version = "1", optional = true } url = { version = "2.4", optional = true } @@ -376,10 +376,10 @@ path = "tests/test_unix_socket_transport.rs" [[test]] name = "test_streamable_http_stale_session" required-features = [ - "server", - "client", - "transport-streamable-http-server", - "transport-streamable-http-client", + "server", + "client", + "transport-streamable-http-server", + "transport-streamable-http-client", "transport-streamable-http-client-reqwest" ] path = "tests/test_streamable_http_stale_session.rs" From d8331d944282a3e7fda9c825879c69d1ae0f69f2 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 10 Jul 2026 10:54:18 -0700 Subject: [PATCH 225/333] feat!: implement SEP-2549 cache hints (#889) implements https://github.com/modelcontextprotocol/rust-sdk/issues/875 Co-authored-by: Jack Amadeo --- crates/rmcp-macros/src/prompt_handler.rs | 2 + crates/rmcp-macros/src/tool_handler.rs | 2 + crates/rmcp/src/model.rs | 93 +++++++++++++- crates/rmcp/tests/test_cache_hints.rs | 79 ++++++++++++ .../server_json_rpc_message_schema.json | 117 +++++++++++++++++- ...erver_json_rpc_message_schema_current.json | 117 +++++++++++++++++- 6 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 crates/rmcp/tests/test_cache_hints.rs diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 24032eaab..88d78f70c 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -65,6 +65,8 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result= 0`; if a server returns a negative value, +/// clients SHOULD treat it as `0` (immediately stale). This tolerates that case +/// rather than erroring, while still accepting an absent field as `None`. +fn deserialize_ttl_ms<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.map(|ttl_ms| ttl_ms.max(0) as u64)) +} + macro_rules! paginated_result { ($t:ident { $i_item: ident: $t_item: ty @@ -1258,24 +1286,50 @@ macro_rules! paginated_result { /// Result type discriminator. Absent values deserialize as `"complete"`. #[serde(default)] pub result_type: ResultType, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub next_cursor: Option, + /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). + /// Required by spec version 2026-07-28, but optional here to maintain compatibility + /// with older spec versions. + #[serde( + default, + deserialize_with = "deserialize_ttl_ms", + skip_serializing_if = "Option::is_none" + )] + pub ttl_ms: Option, + /// Scope describing who may cache this result (SEP-2549). + /// Required by spec version 2026-07-28, but optional here to maintain compatibility + /// with older spec versions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, pub $i_item: $t_item, } impl $t { - pub fn with_all_items( - items: $t_item, - ) -> Self { + pub fn with_all_items(items: $t_item) -> Self { Self { result_type: ResultType::default(), meta: None, next_cursor: None, + ttl_ms: None, + cache_scope: None, $i_item: items, } } + + /// Set the time, in milliseconds, that this result may be treated as fresh. + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = Some(ttl_ms); + self + } + + /// Set the cache scope for this result. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + self.cache_scope = Some(cache_scope); + self + } } }; } @@ -1368,12 +1422,27 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams; /// Result containing the contents of a read resource #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct ReadResourceResult { /// Result type discriminator. Absent values deserialize as `"complete"`. #[serde(default)] pub result_type: ResultType, + /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). + /// Required by spec version 2026-07-28, but optional here to maintain compatibility + /// with older spec versions. + #[serde( + default, + deserialize_with = "deserialize_ttl_ms", + skip_serializing_if = "Option::is_none" + )] + pub ttl_ms: Option, + /// Scope describing who may cache this result (SEP-2549). + /// Required by spec version 2026-07-28, but optional here to maintain compatibility + /// with older spec versions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, /// The actual content of the resource pub contents: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] @@ -1385,10 +1454,24 @@ impl ReadResourceResult { pub fn new(contents: Vec) -> Self { Self { result_type: ResultType::default(), + ttl_ms: None, + cache_scope: None, contents, meta: None, } } + + /// Set the time, in milliseconds, that this result may be treated as fresh. + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = Some(ttl_ms); + self + } + + /// Set the cache scope for this result. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + self.cache_scope = Some(cache_scope); + self + } } /// Request to read a specific resource diff --git a/crates/rmcp/tests/test_cache_hints.rs b/crates/rmcp/tests/test_cache_hints.rs new file mode 100644 index 000000000..2b6aecd8c --- /dev/null +++ b/crates/rmcp/tests/test_cache_hints.rs @@ -0,0 +1,79 @@ +use rmcp::model::{CacheScope, ListToolsResult, ReadResourceResult, ResourceContents}; +use serde_json::json; + +#[test] +fn paginated_results_serialize_cache_hints_as_top_level_fields() { + let result = ListToolsResult::with_all_items(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Private); + + let actual = serde_json::to_value(result).expect("serialize list tools result"); + + assert_eq!( + actual, + json!({ + "ttlMs": 5000, + "cacheScope": "private", + "tools": [], + "resultType": "complete" + }) + ); + assert!(actual.get("_meta").is_none()); +} + +#[test] +fn read_resource_results_serialize_cache_hints_as_top_level_fields() { + let result = + ReadResourceResult::new(vec![ResourceContents::text("hello", "file:///example.txt")]) + .with_ttl_ms(10_000) + .with_cache_scope(CacheScope::Public); + + let actual = serde_json::to_value(result).expect("serialize read resource result"); + + assert_eq!(actual["ttlMs"], 10000); + assert_eq!(actual["cacheScope"], "public"); + assert!(actual["contents"][0].get("_meta").is_none()); +} + +#[test] +fn cache_hints_are_omitted_when_absent() { + let result = ListToolsResult::with_all_items(Vec::new()); + let actual = serde_json::to_value(result).expect("serialize list tools result"); + + assert_eq!(actual, json!({ "tools": [], "resultType": "complete" })); +} + +#[test] +fn cache_hints_default_to_none_and_negative_ttl_is_normalized_to_zero() { + let absent: ListToolsResult = serde_json::from_value(json!({ + "tools": [] + })) + .expect("deserialize result without ttlMs"); + assert_eq!(absent.ttl_ms, None); + assert_eq!(absent.cache_scope, None); + + let negative: ReadResourceResult = serde_json::from_value(json!({ + "ttlMs": -42, + "cacheScope": "private", + "contents": [] + })) + .expect("deserialize result with negative ttlMs"); + assert_eq!(negative.ttl_ms, Some(0)); + assert_eq!(negative.cache_scope, Some(CacheScope::Private)); +} + +#[test] +fn cache_scope_round_trips() { + assert_eq!( + serde_json::to_value(CacheScope::Public).unwrap(), + json!("public") + ); + assert_eq!( + serde_json::to_value(CacheScope::Private).unwrap(), + json!("private") + ); + assert_eq!( + serde_json::from_value::(json!("private")).unwrap(), + CacheScope::Private + ); +} diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 29b3624ee..ddd0eaad2 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -150,6 +150,21 @@ "format": "const", "const": "boolean" }, + "CacheScope": { + "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", + "oneOf": [ + { + "description": "Any client or intermediary may cache and serve the response to any user.", + "type": "string", + "const": "public" + }, + { + "description": "Only the requesting user's client may cache the response.", + "type": "string", + "const": "private" + } + ] + }, "CallToolResult": { "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", "type": "object", @@ -1515,6 +1530,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1535,6 +1561,15 @@ } ], "default": "complete" + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1551,6 +1586,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1571,6 +1617,15 @@ } ], "default": "complete" + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1587,6 +1642,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1607,6 +1673,15 @@ } ], "default": "complete" + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1655,6 +1730,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1675,6 +1761,15 @@ "items": { "$ref": "#/definitions/Tool" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -2214,6 +2309,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "contents": { "description": "The actual content of the resource", "type": "array", @@ -2221,7 +2327,7 @@ "$ref": "#/definitions/ResourceContents" } }, - "result_type": { + "resultType": { "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", "allOf": [ { @@ -2229,6 +2335,15 @@ } ], "default": "complete" + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 29b3624ee..ddd0eaad2 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -150,6 +150,21 @@ "format": "const", "const": "boolean" }, + "CacheScope": { + "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", + "oneOf": [ + { + "description": "Any client or intermediary may cache and serve the response to any user.", + "type": "string", + "const": "public" + }, + { + "description": "Only the requesting user's client may cache the response.", + "type": "string", + "const": "private" + } + ] + }, "CallToolResult": { "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", "type": "object", @@ -1515,6 +1530,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1535,6 +1561,15 @@ } ], "default": "complete" + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1551,6 +1586,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1571,6 +1617,15 @@ } ], "default": "complete" + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1587,6 +1642,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1607,6 +1673,15 @@ } ], "default": "complete" + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1655,6 +1730,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1675,6 +1761,15 @@ "items": { "$ref": "#/definitions/Tool" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -2214,6 +2309,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "contents": { "description": "The actual content of the resource", "type": "array", @@ -2221,7 +2327,7 @@ "$ref": "#/definitions/ResourceContents" } }, - "result_type": { + "resultType": { "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", "allOf": [ { @@ -2229,6 +2335,15 @@ } ], "default": "complete" + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ From 3662d20ac27af34e96d6d7285031d54077a6d755 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:21:07 -0400 Subject: [PATCH 226/333] test: enable supported draft SEP coverage (#971) * test: enable supported draft SEP coverage * test: add SEP-2322 MRTR conformance scenarios * refactor: rename run_mrtr_client to run_stateless_client --- .github/workflows/conformance.yml | 64 +++++ conformance/Cargo.toml | 1 + conformance/src/bin/client.rs | 94 ++++++- conformance/src/bin/server.rs | 435 +++++++++++++++++++++++++++++- 4 files changed, 586 insertions(+), 8 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 787dc4b0c..e57fa9729 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,6 +14,7 @@ concurrency: env: # Pinned for reproducible runs; bump deliberately when the suite updates. CONFORMANCE_VERSION: "0.1.16" + DRAFT_CONFORMANCE_VERSION: "0.2.0-alpha.9" jobs: server: @@ -64,6 +65,61 @@ jobs: -o conformance-results done + - name: Start draft conformance server + run: | + STATELESS=1 PORT=8002 ./target/debug/conformance-server & + echo $! > draft-server.pid + for _ in $(seq 1 30); do + if curl -s -o /dev/null http://127.0.0.1:8002/mcp; then + exit 0 + fi + sleep 1 + done + echo "draft conformance server did not become ready" >&2 + exit 1 + + - name: Run draft SEP scenarios + run: | + for scenario in sep-2164-resource-not-found caching http-header-validation; do + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8002/mcp \ + --scenario "$scenario" \ + --spec-version draft \ + -o conformance-results + done + + # SEP-2322 MRTR scenarios (spec 2026-07-28). They speak the stateless + # lifecycle (bare JSON-RPC POSTs, no initialize handshake), so they run + # against the stateless draft server. + - name: Run SEP-2322 MRTR scenarios + run: | + for scenario in \ + input-required-result-basic-elicitation \ + input-required-result-basic-sampling \ + input-required-result-basic-list-roots \ + input-required-result-request-state \ + input-required-result-multiple-input-requests \ + input-required-result-multi-round \ + input-required-result-missing-input-response \ + input-required-result-non-tool-request \ + input-required-result-result-type \ + input-required-result-unsupported-methods \ + input-required-result-tampered-state \ + input-required-result-capability-check \ + input-required-result-ignore-extra-params \ + input-required-result-validate-input \ + ; do + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8002/mcp \ + --scenario "$scenario" \ + -o conformance-results + done + + - name: Stop draft conformance server + if: always() + run: kill "$(cat draft-server.pid)" 2>/dev/null || true + + - name: Stop conformance server if: always() run: kill "$(cat server.pid)" 2>/dev/null || true @@ -98,6 +154,14 @@ jobs: --spec-version 2025-11-25 \ -o conformance-client-results/full + # SEP-2322 MRTR client scenario (spec 2026-07-28). + - name: Run SEP-2322 MRTR client scenario + run: | + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ + --command "$(pwd)/target/debug/conformance-client" \ + --scenario sep-2322-client-request-state \ + -o conformance-client-results/mrtr + - name: Upload results if: always() uses: actions/upload-artifact@v7 diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index de9a44dd0..83b7007cf 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [ "client", "elicitation", "auth", + "request-state", "transport-streamable-http-server", "transport-streamable-http-client-reqwest", ] } diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 9885b998a..850d74f37 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -1,7 +1,7 @@ use rmcp::{ ClientHandler, ErrorData, RoleClient, ServiceExt, model::*, - service::RequestContext, + service::{RequestContext, serve_directly}, transport::{ AuthClient, AuthorizationManager, StreamableHttpClientTransport, auth::{AuthorizationCallback, OAuthState}, @@ -824,6 +824,97 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()> Ok(()) } +/// A minimal stateless client transport: every outgoing message is one HTTP +/// POST and the JSON response body (if any) is queued for `receive()`. +/// +/// The SEP-2322 client scenario's mock server speaks the stateless lifecycle +/// (no `initialize` handshake, plain JSON responses), which the session-based +/// `StreamableHttpClientTransport` cannot do. The transport is harness +/// plumbing; the behavior under test — the SDK's MRTR retry driver — runs +/// unchanged on top of it. +struct StatelessHttpTransport { + http: reqwest::Client, + uri: std::sync::Arc, + tx: tokio::sync::mpsc::Sender, + rx: tokio::sync::mpsc::Receiver, +} + +impl StatelessHttpTransport { + fn new(uri: &str) -> Self { + let (tx, rx) = tokio::sync::mpsc::channel(16); + Self { + http: reqwest::Client::new(), + uri: uri.into(), + tx, + rx, + } + } +} + +impl rmcp::transport::Transport for StatelessHttpTransport { + type Error = std::io::Error; + + fn send( + &mut self, + item: rmcp::model::ClientJsonRpcMessage, + ) -> impl std::future::Future> + Send + 'static { + let http = self.http.clone(); + let uri = self.uri.clone(); + let tx = self.tx.clone(); + async move { + let response = http + .post(uri.as_ref()) + .header("MCP-Protocol-Version", "2026-07-28") + .json(&item) + .send() + .await + .map_err(std::io::Error::other)?; + match response.json::().await { + Ok(message) => { + let _ = tx.send(message).await; + } + Err(_) => { + // No JSON-RPC body (e.g. 202/204 for notifications). + } + } + Ok(()) + } + } + + async fn receive(&mut self) -> Option { + self.rx.recv().await + } + + async fn close(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +/// A stateless-lifecycle client: the scenario's server has no `initialize` +/// handler, so skip the handshake with `serve_directly`, list the tools, and +/// call each one via the high-level `call_tool` helper (which drives SEP-2322 +/// `input_required` retry rounds when the server requests them). Used by the +/// `sep-2322-client-request-state` scenario, whose mock server verifies +/// requestState echo, fresh JSON-RPC ids on retry, state omission, isolation +/// between tools, and the `resultType` default. +async fn run_stateless_client(server_url: &str) -> anyhow::Result<()> { + let transport = StatelessHttpTransport::new(server_url); + let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + .with_protocol_version(ProtocolVersion::V_2026_07_28); + let client = serve_directly(FullClientHandler, transport, Some(peer_info)); + + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + for tool in &tools.tools { + let result = client + .call_tool(CallToolRequestParams::new(tool.name.clone())) + .await; + tracing::debug!("Called {}: {:?}", tool.name, result.is_ok()); + } + client.cancel().await?; + Ok(()) +} + async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> { let transport = StreamableHttpClientTransport::from_uri(server_url); let client = BasicClientHandler.serve(transport).await?; @@ -869,6 +960,7 @@ async fn main() -> anyhow::Result<()> { run_elicitation_defaults_client(&server_url).await? } "sse-retry" => run_sse_retry_client(&server_url).await?, + "sep-2322-client-request-state" => run_stateless_client(&server_url).await?, // Auth scenarios - standard OAuth flow "auth/metadata-default" diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 2c2f63d41..0e779f666 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -18,6 +18,7 @@ use tracing_subscriber::EnvFilter; const TEST_IMAGE_DATA: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; // Small base64-encoded WAV (silence) const TEST_AUDIO_DATA: &str = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA="; +const CACHE_TTL_MS: u64 = 60_000; /// Helper to convert a serde_json::Value (must be an object) into a JsonObject fn json_object(v: Value) -> JsonObject { @@ -27,10 +28,15 @@ fn json_object(v: Value) -> JsonObject { } } +/// Signing key for SEP-2322 `requestState` sealing. A fixed key is fine for a +/// conformance harness; real servers must load a secret out of clients' reach. +const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!"; + #[derive(Clone)] struct ConformanceServer { subscriptions: Arc>>, log_level: Arc>, + request_state_codec: RequestStateCodec, } impl ConformanceServer { @@ -38,6 +44,318 @@ impl ConformanceServer { Self { subscriptions: Arc::new(Mutex::new(HashSet::new())), log_level: Arc::new(Mutex::new(LoggingLevel::Debug)), + request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY), + } + } +} + +// ─── SEP-2322 MRTR (InputRequiredResult) helpers ──────────────────────────── + +fn mrtr_elicitation_request(message: &str, properties: Value, required: Value) -> InputRequest { + InputRequest::Elicitation(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: message.into(), + requested_schema: serde_json::from_value(json!({ + "type": "object", + "properties": properties, + "required": required, + })) + .expect("valid elicitation schema"), + }, + )) +} + +fn mrtr_sampling_request(prompt: &str) -> InputRequest { + InputRequest::CreateMessage(CreateMessageRequest::new(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text(prompt)], + 100, + ))) +} + +fn mrtr_list_roots_request() -> InputRequest { + InputRequest::ListRoots(ListRootsRequest::default()) +} + +/// An input response is usable when it is a JSON object (an `ElicitResult`, +/// `CreateMessageResult`, or `ListRootsResult` shape). Anything else (e.g. a +/// bare number) is treated as missing so the server re-requests it. +fn mrtr_response<'a>( + responses: Option<&'a InputResponses>, + key: &str, +) -> Option<&'a serde_json::Map> { + responses + .and_then(|r| r.get(key)) + .and_then(Value::as_object) +} + +impl ConformanceServer { + fn mrtr_tampered_state_error() -> ErrorData { + ErrorData::invalid_params("requestState failed integrity verification", None) + } + + /// SEP-2322 test tools. Each returns an `InputRequiredResult` until the + /// client retries with the expected `inputResponses` (and, where used, the + /// echoed `requestState`). + /// + /// `meta` is the request's `_meta`, which the service loop moves out of the + /// params and into the `RequestContext`. + async fn call_mrtr_tool( + &self, + request: CallToolRequestParams, + meta: &Meta, + ) -> Result { + let responses = request.input_responses.as_ref(); + match request.name.as_ref() { + "test_input_required_result_elicitation" => { + match mrtr_response(responses, "user_name") { + Some(response) => { + let name = response + .get("content") + .and_then(|c| c.get("name")) + .and_then(Value::as_str) + .unwrap_or("friend"); + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "Hello, {name}!" + ))]) + .into()) + } + // Initial call, or a retry with missing/invalid responses: + // (re-)request the input per the SEP's recommendation. + None => { + let mut requests = InputRequests::new(); + requests.insert( + "user_name".into(), + mrtr_elicitation_request( + "What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + } + } + + "test_input_required_result_sampling" => { + match mrtr_response(responses, "capital_question") { + Some(response) => { + let text = response + .get("content") + .and_then(|c| c.get("text")) + .and_then(Value::as_str) + .unwrap_or("(no sampling text)"); + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "Sampling response: {text}" + ))]) + .into()) + } + None => { + let mut requests = InputRequests::new(); + requests.insert( + "capital_question".into(), + mrtr_sampling_request("What is the capital of France?"), + ); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + } + } + + "test_input_required_result_list_roots" => { + match mrtr_response(responses, "client_roots") { + Some(response) => { + let roots = response + .get("roots") + .and_then(Value::as_array) + .map(|roots| { + roots + .iter() + .filter_map(|r| r.get("uri").and_then(Value::as_str)) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "Client roots: [{roots}]" + ))]) + .into()) + } + None => { + let mut requests = InputRequests::new(); + requests.insert("client_roots".into(), mrtr_list_roots_request()); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + } + } + + "test_input_required_result_request_state" + | "test_input_required_result_tampered_state" => { + match request.request_state.as_deref() { + // Initial call: request confirmation and seal our progress. + None => { + let sealed = self + .request_state_codec + .seal_json(&json!({ "stage": "confirm" })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert( + "confirm".into(), + mrtr_elicitation_request( + "Please confirm", + json!({ "ok": { "type": "boolean" } }), + json!(["ok"]), + ), + ); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + // Retry: the echoed state is untrusted input and MUST pass + // integrity verification before we act on it. + Some(sealed) => { + self.request_state_codec + .open(sealed) + .map_err(|_| Self::mrtr_tampered_state_error())?; + Ok( + CallToolResult::success(vec![ContentBlock::text( + "Confirmed: state-ok", + )]) + .into(), + ) + } + } + } + + "test_input_required_result_multiple_inputs" => { + if let Some(sealed) = request.request_state.as_deref() { + self.request_state_codec + .open(sealed) + .map_err(|_| Self::mrtr_tampered_state_error())?; + } + let all_present = mrtr_response(responses, "user_name").is_some() + && mrtr_response(responses, "greeting").is_some() + && mrtr_response(responses, "client_roots").is_some(); + if all_present && request.request_state.is_some() { + Ok( + CallToolResult::success(vec![ContentBlock::text("All inputs received")]) + .into(), + ) + } else { + let sealed = self + .request_state_codec + .seal_json(&json!({ "stage": "gather" })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert( + "user_name".into(), + mrtr_elicitation_request( + "What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + requests.insert( + "greeting".into(), + mrtr_sampling_request("Generate a greeting"), + ); + requests.insert("client_roots".into(), mrtr_list_roots_request()); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + } + + "test_input_required_result_multi_round" => { + let round = match request.request_state.as_deref() { + None => 0, + Some(sealed) => { + let state: Value = self + .request_state_codec + .open_json(sealed) + .map_err(|_| Self::mrtr_tampered_state_error())?; + state.get("round").and_then(Value::as_i64).unwrap_or(0) + } + }; + match round { + 0 => { + let sealed = self + .request_state_codec + .seal_json(&json!({ "round": 1 })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert( + "step1".into(), + mrtr_elicitation_request( + "Step 1: What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + 1 => { + let sealed = self + .request_state_codec + .seal_json(&json!({ "round": 2 })) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let mut requests = InputRequests::new(); + requests.insert( + "step2".into(), + mrtr_elicitation_request( + "Step 2: What is your favorite color?", + json!({ "color": { "type": "string" } }), + json!(["color"]), + ), + ); + Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into()) + } + _ => Ok(CallToolResult::success(vec![ContentBlock::text( + "Multi-round flow complete", + )]) + .into()), + } + } + + "test_input_required_result_capabilities" => { + if responses.is_some() { + return Ok(CallToolResult::success(vec![ContentBlock::text( + "Capability-aware flow complete", + )]) + .into()); + } + // Per SEP-2322, only request inputs the client declared support + // for in `_meta['io.modelcontextprotocol/clientCapabilities']`. + let capabilities = meta.client_capabilities().unwrap_or_default(); + let mut requests = InputRequests::new(); + if capabilities.elicitation.is_some() { + requests.insert( + "user_name".into(), + mrtr_elicitation_request( + "What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + } + if capabilities.sampling.is_some() { + requests.insert( + "greeting".into(), + mrtr_sampling_request("Generate a greeting"), + ); + } + if capabilities.roots.is_some() { + requests.insert("client_roots".into(), mrtr_list_roots_request()); + } + if requests.is_empty() { + Ok(CallToolResult::success(vec![ContentBlock::text( + "Client declared no MRTR-capable capabilities", + )]) + .into()) + } else { + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + } + + _ => Err(ErrorData::invalid_params( + format!("Unknown tool: {}", request.name), + None, + )), } } } @@ -45,7 +363,7 @@ impl ConformanceServer { impl ServerHandler for ConformanceServer { async fn initialize( &self, - _request: InitializeRequestParams, + request: InitializeRequestParams, _cx: RequestContext, ) -> Result { Ok(InitializeResult::new( @@ -56,6 +374,7 @@ impl ServerHandler for ConformanceServer { .enable_logging() .build(), ) + .with_protocol_version(request.protocol_version) .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) .with_instructions("Rust MCP conformance test server")) } @@ -203,10 +522,57 @@ impl ServerHandler for ConformanceServer { })), ), ]; + // SEP-2322 MRTR test tools; all take no arguments. + let mrtr_tools = [ + ( + "test_input_required_result_elicitation", + "Requires an elicitation input via InputRequiredResult (SEP-2322)", + ), + ( + "test_input_required_result_sampling", + "Requires a sampling input via InputRequiredResult (SEP-2322)", + ), + ( + "test_input_required_result_list_roots", + "Requires a roots/list input via InputRequiredResult (SEP-2322)", + ), + ( + "test_input_required_result_request_state", + "Round-trips integrity-protected requestState (SEP-2322)", + ), + ( + "test_input_required_result_multiple_inputs", + "Requires elicitation + sampling + roots inputs in one round (SEP-2322)", + ), + ( + "test_input_required_result_multi_round", + "Drives multiple input_required rounds with evolving requestState (SEP-2322)", + ), + ( + "test_input_required_result_tampered_state", + "Rejects tampered requestState with a JSON-RPC error (SEP-2322)", + ), + ( + "test_input_required_result_capabilities", + "Only requests inputs for declared client capabilities (SEP-2322)", + ), + ]; + let tools = tools + .into_iter() + .chain(mrtr_tools.into_iter().map(|(name, description)| { + Tool::new( + name, + description, + json_object(json!({ "type": "object", "properties": {} })), + ) + })) + .collect(); Ok(ListToolsResult { tools, ..Default::default() - }) + } + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) } async fn call_tool( @@ -214,6 +580,9 @@ impl ServerHandler for ConformanceServer { request: CallToolRequestParams, cx: RequestContext, ) -> Result { + if request.name.starts_with("test_input_required_result_") { + return self.call_mrtr_tool(request, &cx.meta).await; + } let args = request.arguments.unwrap_or_default(); let result = match request.name.as_ref() { "test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text( @@ -549,7 +918,9 @@ impl ServerHandler for ConformanceServer { .with_mime_type("image/png"), ], ..Default::default() - }) + } + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) } async fn read_resource( @@ -600,7 +971,13 @@ impl ServerHandler for ConformanceServer { } } }; - result.map(Into::into) + result + .map(|result| { + result + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public) + }) + .map(Into::into) } async fn list_resource_templates( @@ -615,7 +992,9 @@ impl ServerHandler for ConformanceServer { .with_mime_type("application/json"), ], ..Default::default() - }) + } + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) } async fn subscribe( @@ -672,9 +1051,18 @@ impl ServerHandler for ConformanceServer { Some("A test prompt that includes an image"), None, ), + Prompt::new( + "test_input_required_result_prompt", + Some( + "A prompt that requires elicitation input via InputRequiredResult (SEP-2322)", + ), + None, + ), ], ..Default::default() - }) + } + .with_ttl_ms(CACHE_TTL_MS) + .with_cache_scope(CacheScope::Public)) } async fn get_prompt( @@ -682,6 +1070,36 @@ impl ServerHandler for ConformanceServer { request: GetPromptRequestParams, _cx: RequestContext, ) -> Result { + // SEP-2322: InputRequiredResult on a non-tool request (prompts/get). + if request.name == "test_input_required_result_prompt" { + return match mrtr_response(request.input_responses.as_ref(), "user_context") { + Some(response) => { + let context = response + .get("content") + .and_then(|c| c.get("context")) + .and_then(Value::as_str) + .unwrap_or("(no context)"); + Ok(GetPromptResult::new(vec![PromptMessage::new_text( + Role::User, + format!("Prompt with elicited context: {context}"), + )]) + .with_description("A prompt built from elicited context") + .into()) + } + None => { + let mut requests = InputRequests::new(); + requests.insert( + "user_context".into(), + mrtr_elicitation_request( + "What context should the prompt use?", + json!({ "context": { "type": "string" } }), + json!(["context"]), + ), + ); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + }; + } let result = match request.name.as_str() { "test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text( Role::User, @@ -782,7 +1200,10 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting conformance server on {}", bind_addr); let server = ConformanceServer::new(); - let config = StreamableHttpServerConfig::default(); + let stateless = std::env::var_os("STATELESS").is_some(); + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(!stateless) + .with_json_response(stateless); let service = StreamableHttpService::new( move || Ok(server.clone()), LocalSessionManager::default().into(), From 93ea09a51b9c4fc745f664d933e9aff15d92f2ed Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:54:55 -0400 Subject: [PATCH 227/333] docs: retarget roadmap to 2026-07-28 spec (#986) --- ROADMAP.md | 63 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4910bec0f..2f0013cf5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,31 +2,52 @@ This roadmap tracks the path to SEP-1730 Tier 1 for the Rust MCP SDK. -Server conformance: 87.5% (28/32) · Client conformance: 80.0% (16/20) +Spec 2025-11-25 (suite 0.1.16): Server 100% (30/30) · Client 100% (18/18) +Spec 2026-07-28 (suite 0.2.0-alpha.9): Server 92.5% (37/40) · Client 75.0% (24/32) --- -## Tier 2 → Tier 1 +## Target spec: 2026-07-28 (release 2026-07-28) -### Conformance +All 2026-07-28 work carries the `2026-07-28` label and the +[`2026-07-28 spec` milestone](https://github.com/modelcontextprotocol/rust-sdk/milestone/3). +Per-scenario conformance status is tracked in the epic issue: +[#977 — Tracking: 2026-07-28 spec conformance](https://github.com/modelcontextprotocol/rust-sdk/issues/977). -#### Server (87.5% → 100%) +### Conformance (baseline 2026-07-13, suite `0.2.0-alpha.9`) -- [ ] Fix `prompts-get-with-args` — prompt argument handling returns incorrect result (arg1/arg2 not substituted) -- [ ] Fix `prompts-get-embedded-resource` — embedded resource content in prompt responses (invalid content union) -- [ ] Fix `elicitation-sep1330-enums` — enum inference handling per SEP-1330 (missing enumNames for legacy titled enum) -- [ ] Fix `dns-rebinding-protection` — validate `Host` / `Origin` headers on Streamable HTTP transport (accepts invalid headers with 200) +- Server: 3 scenarios (`tools-call-with-progress` stateless behavior, SEP-2243 server-side custom headers, and `server-stateless` — the SEP-2575 discovery/negotiation suite at 2/28 checks) +- Client: 8 scenarios (SEP-2243 headers ×3, `request-metadata`, and 4 single-check auth failures: SEP-2350 step-up, pre-registration, SEP-2352 AS migration, SEP-2468 issuer validation); fixes for SEP-2350 (#888) and SEP-2352 (#965) are already in review +- CI: run the full `--spec-version 2026-07-28` suites (stateless server) instead of hand-picked scenario lists; re-baseline on each draft-suite bump -#### Client (80.0% → 100%) +### Spec features without conformance scenarios -- [ ] Fix `auth/metadata-var3` — AS metadata discovery variant 3 (no authorization support detected) -- [ ] Fix `auth/scope-from-www-authenticate` — use scope parameter from WWW-Authenticate header on 403 insufficient_scope -- [ ] Fix `auth/scope-step-up` — handle 403 `insufficient_scope` and re-authorize with upgraded scopes -- [ ] Fix `auth/2025-03-26-oauth-endpoint-fallback` — legacy OAuth endpoint fallback for pre-2025-06-18 servers (no authorization support detected) +Conformance alone does not cover the full spec surface. Feature work tracked via the milestone: + +- SEP-2567 sessionless MCP via explicit state handles (#870) +- SEP-2260 server requests must associate with a client request (#873) +- SEP-2549 follow-up: client-side TTL-honoring cache (#974) + +(SEP-2575 discovery & negotiation is covered by the `server-stateless` conformance scenario; +implementation is in review — #869, PRs #973, #943.) + +### Release + +The 2026-07-28 implementation ships as **v3.0.0** (release PR #964): MRTR, SEP-2549 cache hints, +SEP-2243 standard headers, and the SEP-2106 relaxations are merged but unreleased — tiering and +relegation are evaluated against the latest stable release, so cutting v3.0.0 with the remaining +conformance fixes is on the critical path. Migration guide (draft, kept current until release): +[discussion #969](https://github.com/modelcontextprotocol/rust-sdk/discussions/969). + +--- + +## Tier 1 (non-conformance requirements) ### Governance & Policy - [ ] Create `VERSIONING.md` — document semver scheme, what constitutes a breaking change, and how breaking changes are communicated +- [ ] Publish a dependency update policy (Tier 1 requires a published policy) +- [ ] Cut v3.0.0 (#964) including all conformance fixes (tier relegation is evaluated against the latest stable release) ### Documentation (26/48 → 48/48 features with prose + examples) @@ -59,13 +80,25 @@ Server conformance: 87.5% (28/32) · Client conformance: 80.0% (16/20) --- +## Completed + +- [x] 2025-11-25 server conformance 100% (30 scenarios + pending `json-schema-2020-12`, `server-sse-polling`) +- [x] 2025-11-25 client conformance 100% (18 scenarios + legacy `auth/2025-03-26-*`) +- [x] SEP-2322 MRTR (14 server scenarios + `sep-2322-client-request-state`) +- [x] SEP-2164 resource not found +- [x] Cache hints (`caching`) +- [x] `http-header-validation` +- [x] Issue triage labels (bug, enhancement, needs confirmation, needs repro, ready for work, P0–P3) + +--- + ## Informational (not scored for tiering) -These draft/extension scenarios are tracked but do not count toward tier advancement: +These extension scenarios are tracked but do not count toward tier advancement: | Scenario | Tag | Status | |---|---|---| -| `auth/resource-mismatch` | draft | ❌ Failed | | `auth/client-credentials-jwt` | extension | ❌ Failed — JWT `aud` claim verification error | | `auth/client-credentials-basic` | extension | ✅ Passed | | `auth/cross-app-access-complete-flow` | extension | ❌ Failed — sends `authorization_code` grant instead of `jwt-bearer` | +| `tasks-*` | extension | Not yet attempted | From 98bb2635f4814c0bef671168833820001e5bbf77 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:55:56 -0400 Subject: [PATCH 228/333] chore(deps): update tokio-tungstenite requirement from 0.29.0 to 0.30.0 (#989) Updates the requirements on [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) to permit the latest version. - [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md) - [Commits](https://github.com/snapview/tokio-tungstenite/compare/v0.29.0...v0.30.0) --- updated-dependencies: - dependency-name: tokio-tungstenite dependency-version: 0.30.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/transport/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/transport/Cargo.toml b/examples/transport/Cargo.toml index 716b32261..a3db249b7 100644 --- a/examples/transport/Cargo.toml +++ b/examples/transport/Cargo.toml @@ -40,7 +40,7 @@ rand = { version = "0.10" } schemars = { version = "1.0", optional = true } hyper = { version = "1", features = ["client", "server", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } -tokio-tungstenite = "0.29.0" +tokio-tungstenite = "0.30.0" reqwest = { version = "0.13.2" } pin-project-lite = "0.2" From 2fe90a245b3e533bd07e8a7a02bc618854d817b9 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 14 Jul 2026 13:47:57 -0400 Subject: [PATCH 229/333] chore: update client conformance test scenarios (#991) --- conformance/src/bin/client.rs | 85 ++++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 850d74f37..cd4671f1f 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -13,8 +13,17 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; // ─── Context parsed from MCP_CONFORMANCE_CONTEXT ──────────────────────────── +#[derive(Debug, Default, serde::Deserialize)] +struct ConformanceToolCall { + name: String, + #[serde(default)] + arguments: Option>, +} + #[derive(Debug, Default, serde::Deserialize)] struct ConformanceContext { + #[serde(default)] + tool_calls: Vec, #[serde(default)] client_id: Option, #[serde(default)] @@ -793,16 +802,29 @@ async fn run_basic_client(server_url: &str) -> anyhow::Result<()> { Ok(()) } -async fn run_tools_call_client(server_url: &str) -> anyhow::Result<()> { +async fn run_tools_call_client(server_url: &str, ctx: &ConformanceContext) -> anyhow::Result<()> { let transport = StreamableHttpClientTransport::from_uri(server_url); let client = FullClientHandler.serve(transport).await?; let tools = client.list_tools(Default::default()).await?; - for tool in &tools.tools { - let args = build_tool_arguments(tool); - let _ = client - .call_tool(call_tool_params(tool.name.clone(), args)) - .await?; + + if ctx.tool_calls.is_empty() { + for tool in &tools.tools { + let args = build_tool_arguments(tool); + client + .call_tool(call_tool_params(tool.name.clone(), args)) + .await?; + } + } else { + for tool_call in &ctx.tool_calls { + client + .call_tool(call_tool_params( + tool_call.name.clone().into(), + tool_call.arguments.clone(), + )) + .await?; + } } + client.cancel().await?; Ok(()) } @@ -851,6 +873,13 @@ impl StatelessHttpTransport { } } +fn conformance_protocol_version() -> ProtocolVersion { + std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION") + .ok() + .and_then(|version| serde_json::from_value(Value::String(version)).ok()) + .unwrap_or(ProtocolVersion::V_2026_07_28) +} + impl rmcp::transport::Transport for StatelessHttpTransport { type Error = std::io::Error; @@ -864,7 +893,10 @@ impl rmcp::transport::Transport for StatelessHttpTransport { async move { let response = http .post(uri.as_ref()) - .header("MCP-Protocol-Version", "2026-07-28") + .header( + "MCP-Protocol-Version", + conformance_protocol_version().as_str(), + ) .json(&item) .send() .await @@ -890,17 +922,19 @@ impl rmcp::transport::Transport for StatelessHttpTransport { } } -/// A stateless-lifecycle client: the scenario's server has no `initialize` -/// handler, so skip the handshake with `serve_directly`, list the tools, and -/// call each one via the high-level `call_tool` helper (which drives SEP-2322 -/// `input_required` retry rounds when the server requests them). Used by the -/// `sep-2322-client-request-state` scenario, whose mock server verifies -/// requestState echo, fresh JSON-RPC ids on retry, state omission, isolation -/// between tools, and the `resultType` default. +/// Runs a client using the draft stateless lifecycle. +/// +/// Stateless servers do not implement the `initialize` handshake, so this +/// uses `serve_directly`. The protocol version comes from +/// `MCP_CONFORMANCE_PROTOCOL_VERSION` (defaulting to `2026-07-28`) and is +/// used for both peer configuration and outgoing HTTP request headers. +/// +/// Lists available tools and calls each one, allowing the SDK's high-level +/// tool-call handling to process any request retries. async fn run_stateless_client(server_url: &str) -> anyhow::Result<()> { let transport = StatelessHttpTransport::new(server_url); let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_protocol_version(ProtocolVersion::V_2026_07_28); + .with_protocol_version(conformance_protocol_version()); let client = serve_directly(FullClientHandler, transport, Some(peer_info)); let tools = client.list_tools(Default::default()).await?; @@ -955,12 +989,18 @@ async fn main() -> anyhow::Result<()> { match scenario.as_str() { // Non-auth scenarios "initialize" => run_basic_client(&server_url).await?, - "tools_call" => run_tools_call_client(&server_url).await?, + "json-schema-ref-no-deref" => run_stateless_client(&server_url).await?, + "tools_call" => run_tools_call_client(&server_url, &ctx).await?, "elicitation-sep1034-client-defaults" => { run_elicitation_defaults_client(&server_url).await? } "sse-retry" => run_sse_retry_client(&server_url).await?, - "sep-2322-client-request-state" => run_stateless_client(&server_url).await?, + "request-metadata" | "sep-2322-client-request-state" => { + run_stateless_client(&server_url).await? + } + "http-standard-headers" | "http-custom-headers" | "http-invalid-tool-headers" => { + run_tools_call_client(&server_url, &ctx).await? + } // Auth scenarios - standard OAuth flow "auth/metadata-default" @@ -1008,16 +1048,7 @@ async fn main() -> anyhow::Result<()> { run_cross_app_access_client(&server_url, &ctx).await? } - _ => { - tracing::warn!("Unknown scenario '{}', trying auth flow", scenario); - match run_auth_client(&server_url, &ctx).await { - Ok(_) => {} - Err(e) => { - tracing::debug!("Auth flow failed for unknown scenario: {e}"); - run_basic_client(&server_url).await? - } - } - } + unknown => anyhow::bail!("Unsupported conformance scenario: {unknown}"), } Ok(()) From f3459fa7773f46989f5969774a9f3ee00ec6c0bb Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:32:56 -0400 Subject: [PATCH 230/333] test: serialize JavaScript dependency install (#972) --- crates/rmcp/tests/test_with_js.rs | 38 ++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/crates/rmcp/tests/test_with_js.rs b/crates/rmcp/tests/test_with_js.rs index 0dbd93f3f..8992f8161 100644 --- a/crates/rmcp/tests/test_with_js.rs +++ b/crates/rmcp/tests/test_with_js.rs @@ -17,6 +17,23 @@ use common::calculator::Calculator; const STREAMABLE_HTTP_BIND_ADDRESS: &str = "127.0.0.1:8001"; const STREAMABLE_HTTP_JS_BIND_ADDRESS: &str = "127.0.0.1:8002"; +// These tests run concurrently, while npm mutates their shared node_modules directory. +static JS_DEPENDENCIES_INSTALLED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); + +async fn install_js_dependencies() -> anyhow::Result<()> { + JS_DEPENDENCIES_INSTALLED + .get_or_try_init(|| async { + let status = tokio::process::Command::new("npm") + .arg("install") + .current_dir("tests/test_with_js") + .status() + .await?; + anyhow::ensure!(status.success(), "npm install failed with {status}"); + Ok(()) + }) + .await?; + Ok(()) +} #[tokio::test] async fn test_with_js_stdio_server() -> anyhow::Result<()> { @@ -27,12 +44,7 @@ async fn test_with_js_stdio_server() -> anyhow::Result<()> { ) .with(tracing_subscriber::fmt::layer()) .try_init(); - tokio::process::Command::new("npm") - .arg("install") - .current_dir("tests/test_with_js") - .spawn()? - .wait() - .await?; + install_js_dependencies().await?; let transport = TokioChildProcess::new(tokio::process::Command::new("node").configure(|cmd| { cmd.arg("tests/test_with_js/server.js"); @@ -57,12 +69,7 @@ async fn test_with_js_streamable_http_client() -> anyhow::Result<()> { ) .with(tracing_subscriber::fmt::layer()) .try_init(); - tokio::process::Command::new("npm") - .arg("install") - .current_dir("tests/test_with_js") - .spawn()? - .wait() - .await?; + install_js_dependencies().await?; let ct = CancellationToken::new(); let service: StreamableHttpService = @@ -104,12 +111,7 @@ async fn test_with_js_streamable_http_server() -> anyhow::Result<()> { ) .with(tracing_subscriber::fmt::layer()) .try_init(); - tokio::process::Command::new("npm") - .arg("install") - .current_dir("tests/test_with_js") - .spawn()? - .wait() - .await?; + install_js_dependencies().await?; let transport = StreamableHttpClientTransport::from_uri(format!( "http://{STREAMABLE_HTTP_JS_BIND_ADDRESS}/mcp" From 1543a1afeee1b14f83cc95e0c2be7e3f9b48329f Mon Sep 17 00:00:00 2001 From: King Star Date: Wed, 15 Jul 2026 02:46:42 +0800 Subject: [PATCH 231/333] fix(streamable-http): preserve progress in JSON mode (#990) --- .../transport/streamable_http_server/tower.rs | 61 +++++---- .../test_streamable_http_json_response.rs | 120 +++++++++++++++++- 2 files changed, 157 insertions(+), 24 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index d8fe2d426..362ef34e8 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -19,6 +19,7 @@ use crate::{ ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, Implementation, InitializeRequest, InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, ProtocolVersion, RequestId, + ServerJsonRpcMessage, }, serve_server, service::serve_directly, @@ -48,10 +49,10 @@ pub struct StreamableHttpServerConfig { /// If true, the server will create a session for each request and keep it alive. /// When enabled, SSE priming events are sent to enable client reconnection. pub stateful_mode: bool, - /// When true and `stateful_mode` is false, the server returns - /// `Content-Type: application/json` directly instead of `text/event-stream`. - /// This eliminates SSE framing overhead for simple request-response tools, - /// allowed by the MCP Streamable HTTP spec (2025-06-18). + /// When true and `stateful_mode` is false, the server prefers + /// `Content-Type: application/json` for simple request-response tools. + /// If the handler emits a notification or request before the final response, + /// the server falls back to `text/event-stream` so no message is lost. pub json_response: bool, /// Cancellation token for the Streamable HTTP server. /// @@ -1352,31 +1353,47 @@ where let _ = service.waiting().await; }); if self.config.json_response { - // JSON-direct mode: await the single response and return as - // application/json, eliminating SSE framing overhead. - // Allowed by MCP Streamable HTTP spec (2025-06-18). + // Prefer JSON for a terminal first message. If the handler + // emits an intermediate notification or request, preserve + // the complete message sequence by falling back to SSE. let cancel = self.config.cancellation_token.child_token(); - match tokio::select! { + let Some(message) = (tokio::select! { res = receiver.recv() => res, _ = cancel.cancelled() => None, - } { - Some(message) => { - tracing::trace!(?message); - let body = serde_json::to_vec(&message).map_err(|e| { - internal_error_response("serialize json response")(e) - })?; - Ok(Response::builder() - .status(http::StatusCode::OK) - .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) - .body(Full::new(Bytes::from(body)).boxed()) - .expect("valid response")) - } - None => Err(internal_error_response("empty response")( + }) else { + return Err(internal_error_response("empty response")( std::io::Error::new( std::io::ErrorKind::UnexpectedEof, "no response message received from handler", ), - )), + )); + }; + tracing::trace!(?message); + if matches!( + message, + ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) + ) { + let body = serde_json::to_vec(&message).map_err(|e| { + internal_error_response("serialize json response")(e) + })?; + Ok(Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response")) + } else { + let first = futures::stream::once(async move { + ServerSseMessage::from_message(message) + }); + let remaining = ReceiverStream::new(receiver).map(|message| { + tracing::trace!(?message); + ServerSseMessage::from_message(message) + }); + Ok(sse_stream_response( + first.chain(remaining), + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )) } } else { // SSE mode (default): original behaviour preserved unchanged diff --git a/crates/rmcp/tests/test_streamable_http_json_response.rs b/crates/rmcp/tests/test_streamable_http_json_response.rs index 09dd69ccd..802408538 100644 --- a/crates/rmcp/tests/test_streamable_http_json_response.rs +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -1,6 +1,14 @@ #![cfg(not(feature = "local"))] -use rmcp::transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, +use rmcp::{ + ErrorData, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + ProgressNotificationParam, ServerCapabilities, ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, }; use tokio_util::sync::CancellationToken; @@ -8,6 +16,37 @@ mod common; use common::calculator::Calculator; const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#; +const CALL_WITH_PROGRESS_BODY: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-test-1"}}}"#; + +#[derive(Clone)] +struct ProgressServer; + +impl ServerHandler for ProgressServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let progress_token = context + .meta + .get_progress_token() + .expect("request includes progressToken"); + context + .peer + .notify_progress( + ProgressNotificationParam::new(progress_token, 50.0) + .with_total(100.0) + .with_message("working"), + ) + .await + .expect("progress notification is delivered"); + Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) + } +} async fn spawn_server( config: StreamableHttpServerConfig, @@ -34,6 +73,31 @@ async fn spawn_server( (client, base_url, ct) } +async fn spawn_progress_server( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + let ct = config.cancellation_token.clone(); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(ProgressServer), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let client = reqwest::Client::new(); + let base_url = format!("http://{addr}/mcp"); + (client, base_url, ct) +} + #[tokio::test] async fn stateless_json_response_returns_application_json() -> anyhow::Result<()> { let ct = CancellationToken::new(); @@ -76,6 +140,58 @@ async fn stateless_json_response_returns_application_json() -> anyhow::Result<() Ok(()) } +#[tokio::test] +async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_progress_server( + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(CALL_WITH_PROGRESS_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("text/event-stream"), + "Expected SSE fallback, got: {content_type}" + ); + + let body = response.text().await?; + let messages: Vec = body + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .filter(|data| !data.is_empty()) + .map(serde_json::from_str) + .collect::>()?; + assert_eq!(messages.len(), 2, "Expected progress and result: {body}"); + assert_eq!(messages[0]["method"], "notifications/progress"); + assert_eq!(messages[1]["id"], 2); + assert!( + messages[1]["result"].is_object(), + "Expected result object: {body}" + ); + + ct.cancel(); + Ok(()) +} + #[tokio::test] async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { let ct = CancellationToken::new(); From 24ba5265e2c98ed04c135616b61cab798bf33ed8 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:05:07 -0400 Subject: [PATCH 232/333] fix: bound streamable HTTP memory usage (#970) * fix: bound streamable HTTP memory usage * fix: handle SSE comment lines safely * refactor: narrow SSE limit public API surface --- crates/rmcp/Cargo.toml | 2 +- .../common/auth/streamable_http_client.rs | 54 +++ .../src/transport/common/client_side_sse.rs | 364 +++++++++++++++++- .../common/reqwest/streamable_http_client.rs | 108 +++++- .../src/transport/common/server_side_http.rs | 118 +++++- .../rmcp/src/transport/common/unix_socket.rs | 62 ++- .../src/transport/streamable_http_client.rs | 124 +++++- .../transport/streamable_http_server/tower.rs | 18 +- 8 files changed, 811 insertions(+), 39 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 5ab429abc..a963c156d 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -153,7 +153,7 @@ server-side-http = [ transport-worker = ["dep:tokio-stream"] # SSE stream parsing utilities (used by streamable HTTP client for SSE-formatted responses) -client-side-sse = ["dep:sse-stream", "dep:http", "base64"] +client-side-sse = ["dep:sse-stream", "dep:http", "dep:bytes", "base64"] # Streamable HTTP client transport-streamable-http-client = ["client-side-sse", "transport-worker"] diff --git a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs index 47f08f13e..f0a6211af 100644 --- a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs @@ -47,6 +47,33 @@ where .await } + async fn get_stream_with_max_sse_event_size( + &self, + uri: std::sync::Arc, + session_id: std::sync::Arc, + last_event_id: Option, + mut auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result< + futures::stream::BoxStream<'static, Result>, + crate::transport::streamable_http_client::StreamableHttpError, + > { + if auth_token.is_none() { + auth_token = Some(self.get_access_token().await?); + } + self.http_client + .get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_token, + custom_headers, + max_sse_event_size, + ) + .await + } + async fn post_message( &self, uri: std::sync::Arc, @@ -65,4 +92,31 @@ where .post_message(uri, message, session_id, auth_token, custom_headers) .await } + + async fn post_message_with_max_sse_event_size( + &self, + uri: std::sync::Arc, + message: crate::model::ClientJsonRpcMessage, + session_id: Option>, + mut auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result< + crate::transport::streamable_http_client::StreamableHttpPostResponse, + StreamableHttpError, + > { + if auth_token.is_none() { + auth_token = Some(self.get_access_token().await?); + } + self.http_client + .post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_token, + custom_headers, + max_sse_event_size, + ) + .await + } } diff --git a/crates/rmcp/src/transport/common/client_side_sse.rs b/crates/rmcp/src/transport/common/client_side_sse.rs index fc9e15eb7..ba21657f7 100644 --- a/crates/rmcp/src/transport/common/client_side_sse.rs +++ b/crates/rmcp/src/transport/common/client_side_sse.rs @@ -5,13 +5,166 @@ use std::{ time::Duration, }; -use futures::{Stream, stream::BoxStream}; -use sse_stream::{Error as SseError, Sse}; +use bytes::Bytes; +use futures::{Stream, StreamExt, stream::BoxStream}; +use sse_stream::{Error as SseError, Sse, SseStream}; +use thiserror::Error; use crate::model::ServerJsonRpcMessage; pub type BoxedSseResponse = BoxStream<'static, Result>; +/// Maximum raw size of one SSE event accepted from a remote server. +pub(crate) const DEFAULT_MAX_SSE_EVENT_SIZE: usize = 16 * 1024 * 1024; + +#[derive(Debug, Error)] +enum BoundedSseStreamError { + #[error(transparent)] + Source(Box), + #[error("SSE event exceeded the maximum size of {max_size} bytes")] + EventTooLarge { max_size: usize }, +} + +#[derive(Debug)] +struct SseEventSizeLimiter { + max_size: usize, + retained_size: usize, + line_size: usize, + line_is_comment: bool, + previous_was_cr: bool, +} + +impl SseEventSizeLimiter { + fn new(max_size: usize) -> Self { + Self { + max_size, + retained_size: 0, + line_size: 0, + line_is_comment: false, + previous_was_cr: false, + } + } + + fn observe(&mut self, chunk: &[u8]) -> Result<(), ()> { + for &byte in chunk { + if self.previous_was_cr { + self.previous_was_cr = false; + if byte == b'\n' { + continue; + } + } + + match byte { + b'\r' => { + self.finish_line()?; + self.previous_was_cr = true; + } + b'\n' => self.finish_line()?, + _ => { + if self.line_size == 0 { + self.line_is_comment = byte == b':'; + } + self.line_size = self.line_size.saturating_add(1); + self.check_limit()?; + } + } + } + Ok(()) + } + + fn finish_line(&mut self) -> Result<(), ()> { + if self.line_size == 0 { + self.retained_size = 0; + } else if !self.line_is_comment { + // The SSE parser inserts a newline when joining multiple data fields. + self.retained_size = self + .retained_size + .saturating_add(self.line_size) + .saturating_add(1); + } + self.line_size = 0; + self.line_is_comment = false; + self.check_limit() + } + + fn check_limit(&self) -> Result<(), ()> { + if self.retained_size.saturating_add(self.line_size) > self.max_size { + Err(()) + } else { + Ok(()) + } + } +} + +pin_project_lite::pin_project! { + struct BoundedSseByteStream { + #[pin] + inner: S, + limiter: SseEventSizeLimiter, + failed: bool, + } +} + +impl Stream for BoundedSseByteStream +where + S: Stream>, + E: std::error::Error + Send + Sync + 'static, +{ + type Item = Result; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + let mut this = self.project(); + if *this.failed { + return Poll::Ready(None); + } + + match ready!(this.inner.as_mut().poll_next(cx)) { + Some(Ok(chunk)) => { + if this.limiter.observe(&chunk).is_err() { + *this.failed = true; + Poll::Ready(Some(Err(BoundedSseStreamError::EventTooLarge { + max_size: this.limiter.max_size, + }))) + } else { + Poll::Ready(Some(Ok(chunk))) + } + } + Some(Err(error)) => { + *this.failed = true; + Poll::Ready(Some(Err(BoundedSseStreamError::Source(Box::new(error))))) + } + None => Poll::Ready(None), + } + } +} + +pub(crate) fn bounded_sse_stream(stream: S, max_event_size: usize) -> BoxedSseResponse +where + S: Stream> + Send + 'static, + E: std::error::Error + Send + Sync + 'static, +{ + let stream = BoundedSseByteStream { + inner: stream, + limiter: SseEventSizeLimiter::new(max_event_size), + failed: false, + }; + SseStream::from_bytes_stream(stream).boxed() +} + +fn is_event_too_large_error(error: &SseError) -> bool { + matches!( + error, + SseError::Body(error) + if matches!( + error.downcast_ref::(), + Some(BoundedSseStreamError::EventTooLarge { .. }) + ) + ) +} + pub trait SseRetryPolicy: std::fmt::Debug + Send + Sync { fn retry(&self, current_times: usize) -> Option; } @@ -124,6 +277,10 @@ pub(crate) trait SseStreamReconnect { tracing::warn!("sse stream error: {error}"); } } + fn map_fatal_stream_error(&mut self, error: SseError) -> Option { + tracing::warn!("fatal sse stream error: {error}"); + None + } } pin_project_lite::pin_project! { @@ -248,6 +405,10 @@ where } } Some(Err(e)) => { + if is_event_too_large_error(&e) { + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); + } this.connector .handle_stream_error(&e, this.last_event_id.as_deref()); let retrying = this @@ -327,3 +488,202 @@ where self.poll_next(cx) } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[derive(Debug, Error)] + enum TestReconnectError { + #[error("SSE stream error: {0}")] + Sse(SseError), + #[error("unexpected reconnect")] + Reconnect, + } + + struct CountingReconnect { + attempts: Arc, + } + + impl SseStreamReconnect for CountingReconnect { + type Error = TestReconnectError; + type Future = futures::future::Ready>; + + fn retry_connection(&mut self, _last_event_id: Option<&str>) -> Self::Future { + self.attempts.fetch_add(1, Ordering::Relaxed); + futures::future::ready(Err(TestReconnectError::Reconnect)) + } + + fn map_fatal_stream_error(&mut self, error: SseError) -> Option { + Some(TestReconnectError::Sse(error)) + } + } + + #[tokio::test] + async fn bounded_sse_stream_rejects_unterminated_event_over_limit() { + let source = futures::stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"data: aaaaa")), + Ok(Bytes::from_static(b"aaaaaa")), + ]); + let mut stream = bounded_sse_stream(source, 16); + + let error = stream.next().await.unwrap().unwrap_err(); + + assert!( + error.to_string().contains("maximum size of 16 bytes"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn bounded_sse_stream_resets_limit_after_event_terminator() { + let source = futures::stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"data: a\r")), + Ok(Bytes::from_static(b"\n\r\ndata: b\n\n")), + ]); + let mut stream = bounded_sse_stream(source, 8); + + let first = stream.next().await.unwrap().unwrap(); + let second = stream.next().await.unwrap().unwrap(); + + assert_eq!( + (first.data.as_deref(), second.data.as_deref()), + (Some("a"), Some("b")) + ); + } + + #[tokio::test] + async fn bounded_sse_stream_passes_event_at_exact_limit() { + let source = + futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"data: ab\n\n"))]); + let mut stream = bounded_sse_stream(source, 9); + + let event = stream.next().await.unwrap().unwrap(); + assert_eq!(event.data.as_deref(), Some("ab")); + } + + #[tokio::test] + async fn bounded_sse_stream_rejects_oversize_split_across_many_chunks() { + let source = futures::stream::iter( + std::iter::repeat_with(|| Ok::<_, std::io::Error>(Bytes::from_static(b"data: x"))) + .take(20), + ); + let mut stream = bounded_sse_stream(source, 32); + + let mut found_error = false; + while let Some(item) = stream.next().await { + if item.is_err() { + found_error = true; + break; + } + } + assert!(found_error, "expected oversize error"); + } + + #[tokio::test] + async fn bounded_sse_stream_handles_crlf_split_across_chunks() { + let source = futures::stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"data: hello\r")), + Ok(Bytes::from_static(b"\n\ndata: world\n\n")), + ]); + let mut stream = bounded_sse_stream(source, 64); + + let first = stream.next().await.unwrap().unwrap(); + let second = stream.next().await.unwrap().unwrap(); + + assert_eq!(first.data.as_deref(), Some("hello")); + assert_eq!(second.data.as_deref(), Some("world")); + } + + #[tokio::test] + async fn bounded_sse_stream_discards_completed_comment_lines_from_limit() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( + b": ping\n: ping\n: ping\n", + ))]); + let mut stream = bounded_sse_stream(source, 6); + + assert!(stream.next().await.is_none()); + } + + #[tokio::test] + async fn bounded_sse_stream_comments_do_not_reset_accumulated_data() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( + b"data: a\n: ping\ndata: b\n", + ))]); + let mut stream = bounded_sse_stream(source, 14); + + assert!(stream.next().await.unwrap().is_err()); + } + + #[tokio::test] + async fn bounded_sse_stream_counts_multiline_data_join_newlines() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( + b"data: aaa\ndata: bbb\n\n", + ))]); + let mut stream = bounded_sse_stream(source, 18); + + let error = stream.next().await.unwrap().unwrap_err(); + assert!( + error.to_string().contains("maximum size"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn bounded_sse_stream_propagates_source_error() { + let source = futures::stream::iter([Err::(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "connection reset", + ))]); + let mut stream = bounded_sse_stream(source, 1024); + + let error = stream.next().await.unwrap().unwrap_err(); + assert!( + error.to_string().contains("connection reset"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn is_event_too_large_error_detects_oversize() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from(vec![b'A'; 100]))]); + let mut stream = bounded_sse_stream(source, 8); + + let error = stream.next().await.unwrap().unwrap_err(); + assert!(is_event_too_large_error(&error)); + } + + #[tokio::test] + async fn is_event_too_large_error_rejects_other_errors() { + let source = + futures::stream::iter([Err::(std::io::Error::other("something else"))]); + let mut stream = bounded_sse_stream(source, 1024); + + let error = stream.next().await.unwrap().unwrap_err(); + assert!(!is_event_too_large_error(&error)); + } + + #[tokio::test] + async fn oversized_event_returns_error_without_reconnecting() { + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from(vec![b'A'; 100]))]); + let attempts = Arc::new(AtomicUsize::new(0)); + let connector = CountingReconnect { + attempts: attempts.clone(), + }; + let stream = SseAutoReconnectStream::new( + bounded_sse_stream(source, 8), + connector, + Arc::new(NeverRetry), + ); + let mut stream = std::pin::pin!(stream); + + let result = stream.next().await; + + assert!( + matches!(result, Some(Err(TestReconnectError::Sse(_)))) + && attempts.load(Ordering::Relaxed) == 0 + ); + } +} diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 57c12f1ca..7032e1a87 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -1,16 +1,19 @@ use std::{borrow::Cow, collections::HashMap, sync::Arc}; -use futures::{StreamExt, stream::BoxStream}; +use futures::stream::BoxStream; use http::{HeaderName, HeaderValue, header::WWW_AUTHENTICATE}; use reqwest::header::ACCEPT; -use sse_stream::{Sse, SseStream}; +use sse_stream::Sse; use crate::{ model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage}, transport::{ - common::http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, - extract_scope_from_header, validate_custom_header, + common::{ + client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, + http_header::{ + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + extract_scope_from_header, validate_custom_header, + }, }, streamable_http_client::*, }, @@ -53,6 +56,26 @@ impl StreamableHttpClient for reqwest::Client { last_event_id: Option, auth_token: Option, custom_headers: HashMap, + ) -> Result>, StreamableHttpError> { + self.get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_token, + custom_headers, + DEFAULT_MAX_SSE_EVENT_SIZE, + ) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Arc, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, ) -> Result>, StreamableHttpError> { let mut request_builder = self .get(uri.as_ref()) @@ -84,7 +107,7 @@ impl StreamableHttpClient for reqwest::Client { return Err(StreamableHttpError::UnexpectedContentType(None)); } } - let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); + let event_stream = bounded_sse_stream(response.bytes_stream(), max_sse_event_size); Ok(event_stream) } @@ -119,6 +142,26 @@ impl StreamableHttpClient for reqwest::Client { session_id: Option>, auth_token: Option, custom_headers: HashMap, + ) -> Result> { + self.post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_token, + custom_headers, + DEFAULT_MAX_SSE_EVENT_SIZE, + ) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, ) -> Result> { let mut request = self .post(uri.as_ref()) @@ -223,7 +266,7 @@ impl StreamableHttpClient for reqwest::Client { } match content_type.as_deref() { Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { - let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); + let event_stream = bounded_sse_stream(response.bytes_stream(), max_sse_event_size); Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) } Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { @@ -364,6 +407,57 @@ mod tests { assert!(parse_json_rpc_error(body).is_none()); } + #[tokio::test] + async fn post_sse_response_honors_configured_event_limit() -> anyhow::Result<()> { + use std::{collections::HashMap, net::SocketAddr, sync::Arc}; + + use axum::{Router, routing::post}; + use futures::StreamExt; + + use crate::transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpPostResponse, + }; + + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await?; + let addr = listener.local_addr()?; + let server = tokio::spawn(async move { + let app = Router::new().route( + "/mcp", + post(|| async { + ( + [(http::header::CONTENT_TYPE, "text/event-stream")], + "data: this event is too large\n", + ) + }), + ); + axum::serve(listener, app).await + }); + let message = ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ); + let client = reqwest::Client::new(); + + let response = client + .post_message_with_max_sse_event_size( + Arc::::from(format!("http://{addr}/mcp")), + message, + None, + None, + HashMap::new(), + 16, + ) + .await?; + let StreamableHttpPostResponse::Sse(mut stream, _) = response else { + anyhow::bail!("expected SSE response"); + }; + let error = stream.next().await.unwrap().unwrap_err(); + + server.abort(); + assert!(error.to_string().contains("maximum size of 16 bytes")); + Ok(()) + } + #[tokio::test] async fn default_http_client_does_not_leak_custom_headers_to_redirect_target() -> anyhow::Result<()> { diff --git a/crates/rmcp/src/transport/common/server_side_http.rs b/crates/rmcp/src/transport/common/server_side_http.rs index 39a321f9b..32609bb75 100644 --- a/crates/rmcp/src/transport/common/server_side_http.rs +++ b/crates/rmcp/src/transport/common/server_side_http.rs @@ -170,31 +170,61 @@ pub(crate) fn unexpected_message_response(expect: &str) -> Response( body: B, + max_bytes: usize, ) -> Result>> where B: Body + Send + 'static, B::Error: Display, { - match body.collect().await { - Ok(bytes) => { - match serde_json::from_reader::<_, ClientJsonRpcMessage>(bytes.aggregate().reader()) { - Ok(message) => Ok(message), - Err(e) => { + let mut collected = bytes::BytesMut::new(); + let mut body = std::pin::pin!(body); + loop { + let frame = futures::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await; + match frame { + None => break, + Some(Ok(frame)) => { + let Ok(mut data) = frame.into_data() else { + continue; + }; + if data.remaining() > max_bytes.saturating_sub(collected.len()) { let response = Response::builder() - .status(http::StatusCode::UNSUPPORTED_MEDIA_TYPE) + .status(http::StatusCode::PAYLOAD_TOO_LARGE) .body( - Full::new(Bytes::from(format!("fail to deserialize request body {e}"))) - .boxed(), + Full::new(Bytes::from(format!( + "Payload Too Large: request body exceeds {max_bytes} bytes" + ))) + .boxed(), ) .expect("valid response"); - Err(response) + return Err(response); } + while data.has_remaining() { + let chunk = data.chunk(); + let chunk_len = chunk.len(); + collected.extend_from_slice(chunk); + data.advance(chunk_len); + } + } + Some(Err(e)) => { + let response = Response::builder() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body( + Full::new(Bytes::from(format!("Failed to read request body: {e}"))).boxed(), + ) + .expect("valid response"); + return Err(response); } } + } + + match serde_json::from_slice::(&collected) { + Ok(message) => Ok(message), Err(e) => { let response = Response::builder() - .status(http::StatusCode::INTERNAL_SERVER_ERROR) - .body(Full::new(Bytes::from(format!("Failed to read request body: {e}"))).boxed()) + .status(http::StatusCode::UNSUPPORTED_MEDIA_TYPE) + .body( + Full::new(Bytes::from(format!("fail to deserialize request body {e}"))).boxed(), + ) .expect("valid response"); Err(response) } @@ -203,9 +233,17 @@ where #[cfg(test)] mod tests { + use std::convert::Infallible; + + use futures::stream; + use http_body::Frame; + use http_body_util::StreamBody; + use super::*; use crate::model::{EmptyResult, JsonRpcResponse, JsonRpcVersion2_0, RequestId, ServerResult}; + const INITIALIZE_REQUEST: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}"#; + fn dummy_message() -> ServerJsonRpcMessage { ServerJsonRpcMessage::Response(JsonRpcResponse { jsonrpc: JsonRpcVersion2_0, @@ -245,4 +283,62 @@ mod tests { assert!(msg.message.is_none()); assert_eq!(msg.retry, Some(Duration::from_secs(5))); } + + #[tokio::test] + async fn expect_json_accepts_body_under_limit() { + let body = Full::new(Bytes::from_static(INITIALIZE_REQUEST.as_bytes())); + let result = expect_json(body, 4 * 1024 * 1024).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn expect_json_accepts_non_contiguous_body_data() { + let split = INITIALIZE_REQUEST.len() / 2; + let data = Bytes::copy_from_slice(&INITIALIZE_REQUEST.as_bytes()[..split]).chain( + Bytes::copy_from_slice(&INITIALIZE_REQUEST.as_bytes()[split..]), + ); + let result = expect_json(Full::new(data), 4 * 1024 * 1024).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn expect_json_accepts_multi_frame_body() { + let split = INITIALIZE_REQUEST.len() / 2; + let body = StreamBody::new(stream::iter([ + Ok::<_, Infallible>(Frame::data(Bytes::copy_from_slice( + &INITIALIZE_REQUEST.as_bytes()[..split], + ))), + Ok(Frame::data(Bytes::copy_from_slice( + &INITIALIZE_REQUEST.as_bytes()[split..], + ))), + ])); + let result = expect_json(body, 4 * 1024 * 1024).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn expect_json_rejects_oversized_body() { + let big_body = Full::new(Bytes::from(vec![b'x'; 128])); + let result = expect_json(big_body, 64).await; + let response = result.unwrap_err(); + assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn expect_json_rejects_oversized_multi_frame_body() { + let body = StreamBody::new(stream::iter([ + Ok::<_, Infallible>(Frame::data(Bytes::from_static(b"12345678"))), + Ok(Frame::data(Bytes::from_static(b"9"))), + ])); + let response = expect_json(body, 8).await.unwrap_err(); + assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn expect_json_returns_415_for_invalid_json_under_limit() { + let body = Full::new(Bytes::from("not valid json")); + let result = expect_json(body, 4 * 1024 * 1024).await; + let response = result.unwrap_err(); + assert_eq!(response.status(), http::StatusCode::UNSUPPORTED_MEDIA_TYPE); + } } diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs index 8ea30f57f..899548313 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -1,20 +1,23 @@ use std::{borrow::Cow, collections::HashMap, sync::Arc}; use bytes::Bytes; -use futures::{StreamExt, stream::BoxStream}; +use futures::stream::BoxStream; use http::{HeaderName, HeaderValue, Method, Request, StatusCode, header::WWW_AUTHENTICATE}; use http_body_util::{BodyExt, Full}; use hyper::body::Incoming; use hyper_util::rt::TokioIo; -use sse_stream::{Sse, SseStream}; +use sse_stream::Sse; use tokio::net::UnixStream; use crate::{ model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, transport::{ - common::http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, - extract_scope_from_header, validate_custom_header, + common::{ + client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, + http_header::{ + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + extract_scope_from_header, validate_custom_header, + }, }, streamable_http_client::*, }, @@ -168,6 +171,26 @@ impl StreamableHttpClient for UnixSocketHttpClient { session_id: Option>, auth_token: Option, custom_headers: HashMap, + ) -> Result> { + self.post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_token, + custom_headers, + DEFAULT_MAX_SSE_EVENT_SIZE, + ) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, ) -> Result> { let json_body = serde_json::to_string(&message) .map_err(|e| StreamableHttpError::Client(UnixSocketError::Json(e)))?; @@ -282,7 +305,8 @@ impl StreamableHttpClient for UnixSocketHttpClient { match content_type { Some(ref ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { - let sse_stream = SseStream::new(response.into_body()).boxed(); + let sse_stream = + bounded_sse_stream(response.into_body().into_data_stream(), max_sse_event_size); Ok(StreamableHttpPostResponse::Sse(sse_stream, session_id)) } Some(ref ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { @@ -357,6 +381,27 @@ impl StreamableHttpClient for UnixSocketHttpClient { auth_token: Option, custom_headers: HashMap, ) -> Result>, StreamableHttpError> + { + self.get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_token, + custom_headers, + DEFAULT_MAX_SSE_EVENT_SIZE, + ) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Arc, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result>, StreamableHttpError> { let mut builder = Request::builder() .method(Method::GET) @@ -444,7 +489,10 @@ impl StreamableHttpClient for UnixSocketHttpClient { } } - Ok(SseStream::new(response.into_body()).boxed()) + Ok(bounded_sse_stream( + response.into_body().into_data_stream(), + max_sse_event_size, + )) } } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 2379baf95..8fe6d7621 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -13,7 +13,9 @@ use thiserror::Error; use tokio_util::sync::CancellationToken; use tracing::debug; -use super::common::client_side_sse::{ExponentialBackoff, SseRetryPolicy, SseStreamReconnect}; +use super::common::client_side_sse::{ + DEFAULT_MAX_SSE_EVENT_SIZE, ExponentialBackoff, SseRetryPolicy, SseStreamReconnect, +}; use crate::{ RoleClient, model::{ @@ -266,6 +268,12 @@ impl StreamableHttpPostResponse { } } +/// HTTP backend used by [`StreamableHttpClientTransport`]. +/// +/// Custom implementations that parse SSE responses must override +/// [`Self::post_message_with_max_sse_event_size`] and +/// [`Self::get_stream_with_max_sse_event_size`] to enforce the transport's +/// configured event-size limit. pub trait StreamableHttpClient: Clone + Send + 'static { type Error: std::error::Error + Send + Sync + 'static; fn post_message( @@ -278,6 +286,29 @@ pub trait StreamableHttpClient: Clone + Send + 'static { ) -> impl Future>> + Send + '_; + /// Send a message while enforcing a maximum raw SSE event size. + /// + /// `max_sse_event_size` is not a per-request option: it is the + /// transport-wide [`StreamableHttpClientTransportConfig::max_sse_event_size`] + /// value, passed identically on every call because the limit must be applied + /// inside the client (at the raw byte layer, before SSE parsing) rather than + /// by the caller. + /// + /// Custom clients that parse SSE responses should override this method. + /// The default implementation delegates to [`Self::post_message`]. + fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + _max_sse_event_size: usize, + ) -> impl Future>> + + Send + + '_ { + self.post_message(uri, message, session_id, auth_header, custom_headers) + } fn delete_session( &self, uri: Arc, @@ -299,6 +330,33 @@ pub trait StreamableHttpClient: Clone + Send + 'static { >, > + Send + '_; + /// Open an SSE stream while enforcing a maximum raw event size. + /// + /// `max_sse_event_size` is not a per-request option: it is the + /// transport-wide [`StreamableHttpClientTransportConfig::max_sse_event_size`] + /// value, passed identically on every call because the limit must be applied + /// inside the client (at the raw byte layer, before SSE parsing) rather than + /// by the caller. + /// + /// Custom clients that parse SSE responses should override this method. + /// The default implementation delegates to [`Self::get_stream`]. + fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Arc, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + _max_sse_event_size: usize, + ) -> impl Future< + Output = Result< + BoxStream<'static, Result>, + StreamableHttpError, + >, + > + Send + + '_ { + self.get_stream(uri, session_id, last_event_id, auth_header, custom_headers) + } } #[non_exhaustive] @@ -313,6 +371,7 @@ struct StreamableHttpClientReconnect { pub uri: Arc, pub auth_header: Option, pub custom_headers: HashMap, + pub max_sse_event_size: usize, } impl SseStreamReconnect for StreamableHttpClientReconnect { @@ -324,13 +383,25 @@ impl SseStreamReconnect for StreamableHttpClientReconne let session_id = self.session_id.clone(); let auth_header = self.auth_header.clone(); let custom_headers = self.custom_headers.clone(); + let max_sse_event_size = self.max_sse_event_size; let last_event_id = last_event_id.map(|s| s.to_owned()); Box::pin(async move { client - .get_stream(uri, session_id, last_event_id, auth_header, custom_headers) + .get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_header, + custom_headers, + max_sse_event_size, + ) .await }) } + + fn map_fatal_stream_error(&mut self, error: SseError) -> Option { + Some(StreamableHttpError::Sse(error)) + } } /// Info retained for cleaning up the session when the worker exits. @@ -485,6 +556,7 @@ impl StreamableHttpClientWorker { uri: Arc, auth_header: Option, custom_headers: HashMap, + max_sse_event_size: usize, retry_config: Arc, ) -> impl Stream>> + Send + 'static { @@ -496,6 +568,7 @@ impl StreamableHttpClientWorker { uri, auth_header, custom_headers, + max_sse_event_size, }, retry_config, ) @@ -514,6 +587,7 @@ impl StreamableHttpClientWorker { uri: Arc, auth_header: Option, custom_headers: HashMap, + max_sse_event_size: usize, retry_config: Arc, ) -> BoxStream<'static, Result>> { match session_id { @@ -524,6 +598,7 @@ impl StreamableHttpClientWorker { uri, auth_header, custom_headers, + max_sse_event_size, retry_config, ) .boxed(), @@ -591,15 +666,17 @@ impl StreamableHttpClientWorker { uri: Arc, auth_header: Option, custom_headers: HashMap, + max_sse_event_size: usize, ) -> Result<(Option>, HashMap), StreamableHttpError> { let (init_msg, new_session_id_str) = client - .post_message( + .post_message_with_max_sse_event_size( uri.clone(), saved_init_request, None, auth_header.clone(), custom_headers.clone(), + max_sse_event_size, ) .await? .expect_initialized::() @@ -624,12 +701,13 @@ impl StreamableHttpClientWorker { &negotiated_version, ); client - .post_message( + .post_message_with_max_sse_event_size( uri, initialized_notification, new_session_id.clone(), auth_header, initialized_headers, + max_sse_event_size, ) .await? .expect_accepted_or_json::()?; @@ -670,12 +748,13 @@ impl Worker for StreamableHttpClientWorker { let saved_init_request = initialize_request.clone(); let (message, session_id) = match self .client - .post_message( + .post_message_with_max_sse_event_size( config.uri.clone(), initialize_request, None, config.auth_header.clone(), config.custom_headers.clone(), + config.max_sse_event_size, ) .await { @@ -730,12 +809,13 @@ impl Worker for StreamableHttpClientWorker { &negotiated_version, ); self.client - .post_message( + .post_message_with_max_sse_event_size( config.uri.clone(), initialized_notification.message, session_id.clone(), config.auth_header.clone(), initialized_headers, + config.max_sse_event_size, ) .await .map_err(WorkerQuitReason::fatal_context( @@ -765,15 +845,17 @@ impl Worker for StreamableHttpClientWorker { let config_uri = config.uri.clone(); let config_auth_header = config.auth_header.clone(); let spawn_headers = protocol_headers.clone(); + let max_sse_event_size = config.max_sse_event_size; streams.spawn(async move { match client - .get_stream( + .get_stream_with_max_sse_event_size( uri.clone(), session_id.clone(), None, auth_header.clone(), spawn_headers.clone(), + max_sse_event_size, ) .await { @@ -786,6 +868,7 @@ impl Worker for StreamableHttpClientWorker { uri: config_uri, auth_header: config_auth_header, custom_headers: spawn_headers, + max_sse_event_size, }, retry_config, ); @@ -855,12 +938,13 @@ impl Worker for StreamableHttpClientWorker { ); let response = self .client - .post_message( + .post_message_with_max_sse_event_size( config.uri.clone(), message.clone(), session_id.clone(), config.auth_header.clone(), request_headers, + config.max_sse_event_size, ) .await; let send_result = match response { @@ -879,6 +963,7 @@ impl Worker for StreamableHttpClientWorker { config.uri.clone(), config.auth_header.clone(), config.custom_headers.clone(), + config.max_sse_event_size, ) .await { @@ -926,14 +1011,16 @@ impl Worker for StreamableHttpClientWorker { let config_uri = config.uri.clone(); let config_auth = config.auth_header.clone(); let spawn_headers = protocol_headers.clone(); + let max_sse_event_size = config.max_sse_event_size; streams.spawn(async move { match client - .get_stream( + .get_stream_with_max_sse_event_size( uri, new_sid.clone(), None, auth_header.clone(), spawn_headers.clone(), + max_sse_event_size, ) .await { @@ -946,6 +1033,7 @@ impl Worker for StreamableHttpClientWorker { uri: config_uri, auth_header: config_auth, custom_headers: spawn_headers, + max_sse_event_size, }, retry_config, ); @@ -981,12 +1069,13 @@ impl Worker for StreamableHttpClientWorker { ); let retry_response = self .client - .post_message( + .post_message_with_max_sse_event_size( config.uri.clone(), message, session_id.clone(), config.auth_header.clone(), retry_headers, + config.max_sse_event_size, ) .await; match retry_response { @@ -1021,6 +1110,7 @@ impl Worker for StreamableHttpClientWorker { config.uri.clone(), config.auth_header.clone(), protocol_headers.clone(), + config.max_sse_event_size, self.config.retry_config.clone(), ); streams.spawn(Self::execute_sse_stream( @@ -1064,6 +1154,7 @@ impl Worker for StreamableHttpClientWorker { config.uri.clone(), config.auth_header.clone(), protocol_headers.clone(), + config.max_sse_event_size, self.config.retry_config.clone(), ); streams.spawn(Self::execute_sse_stream( @@ -1339,6 +1430,12 @@ pub struct StreamableHttpClientTransportConfig { pub auth_header: Option, /// Custom HTTP headers to include with every request pub custom_headers: HashMap, + /// Maximum raw size of one SSE event accepted from the server. + /// + /// The built-in reqwest and Unix socket clients enforce this value. Custom + /// [`StreamableHttpClient`] implementations must override the corresponding + /// `*_with_max_sse_event_size` methods to enforce it. + pub max_sse_event_size: usize, /// Enables transparent recovery when the server reports an expired session (`HTTP 404`). /// /// When enabled, the transport performs one automatic recovery attempt: @@ -1397,6 +1494,12 @@ impl StreamableHttpClientTransportConfig { self } + /// Set the maximum raw size of one SSE event accepted from the server. + pub fn max_sse_event_size(mut self, bytes: usize) -> Self { + self.max_sse_event_size = bytes; + self + } + /// Set whether the transport should attempt transparent re-initialization on session expiration /// See [`Self::reinit_on_expired_session`] for details. /// # Example @@ -1420,6 +1523,7 @@ impl Default for StreamableHttpClientTransportConfig { allow_stateless: true, auth_header: None, custom_headers: HashMap::new(), + max_sse_event_size: DEFAULT_MAX_SSE_EVENT_SIZE, reinit_on_expired_session: true, } } diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 362ef34e8..5d81051d0 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -39,6 +39,9 @@ use crate::{ }, }; +/// Default maximum POST request body size (4 MiB). +pub(crate) const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024; + #[non_exhaustive] #[derive(Debug, Clone)] pub struct StreamableHttpServerConfig { @@ -100,6 +103,12 @@ pub struct StreamableHttpServerConfig { /// }; /// ``` pub session_store: Option>, + /// Maximum POST request body size in bytes. + /// + /// Enforced while streaming the body, independent of `Content-Length`, + /// chunked transfer encoding, or HTTP version. Oversized payloads receive + /// a `413 Payload Too Large` response. + pub max_request_body_bytes: usize, } impl std::fmt::Debug for dyn SessionStore { @@ -119,6 +128,7 @@ impl Default for StreamableHttpServerConfig { allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], allowed_origins: vec![], session_store: None, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, } } } @@ -172,6 +182,12 @@ impl StreamableHttpServerConfig { self.cancellation_token = token; self } + + /// Set the maximum POST request body size in bytes. + pub fn with_max_request_body_bytes(mut self, bytes: usize) -> Self { + self.max_request_body_bytes = bytes; + self + } } #[expect( @@ -1141,7 +1157,7 @@ where // json deserialize request body let (part, body) = request.into_parts(); - let mut message = match expect_json(body).await { + let mut message = match expect_json(body, self.config.max_request_body_bytes).await { Ok(message) => message, Err(response) => return Ok(response), }; From 92581bee53cd883e5b479616b369c1fcb4eb2fc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:20:17 -0400 Subject: [PATCH 233/333] chore(deps): bump actions/setup-node from 6 to 7 (#992) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61ff2c058..b0709ece8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' @@ -213,7 +213,7 @@ jobs: # install nodejs - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' @@ -242,7 +242,7 @@ jobs: # install nodejs - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' @@ -278,7 +278,7 @@ jobs: # install nodejs - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' @@ -313,7 +313,7 @@ jobs: # install nodejs - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' From 839922d8fd44216024b23ae72d16d1eae8cbf013 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:58:46 -0400 Subject: [PATCH 234/333] feat!: align metadata models with draft schema (#993) --- conformance/src/bin/server.rs | 2 +- crates/rmcp/src/handler/server/common.rs | 4 +- .../handler/server/router/tool/tool_traits.rs | 4 +- crates/rmcp/src/model.rs | 177 ++--- crates/rmcp/src/model/content.rs | 24 +- crates/rmcp/src/model/meta.rs | 646 +++++++++++++---- crates/rmcp/src/model/mrtr.rs | 10 +- crates/rmcp/src/model/prompt.rs | 20 +- crates/rmcp/src/model/resource.rs | 18 +- crates/rmcp/src/model/serde_impl.rs | 399 ++++++++--- crates/rmcp/src/model/task.rs | 10 +- crates/rmcp/src/model/tool.rs | 6 +- crates/rmcp/src/service.rs | 22 +- crates/rmcp/src/service/client.rs | 6 +- crates/rmcp/tests/test_elicitation.rs | 4 +- .../rmcp/tests/test_embedded_resource_meta.rs | 10 +- crates/rmcp/tests/test_message_schema.rs | 54 ++ .../client_json_rpc_message_schema.json | 401 +++++++---- ...lient_json_rpc_message_schema_current.json | 401 +++++++---- .../server_json_rpc_message_schema.json | 670 +++++++++++++----- ...erver_json_rpc_message_schema_current.json | 670 +++++++++++++----- crates/rmcp/tests/test_meta_helpers.rs | 10 +- crates/rmcp/tests/test_progress_subscriber.rs | 6 +- .../tests/test_request_timeout_progress.rs | 8 +- crates/rmcp/tests/test_tool_result_meta.rs | 4 +- crates/rmcp/tests/test_trace_context.rs | 6 +- examples/servers/src/common/counter.rs | 6 +- 27 files changed, 2539 insertions(+), 1059 deletions(-) diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 0e779f666..09bdd5e91 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -103,7 +103,7 @@ impl ConformanceServer { async fn call_mrtr_tool( &self, request: CallToolRequestParams, - meta: &Meta, + meta: &RequestMetaObject, ) -> Result { let responses = request.input_responses.as_ref(); match request.name.as_ref() { diff --git a/crates/rmcp/src/handler/server/common.rs b/crates/rmcp/src/handler/server/common.rs index aa1cc313a..8d3e4a8a1 100644 --- a/crates/rmcp/src/handler/server/common.rs +++ b/crates/rmcp/src/handler/server/common.rs @@ -209,13 +209,13 @@ where } } -impl FromContextPart for crate::model::Meta +impl FromContextPart for crate::model::RequestMetaObject where C: AsRequestContext, { fn from_context_part(context: &mut C) -> Result { let request_context = context.as_request_context_mut(); - let mut meta = crate::model::Meta::default(); + let mut meta = crate::model::RequestMetaObject::default(); std::mem::swap(&mut meta, &mut request_context.meta); Ok(meta) } diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs index df6594da7..436c3df3b 100644 --- a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -9,7 +9,7 @@ use crate::{ tool::schema_for_output, wrapper::{Json, Parameters}, }, - model::{Icon, JsonObject, Meta, ToolAnnotations, ToolExecution}, + model::{Icon, JsonObject, MetaObject, ToolAnnotations, ToolExecution}, schemars::JsonSchema, service::{MaybeSend, MaybeSendFuture}, }; @@ -77,7 +77,7 @@ pub trait ToolBase { fn icons() -> Option> { None } - fn meta() -> Option { + fn meta() -> Option { None } } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 70de62ea6..366511198 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -339,7 +339,8 @@ pub struct ProgressToken(pub NumberOrString); pub struct Request { pub method: M, pub params: P, - /// extensions will carry anything possible in the context, including [`Meta`] + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) /// /// this is similar with the Extensions in `http` crate #[cfg_attr(feature = "schemars", schemars(skip))] @@ -372,7 +373,8 @@ pub struct RequestOptionalParam { pub method: M, // #[serde(skip_serializing_if = "Option::is_none")] pub params: Option

, - /// extensions will carry anything possible in the context, including [`Meta`] + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) /// /// this is similar with the Extensions in `http` crate #[cfg_attr(feature = "schemars", schemars(skip))] @@ -394,7 +396,8 @@ impl RequestOptionalParam { #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RequestNoParam { pub method: M, - /// extensions will carry anything possible in the context, including [`Meta`] + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) /// /// this is similar with the Extensions in `http` crate #[cfg_attr(feature = "schemars", schemars(skip))] @@ -415,7 +418,8 @@ impl GetExtensions for RequestNoParam { pub struct Notification { pub method: M, pub params: P, - /// extensions will carry anything possible in the context, including [`Meta`] + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) /// /// this is similar with the Extensions in `http` crate #[cfg_attr(feature = "schemars", schemars(skip))] @@ -437,7 +441,8 @@ impl Notification { #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct NotificationNoParam { pub method: M, - /// extensions will carry anything possible in the context, including [`Meta`] + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) /// /// this is similar with the Extensions in `http` crate #[cfg_attr(feature = "schemars", schemars(skip))] @@ -782,7 +787,7 @@ pub struct CancelledNotificationParam { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl CancelledNotificationParam { @@ -818,7 +823,8 @@ pub type CancelledNotification = pub struct CustomNotification { pub method: String, pub params: Option, - /// extensions will carry anything possible in the context, including [`Meta`] + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) /// /// this is similar with the Extensions in `http` crate #[cfg_attr(feature = "schemars", schemars(skip))] @@ -853,7 +859,8 @@ impl CustomNotification { pub struct CustomRequest { pub method: String, pub params: Option, - /// extensions will carry anything possible in the context, including [`Meta`] + /// extensions will carry anything possible in the context, including the metadata + /// ([`RequestMetaObject`] for requests, [`NotificationMetaObject`] for notifications) /// /// this is similar with the Extensions in `http` crate #[cfg_attr(feature = "schemars", schemars(skip))] @@ -898,7 +905,7 @@ pub type InitializedNotification = NotificationNoParam, + pub meta: Option, /// The MCP protocol version this client supports pub protocol_version: ProtocolVersion, /// The capabilities this client supports (sampling, roots, etc.) @@ -925,10 +932,10 @@ impl InitializeRequestParams { } impl RequestParamsMeta for InitializeRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -956,7 +963,7 @@ pub struct InitializeResult { #[serde(skip_serializing_if = "Option::is_none")] pub instructions: Option, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl InitializeResult { @@ -1167,7 +1174,7 @@ impl Implementation { pub struct PaginatedRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, } @@ -1180,10 +1187,10 @@ impl PaginatedRequestParams { } impl RequestParamsMeta for PaginatedRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -1214,7 +1221,7 @@ pub struct ProgressNotificationParam { #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl ProgressNotificationParam { @@ -1287,7 +1294,7 @@ macro_rules! paginated_result { #[serde(default)] pub result_type: ResultType, #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub next_cursor: Option, /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). @@ -1365,7 +1372,7 @@ const_string!(ReadResourceRequestMethod = "resources/read"); pub struct ReadResourceRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// The URI of the resource to read pub uri: String, /// Client responses to server-initiated input requests from a previous @@ -1389,7 +1396,7 @@ impl ReadResourceRequestParams { } /// Set the metadata for this request. - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { self.meta = Some(meta); self } @@ -1408,10 +1415,10 @@ impl ReadResourceRequestParams { } impl RequestParamsMeta for ReadResourceRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -1446,7 +1453,7 @@ pub struct ReadResourceResult { /// The actual content of the resource pub contents: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl ReadResourceResult { @@ -1491,7 +1498,7 @@ const_string!(SubscribeRequestMethod = "resources/subscribe"); pub struct SubscribeRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// The URI of the resource to subscribe to pub uri: String, } @@ -1507,10 +1514,10 @@ impl SubscribeRequestParams { } impl RequestParamsMeta for SubscribeRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -1531,7 +1538,7 @@ const_string!(UnsubscribeRequestMethod = "resources/unsubscribe"); pub struct UnsubscribeRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// The URI of the resource to unsubscribe from pub uri: String, } @@ -1547,10 +1554,10 @@ impl UnsubscribeRequestParams { } impl RequestParamsMeta for UnsubscribeRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -1572,7 +1579,7 @@ pub struct ResourceUpdatedNotificationParam { /// The URI of the resource that was updated pub uri: String, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl ResourceUpdatedNotificationParam { @@ -1611,7 +1618,7 @@ const_string!(GetPromptRequestMethod = "prompts/get"); pub struct GetPromptRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, @@ -1643,7 +1650,7 @@ impl GetPromptRequestParams { } /// Set the metadata for this request. - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { self.meta = Some(meta); self } @@ -1662,10 +1669,10 @@ impl GetPromptRequestParams { } impl RequestParamsMeta for GetPromptRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -1722,7 +1729,7 @@ const_string!(SetLevelRequestMethod = "logging/setLevel"); pub struct SetLevelRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// The desired logging level pub level: LoggingLevel, } @@ -1735,10 +1742,10 @@ impl SetLevelRequestParams { } impl RequestParamsMeta for SetLevelRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -1773,7 +1780,7 @@ pub struct LoggingMessageNotificationParam { /// The actual log data pub data: Value, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl LoggingMessageNotificationParam { @@ -1994,7 +2001,7 @@ pub struct SamplingMessage { /// The actual content of the message (text, image, audio, tool use, or tool result) pub content: SamplingContent, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } /// Content types for sampling messages (SEP-1577). @@ -2161,7 +2168,7 @@ pub enum ContextInclusion { pub struct CreateMessageRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// Task metadata for async task management (SEP-1319) #[serde(skip_serializing_if = "Option::is_none")] pub task: Option, @@ -2196,10 +2203,10 @@ pub struct CreateMessageRequestParams { } impl RequestParamsMeta for CreateMessageRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -2516,7 +2523,7 @@ impl CompletionContext { pub struct CompleteRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, pub r#ref: Reference, pub argument: ArgumentInfo, /// Optional context containing previously resolved argument values @@ -2543,10 +2550,10 @@ impl CompleteRequestParams { } impl RequestParamsMeta for CompleteRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -2646,7 +2653,7 @@ pub struct CompleteResult { pub result_type: ResultType, pub completion: CompletionInfo, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl CompleteResult { @@ -2789,7 +2796,7 @@ pub struct Root { #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl Root { @@ -2809,7 +2816,7 @@ impl Root { } /// Sets the protocol-level metadata for this root. - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -2833,7 +2840,7 @@ pub type ListRootsRequest = RequestNoParam; pub struct ListRootsResult { pub roots: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl ListRootsResult { @@ -2843,7 +2850,7 @@ impl ListRootsResult { } /// Sets the protocol-level metadata for this result. - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -2889,14 +2896,14 @@ enum CreateElicitationRequestParamDeserializeHelper { #[serde(rename = "form", rename_all = "camelCase")] FormElicitationParam { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, + meta: Option, message: String, requested_schema: ElicitationSchema, }, #[serde(rename = "url", rename_all = "camelCase")] UrlElicitationParam { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, + meta: Option, message: String, url: String, elicitation_id: String, @@ -2904,7 +2911,7 @@ enum CreateElicitationRequestParamDeserializeHelper { #[serde(untagged, rename_all = "camelCase")] FormElicitationParamBackwardsCompat { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, + meta: Option, message: String, requested_schema: ElicitationSchema, }, @@ -2988,7 +2995,7 @@ pub enum ElicitRequestParams { FormElicitationParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, + meta: Option, /// Human-readable message explaining what input is needed from the user. /// This should be clear and provide sufficient context for the user to understand /// what information they need to provide. @@ -3003,7 +3010,7 @@ pub enum ElicitRequestParams { UrlElicitationParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - meta: Option, + meta: Option, /// Human-readable message explaining what input is needed from the user. /// This should be clear and provide sufficient context for the user to understand /// what information they need to provide. @@ -3018,13 +3025,13 @@ pub enum ElicitRequestParams { } impl RequestParamsMeta for ElicitRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { match self { ElicitRequestParams::FormElicitationParams { meta, .. } => meta.as_ref(), ElicitRequestParams::UrlElicitationParams { meta, .. } => meta.as_ref(), } } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { match self { ElicitRequestParams::FormElicitationParams { meta, .. } => meta, ElicitRequestParams::UrlElicitationParams { meta, .. } => meta, @@ -3059,7 +3066,7 @@ pub struct ElicitResult { /// Optional protocol-level metadata for this result. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl ElicitResult { @@ -3079,7 +3086,7 @@ impl ElicitResult { } /// Set the metadata on this result. - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -3121,7 +3128,7 @@ pub struct CallToolResult { pub is_error: Option, /// Optional protocol-level metadata for this result #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } // Custom Deserialize implementation that: @@ -3143,7 +3150,7 @@ impl<'de> Deserialize<'de> for CallToolResult { structured_content: Option, is_error: Option, #[serde(rename = "_meta")] - meta: Option, + meta: Option, } let helper = Helper::deserialize(deserializer)?; @@ -3290,7 +3297,7 @@ impl CallToolResult { } /// Set the metadata on this result - pub fn with_meta(mut self, meta: Option) -> Self { + pub fn with_meta(mut self, meta: Option) -> Self { self.meta = meta; self } @@ -3349,7 +3356,7 @@ const_string!(CallToolRequestMethod = "tools/call"); pub struct CallToolRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// The name of the tool to call pub name: Cow<'static, str>, /// Arguments to pass to the tool (must match the tool's input schema) @@ -3407,10 +3414,10 @@ impl CallToolRequestParams { } impl RequestParamsMeta for CallToolRequestParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -3503,7 +3510,7 @@ pub struct GetPromptResult { pub description: Option, pub messages: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl GetPromptResult { @@ -3543,7 +3550,7 @@ pub type GetTaskInfoRequest = GetTaskRequest; pub struct GetTaskParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, pub task_id: String, } @@ -3557,10 +3564,10 @@ impl GetTaskParams { } impl RequestParamsMeta for GetTaskParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -3589,7 +3596,7 @@ pub type GetTaskResultRequest = GetTaskPayloadRequest; pub struct GetTaskPayloadParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, pub task_id: String, } @@ -3603,10 +3610,10 @@ impl GetTaskPayloadParams { } impl RequestParamsMeta for GetTaskPayloadParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -3626,7 +3633,7 @@ pub type CancelTaskRequest = Request; pub struct CancelTaskParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, pub task_id: String, } @@ -3640,10 +3647,10 @@ impl CancelTaskParams { } impl RequestParamsMeta for CancelTaskParams { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -3666,7 +3673,7 @@ const_string!(TaskStatusNotificationMethod = "notifications/tasks/status"); #[non_exhaustive] pub struct TaskStatusNotificationParam { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, #[serde(flatten)] pub task: crate::model::Task, } @@ -3676,7 +3683,7 @@ impl TaskStatusNotificationParam { Self { meta: None, task } } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: NotificationMetaObject) -> Self { self.meta = Some(meta); self } @@ -3717,7 +3724,7 @@ pub struct ListTasksResult { #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl ListTasksResult { @@ -4487,7 +4494,9 @@ mod tests { { assert_eq!( meta, - Some(Meta(object!({ "meta_form_key_1": "meta form value 1" }))) + Some(RequestMetaObject(MetaObject( + object!({ "meta_form_key_1": "meta form value 1" }) + ))) ); assert_eq!(message, "Please provide more details."); assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); @@ -4514,7 +4523,9 @@ mod tests { { assert_eq!( meta, - Some(Meta(object!({ "meta_url_key_1": "meta url value 1" }))) + Some(RequestMetaObject(MetaObject( + object!({ "meta_url_key_1": "meta url value 1" }) + ))) ); assert_eq!(message, "Please fill out the form at the following URL."); assert_eq!(url, "https://example.com/form"); @@ -4527,7 +4538,9 @@ mod tests { #[test] fn test_elicitation_serialization() { let form_elicitation = ElicitRequestParams::FormElicitationParams { - meta: Some(Meta(object!({ "meta_form_key_1": "meta form value 1" }))), + meta: Some(RequestMetaObject(MetaObject( + object!({ "meta_form_key_1": "meta form value 1" }), + ))), message: "Please provide more details.".to_string(), requested_schema: ElicitationSchema::builder() .title("User Details") @@ -4551,7 +4564,9 @@ mod tests { assert_eq!(json_form, expected_form_json); let url_elicitation = ElicitRequestParams::UrlElicitationParams { - meta: Some(Meta(object!({ "meta_url_key_1": "meta url value 1" }))), + meta: Some(RequestMetaObject(MetaObject( + object!({ "meta_url_key_1": "meta url value 1" }), + ))), message: "Please fill out the form at the following URL.".to_string(), url: "https://example.com/form".to_string(), elicitation_id: "elicitation-123".to_string(), diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index d454255e3..11b2f0e53 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use super::{Annotations, Meta, resource::ResourceContents}; +use super::{Annotations, MetaObject, resource::ResourceContents}; // --------------------------------------------------------------------------- // Flat content structs @@ -28,7 +28,7 @@ pub struct TextContent { pub text: String, /// Optional protocol-level metadata for this content block. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// Optional annotations describing how the client should use this content. #[serde(skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -43,7 +43,7 @@ impl TextContent { } } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -66,7 +66,7 @@ pub struct ImageContent { pub mime_type: String, /// Optional protocol-level metadata for this content block. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// Optional annotations describing how the client should use this content. #[serde(skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -82,7 +82,7 @@ impl ImageContent { } } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -105,7 +105,7 @@ pub struct AudioContent { pub mime_type: String, /// Optional protocol-level metadata for this content block. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// Optional annotations describing how the client should use this content. #[serde(skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -121,7 +121,7 @@ impl AudioContent { } } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -142,7 +142,7 @@ pub struct EmbeddedResource { pub resource: ResourceContents, /// Optional protocol-level metadata for this content block. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// Optional annotations describing how the client should use this content. #[serde(skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -164,7 +164,7 @@ impl EmbeddedResource { } } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -189,7 +189,7 @@ pub struct ToolUseContent { pub name: String, pub input: super::JsonObject, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } /// Tool execution result in user message (SEP-1577). @@ -203,7 +203,7 @@ pub struct ToolUseContent { )] pub struct ToolResultContent { #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, pub tool_use_id: String, pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -402,7 +402,7 @@ mod tests { #[test] fn test_audio_content_has_meta() { - let audio = AudioContent::new("data", "audio/wav").with_meta(Meta::default()); + let audio = AudioContent::new("data", "audio/wav").with_meta(MetaObject::default()); let json = serde_json::to_value(&audio).unwrap(); assert!(json.get("_meta").is_some()); } diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index bc1b94b48..53675ecc5 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -5,13 +5,29 @@ use serde_json::Value; use super::{ ClientCapabilities, ClientNotification, ClientRequest, CustomNotification, CustomRequest, - Extensions, Implementation, JsonObject, JsonRpcMessage, LoggingLevel, NumberOrString, - ProgressToken, ProtocolVersion, ServerNotification, ServerRequest, TaskMetadata, + Extensions, Implementation, JsonObject, JsonRpcMessage, LoggingLevel, ProgressToken, + ProtocolVersion, RequestId, ServerNotification, ServerRequest, TaskMetadata, }; +/// Access to the metadata carried by a message envelope's [`Extensions`]. +/// +/// The metadata type differs by message kind: requests carry a +/// [`RequestMetaObject`] and notifications carry a [`NotificationMetaObject`]. +/// +/// The envelope extensions are the canonical runtime location for `_meta`: +/// deserialization strips the wire `params._meta` into the extensions (typed +/// params `meta` fields stay empty), and the service loop moves it into +/// [`RequestContext::meta`] / [`NotificationContext::meta`] before dispatch. +/// Typed params `meta` fields are honored when serializing outgoing messages; +/// on key conflicts the extensions-level metadata wins. +/// +/// [`RequestContext::meta`]: crate::service::RequestContext +/// [`NotificationContext::meta`]: crate::service::NotificationContext pub trait GetMeta { - fn get_meta_mut(&mut self) -> &mut Meta; - fn get_meta(&self) -> &Meta; + /// The metadata type for this message kind. + type Metadata: Default; + fn get_meta_mut(&mut self) -> &mut Self::Metadata; + fn get_meta(&self) -> &Self::Metadata; } pub trait GetExtensions { @@ -21,15 +37,16 @@ pub trait GetExtensions { /// Trait for request params that contain the `_meta` field. /// -/// Per the MCP 2025-11-25 spec, all request params should have an optional `_meta` -/// field that can contain a `progressToken` for tracking long-running operations. +/// Per the MCP spec, all request params may have an optional `_meta` +/// field ([`RequestMetaObject`]) that can contain a `progressToken` for +/// tracking long-running operations. pub trait RequestParamsMeta { /// Get a reference to the meta field - fn meta(&self) -> Option<&Meta>; + fn meta(&self) -> Option<&RequestMetaObject>; /// Get a mutable reference to the meta field - fn meta_mut(&mut self) -> &mut Option; + fn meta_mut(&mut self) -> &mut Option; /// Set the meta field - fn set_meta(&mut self, meta: Meta) { + fn set_meta(&mut self, meta: RequestMetaObject) { *self.meta_mut() = Some(meta); } /// Get the progress token from meta, if present @@ -41,7 +58,7 @@ pub trait RequestParamsMeta { match self.meta_mut() { Some(meta) => meta.set_progress_token(token), none => { - let mut meta = Meta::new(); + let mut meta = RequestMetaObject::new(); meta.set_progress_token(token); *none = Some(meta); } @@ -72,8 +89,8 @@ pub trait RequestParamsMeta { self.meta_or_default().set_baggage(value); } /// Get a mutable reference to meta, inserting an empty one if absent. - fn meta_or_default(&mut self) -> &mut Meta { - self.meta_mut().get_or_insert_with(Meta::new) + fn meta_or_default(&mut self) -> &mut RequestMetaObject { + self.meta_mut().get_or_insert_with(RequestMetaObject::new) } } @@ -102,13 +119,14 @@ impl GetExtensions for CustomNotification { } impl GetMeta for CustomNotification { - fn get_meta_mut(&mut self) -> &mut Meta { + type Metadata = NotificationMetaObject; + fn get_meta_mut(&mut self) -> &mut NotificationMetaObject { self.extensions_mut().get_or_insert_default() } - fn get_meta(&self) -> &Meta { + fn get_meta(&self) -> &NotificationMetaObject { self.extensions() - .get::() - .unwrap_or(Meta::static_empty()) + .get::() + .unwrap_or(NotificationMetaObject::static_empty()) } } @@ -122,19 +140,20 @@ impl GetExtensions for CustomRequest { } impl GetMeta for CustomRequest { - fn get_meta_mut(&mut self) -> &mut Meta { + type Metadata = RequestMetaObject; + fn get_meta_mut(&mut self) -> &mut RequestMetaObject { self.extensions_mut().get_or_insert_default() } - fn get_meta(&self) -> &Meta { + fn get_meta(&self) -> &RequestMetaObject { self.extensions() - .get::() - .unwrap_or(Meta::static_empty()) + .get::() + .unwrap_or(RequestMetaObject::static_empty()) } } macro_rules! variant_extension { ( - $Enum: ident { + $Enum: ident: $Metadata: ident { $($variant: ident)* } ) => { @@ -155,18 +174,19 @@ macro_rules! variant_extension { } } impl GetMeta for $Enum { - fn get_meta_mut(&mut self) -> &mut Meta { + type Metadata = $Metadata; + fn get_meta_mut(&mut self) -> &mut $Metadata { self.extensions_mut().get_or_insert_default() } - fn get_meta(&self) -> &Meta { - self.extensions().get::().unwrap_or(Meta::static_empty()) + fn get_meta(&self) -> &$Metadata { + self.extensions().get::<$Metadata>().unwrap_or($Metadata::static_empty()) } } }; } variant_extension! { - ClientRequest { + ClientRequest: RequestMetaObject { PingRequest InitializeRequest CompleteRequest @@ -189,7 +209,7 @@ variant_extension! { } variant_extension! { - ServerRequest { + ServerRequest: RequestMetaObject { PingRequest CreateMessageRequest ListRootsRequest @@ -199,7 +219,7 @@ variant_extension! { } variant_extension! { - ClientNotification { + ClientNotification: NotificationMetaObject { CancelledNotification ProgressNotification InitializedNotification @@ -210,7 +230,7 @@ variant_extension! { } variant_extension! { - ServerNotification { + ServerNotification: NotificationMetaObject { CancelledNotification ProgressNotification LoggingMessageNotification @@ -222,18 +242,31 @@ variant_extension! { CustomNotification } } + +/// General-purpose `_meta` map (spec `MetaObject`). +/// +/// This is the metadata shape used by results, content blocks, and catalog +/// descriptors (tools, prompts, resources, roots, ...). It preserves arbitrary +/// extension keys and offers helpers for the reserved W3C Trace Context keys +/// (SEP-414). +/// +/// Request and notification `_meta` maps have additional reserved keys; see +/// [`RequestMetaObject`] and [`NotificationMetaObject`]. #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(transparent)] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct Meta(pub JsonObject); +pub struct MetaObject(pub JsonObject); -impl Meta { - const PROGRESS_TOKEN_FIELD: &str = "progressToken"; - const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; - const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; - const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; - const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; +/// Deprecated alias for [`MetaObject`]. +/// +/// This is a re-export rather than a type alias so the `Meta(...)` tuple +/// constructor keeps working. Request and notification metadata now have +/// dedicated types; use [`RequestMetaObject`] or [`NotificationMetaObject`] +/// where those are expected. +#[deprecated(note = "Use MetaObject (or RequestMetaObject / NotificationMetaObject)")] +pub use self::MetaObject as Meta; + +impl MetaObject { /// Reserved `_meta` key for the W3C Trace Context `traceparent` value (SEP-414). const TRACEPARENT_FIELD: &str = "traceparent"; /// Reserved `_meta` key for the W3C Trace Context `tracestate` value (SEP-414). @@ -241,11 +274,164 @@ impl Meta { /// Reserved `_meta` key for the W3C Baggage value (SEP-414). const BAGGAGE_FIELD: &str = "baggage"; + /// Create an empty metadata map. pub fn new() -> Self { Self(JsonObject::new()) } - /// Create a new Meta with a progress token set + /// Read a string-valued `_meta` field, or `None` if absent or not a string. + fn get_str(&self, field: &str) -> Option<&str> { + self.0.get(field).and_then(Value::as_str) + } + + /// Write a string-valued `_meta` field. + fn set_str(&mut self, field: &str, value: impl Into) { + self.0 + .insert(field.to_string(), Value::String(value.into())); + } + + /// Get the W3C `traceparent` value (SEP-414), if present. + pub fn get_traceparent(&self) -> Option<&str> { + self.get_str(Self::TRACEPARENT_FIELD) + } + + /// Set the W3C `traceparent` value (SEP-414). + /// + /// ``` + /// use rmcp::model::MetaObject; + /// + /// let mut meta = MetaObject::new(); + /// meta.set_traceparent("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"); + /// assert_eq!( + /// meta.get_traceparent(), + /// Some("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"), + /// ); + /// ``` + pub fn set_traceparent(&mut self, value: impl Into) { + self.set_str(Self::TRACEPARENT_FIELD, value); + } + + /// Get the W3C `tracestate` value (SEP-414), if present. + pub fn get_tracestate(&self) -> Option<&str> { + self.get_str(Self::TRACESTATE_FIELD) + } + + /// Set the W3C `tracestate` value (SEP-414). + pub fn set_tracestate(&mut self, value: impl Into) { + self.set_str(Self::TRACESTATE_FIELD, value); + } + + /// Get the W3C `baggage` value (SEP-414), if present. + pub fn get_baggage(&self) -> Option<&str> { + self.get_str(Self::BAGGAGE_FIELD) + } + + /// Set the W3C `baggage` value (SEP-414). + pub fn set_baggage(&mut self, value: impl Into) { + self.set_str(Self::BAGGAGE_FIELD, value); + } + + /// Insert every entry of `other`, overwriting existing keys on conflict. + pub fn extend(&mut self, other: MetaObject) { + self.0.extend(other.0); + } + + fn decode_value(&self, key: &str) -> Option + where + T: for<'de> Deserialize<'de>, + { + self.0.get(key).and_then(|value| T::deserialize(value).ok()) + } + + fn insert_serialized(&mut self, key: &str, value: T) + where + T: Serialize, + { + let value = serde_json::to_value(value) + .expect("MCP meta helper value should serialize to valid JSON"); + self.0.insert(key.to_string(), value); + } +} + +impl Deref for MetaObject { + type Target = JsonObject; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for MetaObject { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for MetaObject { + fn from(object: JsonObject) -> Self { + Self(object) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for MetaObject { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("MetaObject") + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "type": "object", + "additionalProperties": true, + }) + } +} + +/// The `_meta` map carried by requests (spec `RequestMetaObject`). +/// +/// In addition to arbitrary extension keys, requests reserve: +/// - `progressToken` for progress tracking +/// - `io.modelcontextprotocol/protocolVersion` (SEP-2575) +/// - `io.modelcontextprotocol/clientInfo` (SEP-2575) +/// - `io.modelcontextprotocol/clientCapabilities` (SEP-2575) +/// - `io.modelcontextprotocol/logLevel` (SEP-2575) +/// +/// The 2026-07-28 draft schema marks the protocol-version, client-info, and +/// client-capabilities keys as required; earlier protocol versions do not know +/// them. All keys therefore stay optional at runtime and in the generated +/// (version-shared) JSON schema — use +/// [`RequestMetaObject::missing_required_keys`] to validate a request against +/// the negotiated protocol version. +/// +/// This type dereferences to [`MetaObject`] (and transitively to the underlying +/// map), so general helpers such as the SEP-414 trace-context accessors remain +/// available. +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] +#[serde(transparent)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct RequestMetaObject(pub MetaObject); + +impl RequestMetaObject { + const PROGRESS_TOKEN_FIELD: &str = "progressToken"; + const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; + const META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo"; + const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; + const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; + + /// Request `_meta` keys the 2026-07-28 draft schema marks as required. + pub const DRAFT_REQUIRED_KEYS: [&str; 3] = [ + Self::META_KEY_PROTOCOL_VERSION, + Self::META_KEY_CLIENT_INFO, + Self::META_KEY_CLIENT_CAPABILITIES, + ]; + + /// Create an empty request metadata map. + pub fn new() -> Self { + Self::default() + } + + /// Create a new request meta with a progress token set pub fn with_progress_token(token: ProgressToken) -> Self { let mut meta = Self::new(); meta.set_progress_token(token); @@ -253,55 +439,28 @@ impl Meta { } pub(crate) fn static_empty() -> &'static Self { - static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); EMPTY.get_or_init(Default::default) } + /// Get the progress token carried in `_meta`, if present and valid. pub fn get_progress_token(&self) -> Option { - self.0 - .get(Self::PROGRESS_TOKEN_FIELD) - .and_then(|v| match v { - Value::String(s) => { - Some(ProgressToken(NumberOrString::String(s.to_string().into()))) - } - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Some(ProgressToken(NumberOrString::Number(i))) - } else if let Some(u) = n.as_u64() { - if u <= i64::MAX as u64 { - Some(ProgressToken(NumberOrString::Number(u as i64))) - } else { - None - } - } else { - None - } - } - _ => None, - }) + self.0.decode_value(Self::PROGRESS_TOKEN_FIELD) } + /// Set the progress token carried in `_meta`. pub fn set_progress_token(&mut self, token: ProgressToken) { - match token.0 { - NumberOrString::String(ref s) => self.0.insert( - Self::PROGRESS_TOKEN_FIELD.to_string(), - Value::String(s.to_string()), - ), - NumberOrString::Number(n) => self.0.insert( - Self::PROGRESS_TOKEN_FIELD.to_string(), - Value::Number(n.into()), - ), - }; + self.0.insert_serialized(Self::PROGRESS_TOKEN_FIELD, token); } /// Get the MCP protocol version carried in `_meta`, if present and valid. pub fn protocol_version(&self) -> Option { - self.decode_value(Self::META_KEY_PROTOCOL_VERSION) + self.0.decode_value(Self::META_KEY_PROTOCOL_VERSION) } /// Set the MCP protocol version carried in `_meta`. pub fn set_protocol_version(&mut self, protocol_version: ProtocolVersion) { - self.0.insert( + self.0.0.insert( Self::META_KEY_PROTOCOL_VERSION.to_string(), Value::String(protocol_version.to_string()), ); @@ -309,123 +468,246 @@ impl Meta { /// Get the client implementation identity carried in `_meta`, if present and valid. pub fn client_info(&self) -> Option { - self.decode_value(Self::META_KEY_CLIENT_INFO) + self.0.decode_value(Self::META_KEY_CLIENT_INFO) } /// Set the client implementation identity carried in `_meta`. pub fn set_client_info(&mut self, client_info: Implementation) { - self.insert_serialized(Self::META_KEY_CLIENT_INFO, client_info); + self.0 + .insert_serialized(Self::META_KEY_CLIENT_INFO, client_info); } /// Get the client capabilities carried in `_meta`, if present and valid. pub fn client_capabilities(&self) -> Option { - self.decode_value(Self::META_KEY_CLIENT_CAPABILITIES) + self.0.decode_value(Self::META_KEY_CLIENT_CAPABILITIES) } /// Set the client capabilities carried in `_meta`. pub fn set_client_capabilities(&mut self, client_capabilities: ClientCapabilities) { - self.insert_serialized(Self::META_KEY_CLIENT_CAPABILITIES, client_capabilities); + self.0 + .insert_serialized(Self::META_KEY_CLIENT_CAPABILITIES, client_capabilities); } /// Get the requested per-request log level carried in `_meta`, if present and valid. pub fn log_level(&self) -> Option { - self.decode_value(Self::META_KEY_LOG_LEVEL) + self.0.decode_value(Self::META_KEY_LOG_LEVEL) } /// Set the requested per-request log level carried in `_meta`. pub fn set_log_level(&mut self, log_level: LoggingLevel) { - self.insert_serialized(Self::META_KEY_LOG_LEVEL, log_level); - } - - /// Read a string-valued `_meta` field, or `None` if absent or not a string. - fn get_str(&self, field: &str) -> Option<&str> { - self.0.get(field).and_then(Value::as_str) - } - - /// Write a string-valued `_meta` field. - fn set_str(&mut self, field: &str, value: impl Into) { self.0 - .insert(field.to_string(), Value::String(value.into())); - } - - /// Get the W3C `traceparent` value (SEP-414), if present. - pub fn get_traceparent(&self) -> Option<&str> { - self.get_str(Self::TRACEPARENT_FIELD) + .insert_serialized(Self::META_KEY_LOG_LEVEL, log_level); } - /// Set the W3C `traceparent` value (SEP-414). + /// Return the [`Self::DRAFT_REQUIRED_KEYS`] whose values are absent or + /// invalid in this map, if `protocol_version` requires them. + /// + /// A key counts as missing when it is not present *or* when its value does + /// not decode into the expected type (e.g. a numeric `protocolVersion` or + /// a string `clientInfo`), matching what the typed accessors return. + /// + /// Protocol versions before 2026-07-28 have no required request metadata, + /// so this always returns an empty list for them. + /// + /// # Examples /// /// ``` - /// use rmcp::model::Meta; + /// use rmcp::model::{ProtocolVersion, RequestMetaObject}; /// - /// let mut meta = Meta::new(); - /// meta.set_traceparent("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"); + /// let meta = RequestMetaObject::new(); + /// // Older protocols have no required request metadata. + /// assert!( + /// meta.missing_required_keys(&ProtocolVersion::V_2025_11_25) + /// .is_empty() + /// ); + /// // The 2026-07-28 draft requires the SEP-2575 keys. /// assert_eq!( - /// meta.get_traceparent(), - /// Some("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"), + /// meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), + /// RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec(), /// ); /// ``` - pub fn set_traceparent(&mut self, value: impl Into) { - self.set_str(Self::TRACEPARENT_FIELD, value); + pub fn missing_required_keys(&self, protocol_version: &ProtocolVersion) -> Vec<&'static str> { + if protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() { + return Vec::new(); + } + let mut missing = Vec::new(); + if self.protocol_version().is_none() { + missing.push(Self::META_KEY_PROTOCOL_VERSION); + } + if self.client_info().is_none() { + missing.push(Self::META_KEY_CLIENT_INFO); + } + if self.client_capabilities().is_none() { + missing.push(Self::META_KEY_CLIENT_CAPABILITIES); + } + missing } - /// Get the W3C `tracestate` value (SEP-414), if present. - pub fn get_tracestate(&self) -> Option<&str> { - self.get_str(Self::TRACESTATE_FIELD) + /// Insert every entry of `other`, overwriting existing keys on conflict. + pub fn extend(&mut self, other: RequestMetaObject) { + self.0.extend(other.0); } +} - /// Set the W3C `tracestate` value (SEP-414). - pub fn set_tracestate(&mut self, value: impl Into) { - self.set_str(Self::TRACESTATE_FIELD, value); +impl Deref for RequestMetaObject { + type Target = MetaObject; + + fn deref(&self) -> &Self::Target { + &self.0 } +} - /// Get the W3C `baggage` value (SEP-414), if present. - pub fn get_baggage(&self) -> Option<&str> { - self.get_str(Self::BAGGAGE_FIELD) +impl DerefMut for RequestMetaObject { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 } +} - /// Set the W3C `baggage` value (SEP-414). - pub fn set_baggage(&mut self, value: impl Into) { - self.set_str(Self::BAGGAGE_FIELD, value); +impl From for RequestMetaObject { + fn from(meta: MetaObject) -> Self { + Self(meta) } +} - pub fn extend(&mut self, other: Meta) { - for (k, v) in other.0.into_iter() { - self.0.insert(k, v); - } +impl From for RequestMetaObject { + fn from(object: JsonObject) -> Self { + Self(MetaObject(object)) } +} - fn decode_value(&self, key: &str) -> Option - where - T: for<'de> Deserialize<'de>, - { - self.0.get(key).and_then(|value| T::deserialize(value).ok()) +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for RequestMetaObject { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("RequestMetaObject") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let progress_token = generator.subschema_for::(); + let client_info = generator.subschema_for::(); + let client_capabilities = generator.subschema_for::(); + let log_level = generator.subschema_for::(); + // rmcp generates one schema shared by every supported protocol + // version, so the keys the 2026-07-28 draft marks as required are left + // optional here: a 2025-11-25 request whose `_meta` only carries + // `progressToken` is valid. Draft-strict validation is available at + // runtime via [`RequestMetaObject::missing_required_keys`]. + schemars::json_schema!({ + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "progressToken": progress_token, + "io.modelcontextprotocol/protocolVersion": { + "type": "string", + }, + "io.modelcontextprotocol/clientInfo": client_info, + "io.modelcontextprotocol/clientCapabilities": client_capabilities, + "io.modelcontextprotocol/logLevel": log_level, + }, + "additionalProperties": true, + }) } +} - fn insert_serialized(&mut self, key: &str, value: T) - where - T: Serialize, - { - let value = serde_json::to_value(value) - .expect("MCP meta helper value should serialize to valid JSON"); - self.0.insert(key.to_string(), value); +/// The `_meta` map carried by notifications (spec `NotificationMetaObject`). +/// +/// In addition to arbitrary extension keys, notifications reserve +/// `io.modelcontextprotocol/subscriptionId` to correlate a notification with a +/// prior subscription request. +/// +/// This type dereferences to [`MetaObject`] (and transitively to the underlying +/// map), so general helpers such as the SEP-414 trace-context accessors remain +/// available. +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] +#[serde(transparent)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct NotificationMetaObject(pub MetaObject); + +impl NotificationMetaObject { + const META_KEY_SUBSCRIPTION_ID: &str = "io.modelcontextprotocol/subscriptionId"; + + /// Create an empty notification metadata map. + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn static_empty() -> &'static Self { + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + EMPTY.get_or_init(Default::default) + } + + /// Get the subscription id carried in `_meta`, if present and valid. + /// + /// # Examples + /// + /// ``` + /// use rmcp::model::{NotificationMetaObject, RequestId}; + /// + /// let mut meta = NotificationMetaObject::new(); + /// assert_eq!(meta.subscription_id(), None); + /// meta.set_subscription_id(RequestId::Number(7)); + /// assert_eq!(meta.subscription_id(), Some(RequestId::Number(7))); + /// ``` + pub fn subscription_id(&self) -> Option { + self.0.decode_value(Self::META_KEY_SUBSCRIPTION_ID) + } + + /// Set the subscription id carried in `_meta`. + pub fn set_subscription_id(&mut self, subscription_id: RequestId) { + self.0 + .insert_serialized(Self::META_KEY_SUBSCRIPTION_ID, subscription_id); + } + + /// Insert every entry of `other`, overwriting existing keys on conflict. + pub fn extend(&mut self, other: NotificationMetaObject) { + self.0.extend(other.0); } } -impl Deref for Meta { - type Target = JsonObject; +impl Deref for NotificationMetaObject { + type Target = MetaObject; fn deref(&self) -> &Self::Target { &self.0 } } -impl DerefMut for Meta { +impl DerefMut for NotificationMetaObject { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } +impl From for NotificationMetaObject { + fn from(meta: MetaObject) -> Self { + Self(meta) + } +} + +impl From for NotificationMetaObject { + fn from(object: JsonObject) -> Self { + Self(MetaObject(object)) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for NotificationMetaObject { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("NotificationMetaObject") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let subscription_id = generator.subschema_for::(); + schemars::json_schema!({ + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": subscription_id, + }, + "additionalProperties": true, + }) + } +} + impl JsonRpcMessage where Req: GetExtensions, @@ -450,17 +732,18 @@ where #[cfg(test)] mod tests { use super::*; + use crate::model::NumberOrString; #[derive(Default)] struct Params { - meta: Option, + meta: Option, } impl RequestParamsMeta for Params { - fn meta(&self) -> Option<&Meta> { + fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } - fn meta_mut(&mut self) -> &mut Option { + fn meta_mut(&mut self) -> &mut Option { &mut self.meta } } @@ -469,7 +752,7 @@ mod tests { #[test] fn trace_context_round_trip() { - let mut meta = Meta::new(); + let mut meta = MetaObject::new(); meta.set_traceparent(TRACEPARENT); meta.set_tracestate("vendor1=value1,vendor2=value2"); meta.set_baggage("userId=alice,region=us-east-1"); @@ -480,7 +763,7 @@ mod tests { #[test] fn absent_field_is_none() { - let meta = Meta::new(); + let meta = MetaObject::new(); assert_eq!(meta.get_traceparent(), None); assert_eq!(meta.get_tracestate(), None); assert_eq!(meta.get_baggage(), None); @@ -488,9 +771,9 @@ mod tests { #[test] fn non_string_value_is_none() { - let mut meta = Meta::new(); + let mut meta = MetaObject::new(); meta.0 - .insert(Meta::TRACEPARENT_FIELD.to_string(), Value::from(42)); + .insert(MetaObject::TRACEPARENT_FIELD.to_string(), Value::from(42)); assert_eq!(meta.get_traceparent(), None); } @@ -501,4 +784,91 @@ mod tests { params.set_traceparent(TRACEPARENT); assert_eq!(params.traceparent(), Some(TRACEPARENT)); } + + #[test] + fn request_meta_derefs_to_general_helpers() { + let mut meta = RequestMetaObject::new(); + meta.set_traceparent(TRACEPARENT); + meta.set_progress_token(ProgressToken(NumberOrString::Number(7))); + assert_eq!(meta.get_traceparent(), Some(TRACEPARENT)); + assert_eq!( + meta.get_progress_token(), + Some(ProgressToken(NumberOrString::Number(7))) + ); + } + + mod subscription_id { + use super::*; + + #[test] + fn returns_none_when_absent() { + let meta = NotificationMetaObject::new(); + assert_eq!(meta.subscription_id(), None); + } + + #[test] + fn round_trips_number_id() { + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(RequestId::Number(42)); + assert_eq!(meta.subscription_id(), Some(RequestId::Number(42))); + } + + #[test] + fn round_trips_string_id() { + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(RequestId::String("sub-1".into())); + assert_eq!( + meta.subscription_id(), + Some(RequestId::String("sub-1".into())) + ); + } + } + + mod missing_required_keys { + use super::*; + + #[test] + fn is_empty_for_pre_draft_protocols() { + let meta = RequestMetaObject::new(); + assert!( + meta.missing_required_keys(&ProtocolVersion::V_2025_11_25) + .is_empty() + ); + } + + #[test] + fn lists_all_draft_keys_for_empty_meta() { + let meta = RequestMetaObject::new(); + assert_eq!( + meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), + RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec() + ); + } + + #[test] + fn treats_malformed_values_as_missing() { + let meta: RequestMetaObject = serde_json::from_value(serde_json::json!({ + "io.modelcontextprotocol/protocolVersion": 123, + "io.modelcontextprotocol/clientInfo": "not an implementation", + "io.modelcontextprotocol/clientCapabilities": null, + })) + .unwrap(); + assert_eq!( + meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), + RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec() + ); + } + + #[test] + fn is_empty_when_draft_keys_are_present() { + let mut meta = RequestMetaObject::new(); + meta.set_protocol_version(ProtocolVersion::V_2026_07_28); + meta.set_client_info(Implementation::from_build_env()); + meta.set_client_capabilities(ClientCapabilities::default()); + assert!( + meta.missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty() + ); + } + } } diff --git a/crates/rmcp/src/model/mrtr.rs b/crates/rmcp/src/model/mrtr.rs index 2b8ba71be..7e08a5cc0 100644 --- a/crates/rmcp/src/model/mrtr.rs +++ b/crates/rmcp/src/model/mrtr.rs @@ -42,8 +42,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use super::{ - CallToolResult, CreateMessageRequest, ElicitRequest, GetPromptResult, ListRootsRequest, Meta, - ReadResourceResult, ResultType, ServerResult, + CallToolResult, CreateMessageRequest, ElicitRequest, GetPromptResult, ListRootsRequest, + MetaObject, ReadResourceResult, ResultType, ServerResult, }; /// Default maximum number of MRTR rounds a high-level client call will drive. @@ -220,7 +220,7 @@ pub struct InputRequiredResult { /// Optional protocol-level metadata. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } /// Custom deserializer that requires `resultType: "input_required"` to prevent @@ -238,7 +238,7 @@ impl<'de> Deserialize<'de> for InputRequiredResult { input_requests: Option, request_state: Option, #[serde(rename = "_meta")] - meta: Option, + meta: Option, } let helper = Helper::deserialize(deserializer)?; @@ -283,7 +283,7 @@ impl InputRequiredResult { } /// Sets optional metadata. - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index e438260b5..46f5168ea 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use super::{ - Annotations, ContentBlock, Icon, Meta, Role, + Annotations, ContentBlock, Icon, MetaObject, Role, content::{AudioContent, EmbeddedResource, ImageContent, TextContent}, resource::ResourceContents, }; @@ -22,7 +22,7 @@ pub struct Prompt { #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl Prompt { @@ -70,7 +70,7 @@ impl Prompt { self } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -144,7 +144,7 @@ impl PromptMessage { role: Role, data: &[u8], mime_type: &str, - meta: Option, + meta: Option, annotations: Option, ) -> Self { use base64::{Engine, prelude::BASE64_STANDARD}; @@ -166,7 +166,7 @@ impl PromptMessage { role: Role, data: &[u8], mime_type: &str, - meta: Option, + meta: Option, annotations: Option, ) -> Self { use base64::{Engine, prelude::BASE64_STANDARD}; @@ -188,8 +188,8 @@ impl PromptMessage { uri: String, mime_type: Option, text: Option, - resource_meta: Option, - resource_content_meta: Option, + resource_meta: Option, + resource_content_meta: Option, annotations: Option, ) -> Self { let resource_contents = match text { @@ -216,7 +216,11 @@ impl PromptMessage { } } - pub fn new_text_with_meta>(role: Role, text: S, meta: Option) -> Self { + pub fn new_text_with_meta>( + role: Role, + text: S, + meta: Option, + ) -> Self { Self { role, content: ContentBlock::Text(TextContent { diff --git a/crates/rmcp/src/model/resource.rs b/crates/rmcp/src/model/resource.rs index 0381d4d82..9c27dce2f 100644 --- a/crates/rmcp/src/model/resource.rs +++ b/crates/rmcp/src/model/resource.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use super::{Annotations, Icon, Meta}; +use super::{Annotations, Icon, MetaObject}; /// A known resource that the server is capable of reading (spec `Resource`). /// @@ -31,7 +31,7 @@ pub struct Resource { pub icons: Option>, /// Optional protocol-level metadata for this resource. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// Optional annotations describing how the client should use this resource. #[serde(skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -77,7 +77,7 @@ impl Resource { self } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -112,7 +112,7 @@ pub struct ResourceTemplate { pub icons: Option>, /// Optional protocol-level metadata for this resource template. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, /// Optional annotations describing how the client should use this template. #[serde(skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -152,7 +152,7 @@ impl ResourceTemplate { self } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -176,7 +176,7 @@ pub enum ResourceContents { mime_type: Option, text: String, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - meta: Option, + meta: Option, }, #[serde(rename_all = "camelCase")] BlobResourceContents { @@ -185,7 +185,7 @@ pub enum ResourceContents { mime_type: Option, blob: String, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - meta: Option, + meta: Option, }, } @@ -216,7 +216,7 @@ impl ResourceContents { self } - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { match &mut self { Self::TextResourceContents { meta: m, .. } => *m = Some(meta), Self::BlobResourceContents { meta: m, .. } => *m = Some(meta), @@ -303,7 +303,7 @@ mod tests { #[test] fn test_resource_template_with_meta() { let resource_template = - ResourceTemplate::new("file:///{path}", "template").with_meta(Meta::default()); + ResourceTemplate::new("file:///{path}", "template").with_meta(MetaObject::default()); let json = serde_json::to_value(&resource_template).unwrap(); assert!(json.get("_meta").is_some()); } diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index 7ff91099f..6a7197ae5 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -3,12 +3,19 @@ use std::borrow::Cow; use serde::{Deserialize, Serialize}; use super::{ - CustomNotification, CustomRequest, Extensions, Meta, Notification, NotificationNoParam, - Request, RequestNoParam, RequestOptionalParam, + CustomNotification, CustomRequest, Extensions, JsonObject, MetaObject, Notification, + NotificationMetaObject, NotificationNoParam, Request, RequestMetaObject, RequestNoParam, + RequestOptionalParam, }; + +/// Wire-side view of `params`: the `_meta` map plus the remaining fields. +/// +/// All metadata types are transparent wrappers over [`JsonObject`], so the +/// serde plumbing works on the raw map; call sites wrap/unwrap the typed +/// metadata ([`RequestMetaObject`] / [`NotificationMetaObject`]). #[derive(Deserialize)] struct WithMeta<'a, P> { - _meta: Option>, + _meta: Option>, #[serde(flatten)] _rest: P, } @@ -25,7 +32,7 @@ impl Serialize for WithMeta<'_, P> { serde_json::to_value(&self._rest).map_err(serde::ser::Error::custom)?; // Extract _meta from the serialized params (if it's an object containing one) - let params_meta: Option = rest_value + let params_meta: Option = rest_value .as_object_mut() .and_then(|obj| obj.remove("_meta")) .and_then(|v| serde_json::from_value(v).ok()); @@ -78,6 +85,53 @@ struct ProxyNoParam { method: M, } +/// Combine the message-specific `_meta` map with a legacy [`MetaObject`] +/// extension (inserted through the deprecated `Meta` name), so pre-3.x code +/// does not silently lose metadata on the wire. On key conflicts the +/// message-specific map wins. +fn merge_legacy_meta<'a>( + typed: Option<&'a JsonObject>, + extensions: &'a Extensions, +) -> Option> { + let legacy = extensions.get::().map(|meta| &meta.0); + match (typed, legacy) { + (Some(typed), None) => Some(Cow::Borrowed(typed)), + (None, Some(legacy)) => Some(Cow::Borrowed(legacy)), + (Some(typed), Some(legacy)) => { + let mut merged = legacy.clone(); + merged.extend(typed.clone()); + Some(Cow::Owned(merged)) + } + (None, None) => None, + } +} + +/// Borrow the request `_meta` map from extensions, if any. +fn request_meta(extensions: &Extensions) -> Option> { + let typed = extensions.get::().map(|meta| &meta.0.0); + merge_legacy_meta(typed, extensions) +} + +/// Borrow the notification `_meta` map from extensions, if any. +fn notification_meta(extensions: &Extensions) -> Option> { + let typed = extensions + .get::() + .map(|meta| &meta.0.0); + merge_legacy_meta(typed, extensions) +} + +/// Build extensions holding a typed metadata map deserialized from `params._meta`. +fn extensions_with_meta(meta: Option>) -> Extensions +where + T: From + Clone + Send + Sync + 'static, +{ + let mut extensions = Extensions::new(); + if let Some(meta) = meta { + extensions.insert(T::from(meta.into_owned())); + } + extensions +} + impl Serialize for Request where M: Serialize, @@ -87,14 +141,12 @@ where where S: serde::Serializer, { - let extensions = &self.extensions; - let _meta = extensions.get::().map(Cow::Borrowed); Proxy::serialize( &Proxy { method: &self.method, params: WithMeta { _rest: &self.params, - _meta, + _meta: request_meta(&self.extensions), }, }, serializer, @@ -112,13 +164,8 @@ where D: serde::Deserializer<'de>, { let body = Proxy::deserialize(deserializer)?; - let _meta = body.params._meta.map(|m| m.into_owned()); - let mut extensions = Extensions::new(); - if let Some(meta) = _meta { - extensions.insert(meta); - } Ok(Request { - extensions, + extensions: extensions_with_meta::(body.params._meta), method: body.method, params: body.params._rest, }) @@ -134,14 +181,12 @@ where where S: serde::Serializer, { - let extensions = &self.extensions; - let _meta = extensions.get::().map(Cow::Borrowed); Proxy::serialize( &Proxy { method: &self.method, params: WithMeta { _rest: &self.params, - _meta, + _meta: request_meta(&self.extensions), }, }, serializer, @@ -163,14 +208,10 @@ where let mut _meta = None; if let Some(body_params) = body.params { params = body_params._rest; - _meta = body_params._meta.map(|m| m.into_owned()); - } - let mut extensions = Extensions::new(); - if let Some(meta) = _meta { - extensions.insert(meta); + _meta = body_params._meta; } Ok(RequestOptionalParam { - extensions, + extensions: extensions_with_meta::(_meta), method: body.method, params, }) @@ -185,14 +226,26 @@ where where S: serde::Serializer, { - let extensions = &self.extensions; - let _meta = extensions.get::().map(Cow::Borrowed); - ProxyNoParam::serialize( - &ProxyNoParam { - method: &self.method, - }, - serializer, - ) + // Emit `params` only when metadata is present, so the wire shape of + // meta-less requests stays `{"method": ...}`. + match request_meta(&self.extensions) { + Some(_meta) => Proxy::serialize( + &Proxy { + method: &self.method, + params: WithMeta { + _meta: Some(_meta), + _rest: (), + }, + }, + serializer, + ), + None => ProxyNoParam::serialize( + &ProxyNoParam { + method: &self.method, + }, + serializer, + ), + } } } @@ -204,10 +257,10 @@ where where D: serde::Deserializer<'de>, { - let body = ProxyNoParam::<_>::deserialize(deserializer)?; - let extensions = Extensions::new(); + let body = ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; + let _meta = body.params.and_then(|params| params._meta); Ok(RequestNoParam { - extensions, + extensions: extensions_with_meta::(_meta), method: body.method, }) } @@ -222,14 +275,12 @@ where where S: serde::Serializer, { - let extensions = &self.extensions; - let _meta = extensions.get::().map(Cow::Borrowed); Proxy::serialize( &Proxy { method: &self.method, params: WithMeta { _rest: &self.params, - _meta, + _meta: notification_meta(&self.extensions), }, }, serializer, @@ -248,10 +299,7 @@ where { let body = ProxyOptionalParam::<'_, _, R>::deserialize(deserializer)?; let (_meta, params) = match body.params { - Some(with_meta) => { - let meta = with_meta._meta.map(|m| m.into_owned()); - (meta, with_meta._rest) - } + Some(with_meta) => (with_meta._meta, with_meta._rest), None => { // JSON-RPC 2.0: params is optional. Treat absent params as {}. let empty = serde_json::Value::Object(serde_json::Map::new()); @@ -259,12 +307,8 @@ where (None, r) } }; - let mut extensions = Extensions::new(); - if let Some(meta) = _meta { - extensions.insert(meta); - } Ok(Notification { - extensions, + extensions: extensions_with_meta::(_meta), method: body.method, params, }) @@ -279,14 +323,26 @@ where where S: serde::Serializer, { - let extensions = &self.extensions; - let _meta = extensions.get::().map(Cow::Borrowed); - ProxyNoParam::serialize( - &ProxyNoParam { - method: &self.method, - }, - serializer, - ) + // Emit `params` only when metadata is present, so the wire shape of + // meta-less notifications stays `{"method": ...}`. + match notification_meta(&self.extensions) { + Some(_meta) => Proxy::serialize( + &Proxy { + method: &self.method, + params: WithMeta { + _meta: Some(_meta), + _rest: (), + }, + }, + serializer, + ), + None => ProxyNoParam::serialize( + &ProxyNoParam { + method: &self.method, + }, + serializer, + ), + } } } @@ -298,10 +354,10 @@ where where D: serde::Deserializer<'de>, { - let body = ProxyNoParam::<_>::deserialize(deserializer)?; - let extensions = Extensions::new(); + let body = ProxyOptionalParam::<'_, _, Option>::deserialize(deserializer)?; + let _meta = body.params.and_then(|params| params._meta); Ok(NotificationNoParam { - extensions, + extensions: extensions_with_meta::(_meta), method: body.method, }) } @@ -312,8 +368,7 @@ impl Serialize for CustomRequest { where S: serde::Serializer, { - let extensions = &self.extensions; - let _meta = extensions.get::().map(Cow::Borrowed); + let _meta = request_meta(&self.extensions); let params = self.params.as_ref(); let params = if _meta.is_some() || params.is_some() { @@ -346,14 +401,10 @@ impl<'de> Deserialize<'de> for CustomRequest { let mut _meta = None; if let Some(body_params) = body.params { params = body_params._rest; - _meta = body_params._meta.map(|m| m.into_owned()); - } - let mut extensions = Extensions::new(); - if let Some(meta) = _meta { - extensions.insert(meta); + _meta = body_params._meta; } Ok(CustomRequest { - extensions, + extensions: extensions_with_meta::(_meta), method: body.method, params, }) @@ -365,8 +416,7 @@ impl Serialize for CustomNotification { where S: serde::Serializer, { - let extensions = &self.extensions; - let _meta = extensions.get::().map(Cow::Borrowed); + let _meta = notification_meta(&self.extensions); let params = self.params.as_ref(); let params = if _meta.is_some() || params.is_some() { @@ -399,14 +449,10 @@ impl<'de> Deserialize<'de> for CustomNotification { let mut _meta = None; if let Some(body_params) = body.params { params = body_params._rest; - _meta = body_params._meta.map(|m| m.into_owned()); - } - let mut extensions = Extensions::new(); - if let Some(meta) = _meta { - extensions.insert(meta); + _meta = body_params._meta; } Ok(CustomNotification { - extensions, + extensions: extensions_with_meta::(_meta), method: body.method, params, }) @@ -418,7 +464,8 @@ mod test { use serde_json::json; use crate::model::{ - CallToolRequest, CallToolRequestParams, CustomRequest, Extensions, ListToolsRequest, Meta, + CallToolRequest, CallToolRequestParams, CustomRequest, Extensions, InitializedNotification, + ListToolsRequest, NotificationMetaObject, PingRequest, RequestMetaObject, }; #[test] @@ -436,12 +483,12 @@ mod test { // When both extensions and params contain _meta, the output should have // a single merged _meta key (not two separate ones). let mut extensions = Extensions::new(); - let mut ext_meta = Meta::new(); - ext_meta.0.insert("traceId".to_string(), json!("abc")); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("traceId".to_string(), json!("abc")); extensions.insert(ext_meta); - let mut params_meta = Meta::new(); - params_meta.0.insert("progressToken".to_string(), json!(1)); + let mut params_meta = RequestMetaObject::new(); + params_meta.insert("progressToken".to_string(), json!(1)); let req = CallToolRequest { extensions, @@ -479,8 +526,8 @@ mod test { #[test] fn test_meta_only_from_extensions() { let mut extensions = Extensions::new(); - let mut ext_meta = Meta::new(); - ext_meta.0.insert("traceId".to_string(), json!("ext-only")); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("traceId".to_string(), json!("ext-only")); extensions.insert(ext_meta); let req = CallToolRequest { @@ -503,8 +550,8 @@ mod test { #[test] fn test_meta_only_from_params() { - let mut params_meta = Meta::new(); - params_meta.0.insert("progressToken".to_string(), json!(42)); + let mut params_meta = RequestMetaObject::new(); + params_meta.insert("progressToken".to_string(), json!(42)); let req = CallToolRequest { extensions: Extensions::new(), @@ -550,19 +597,13 @@ mod test { fn test_extensions_meta_takes_priority_on_conflict() { // When both sources have the same key, extensions should win. let mut extensions = Extensions::new(); - let mut ext_meta = Meta::new(); - ext_meta - .0 - .insert("shared_key".to_string(), json!("from_extensions")); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("shared_key".to_string(), json!("from_extensions")); extensions.insert(ext_meta); - let mut params_meta = Meta::new(); - params_meta - .0 - .insert("shared_key".to_string(), json!("from_params")); - params_meta - .0 - .insert("params_only".to_string(), json!("kept")); + let mut params_meta = RequestMetaObject::new(); + params_meta.insert("shared_key".to_string(), json!("from_params")); + params_meta.insert("params_only".to_string(), json!("kept")); let req = CallToolRequest { extensions, @@ -586,10 +627,8 @@ mod test { #[test] fn test_round_trip_preserves_meta() { let mut extensions = Extensions::new(); - let mut ext_meta = Meta::new(); - ext_meta - .0 - .insert("traceId".to_string(), json!("round-trip")); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("traceId".to_string(), json!("round-trip")); extensions.insert(ext_meta); let req = CallToolRequest { @@ -609,8 +648,8 @@ mod test { let deserialized: CallToolRequest = serde_json::from_str(&serialized).unwrap(); // Extensions should have the meta after round-trip - let meta = deserialized.extensions.get::().unwrap(); - assert_eq!(meta.0.get("traceId").unwrap(), "round-trip"); + let meta = deserialized.extensions.get::().unwrap(); + assert_eq!(meta.get("traceId").unwrap(), "round-trip"); // Params should be preserved assert_eq!(deserialized.params.name, "my_tool"); @@ -630,10 +669,8 @@ mod test { fn test_custom_request_no_duplicate_meta() { // CustomRequest uses Option as params — verify no duplicate _meta. let mut extensions = Extensions::new(); - let mut ext_meta = Meta::new(); - ext_meta - .0 - .insert("traceId".to_string(), json!("custom-ext")); + let mut ext_meta = RequestMetaObject::new(); + ext_meta.insert("traceId".to_string(), json!("custom-ext")); extensions.insert(ext_meta); let params = Some(json!({ @@ -660,4 +697,162 @@ mod test { assert_eq!(meta.get("traceId").unwrap(), "custom-ext"); assert_eq!(meta.get("progressToken").unwrap(), 99); } + + #[test] + fn test_request_no_param_meta_round_trip() { + // Ping-shaped requests must carry `params._meta` on the wire. + let mut extensions = Extensions::new(); + let mut meta = RequestMetaObject::new(); + meta.insert("traceId".to_string(), json!("ping-trace")); + extensions.insert(meta); + + let req = PingRequest { + method: Default::default(), + extensions, + }; + + let value = serde_json::to_value(&req).unwrap(); + assert_eq!(value["params"]["_meta"]["traceId"], json!("ping-trace")); + + let deserialized: PingRequest = serde_json::from_value(value).unwrap(); + let meta = deserialized + .extensions + .get::() + .expect("meta should survive the round-trip"); + assert_eq!(meta.get("traceId").unwrap(), &json!("ping-trace")); + } + + #[test] + fn test_request_no_param_without_meta_has_no_params_key() { + let req = PingRequest { + method: Default::default(), + extensions: Extensions::new(), + }; + let value = serde_json::to_value(&req).unwrap(); + assert!( + value.get("params").is_none(), + "meta-less no-param requests must keep the historical wire shape: {value}" + ); + } + + #[test] + fn test_notification_no_param_meta_round_trip() { + // Initialized-shaped notifications must carry `params._meta` on the wire. + let mut extensions = Extensions::new(); + let mut meta = NotificationMetaObject::new(); + meta.insert("traceId".to_string(), json!("init-trace")); + extensions.insert(meta); + + let notification = InitializedNotification { + method: Default::default(), + extensions, + }; + + let value = serde_json::to_value(¬ification).unwrap(); + assert_eq!(value["params"]["_meta"]["traceId"], json!("init-trace")); + + let deserialized: InitializedNotification = serde_json::from_value(value).unwrap(); + let meta = deserialized + .extensions + .get::() + .expect("meta should survive the round-trip"); + assert_eq!(meta.get("traceId").unwrap(), &json!("init-trace")); + } + + #[test] + fn test_notification_no_param_without_meta_has_no_params_key() { + let notification = InitializedNotification { + method: Default::default(), + extensions: Extensions::new(), + }; + let value = serde_json::to_value(¬ification).unwrap(); + assert!( + value.get("params").is_none(), + "meta-less no-param notifications must keep the historical wire shape: {value}" + ); + } + + #[test] + fn test_no_param_ignores_unknown_params_fields() { + // Old/foreign peers may send params without _meta; both shapes must parse. + let _req: PingRequest = + serde_json::from_value(json!({"method": "ping", "params": {}})).unwrap(); + let _req: PingRequest = + serde_json::from_value(json!({"method": "ping", "params": {"unknown": 1}})).unwrap(); + let _req: PingRequest = serde_json::from_value(json!({"method": "ping"})).unwrap(); + } + + #[test] + fn test_legacy_meta_extension_still_serializes() { + // Pre-3.x code inserts `MetaObject` into extensions through the + // deprecated `Meta` name; its metadata must not be silently dropped. + let mut extensions = Extensions::new(); + let mut legacy = crate::model::MetaObject::new(); + legacy.insert("traceId".to_string(), json!("legacy")); + extensions.insert(legacy); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: None, + task: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + assert_eq!(value["params"]["_meta"]["traceId"], json!("legacy")); + } + + #[test] + fn test_typed_meta_wins_over_legacy_extension_on_conflict() { + let mut extensions = Extensions::new(); + let mut legacy = crate::model::MetaObject::new(); + legacy.insert("shared".to_string(), json!("legacy")); + legacy.insert("legacy_only".to_string(), json!("kept")); + extensions.insert(legacy); + let mut typed = RequestMetaObject::new(); + typed.insert("shared".to_string(), json!("typed")); + extensions.insert(typed); + + let req = CallToolRequest { + extensions, + method: Default::default(), + params: CallToolRequestParams { + meta: None, + name: "my_tool".into(), + arguments: None, + task: None, + input_responses: None, + request_state: None, + }, + }; + + let value = serde_json::to_value(&req).unwrap(); + let meta = value["params"]["_meta"].as_object().unwrap(); + assert_eq!(meta.get("shared").unwrap(), "typed"); + assert_eq!(meta.get("legacy_only").unwrap(), "kept"); + } + + #[test] + fn test_arbitrary_meta_keys_round_trip_unchanged() { + let input = json!({ + "method": "tools/call", + "params": { + "_meta": { + "progressToken": 5, + "vendor.example/custom": {"nested": ["a", 1, null]}, + "another-key": true + }, + "name": "my_tool" + } + }); + let req: CallToolRequest = serde_json::from_value(input.clone()).unwrap(); + let output = serde_json::to_value(&req).unwrap(); + assert_eq!(input, output); + } } diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index 8f4934258..e57fdd872 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::Meta; +use super::MetaObject; /// Metadata for augmenting a request with task execution (spec `TaskMetadata`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] @@ -139,7 +139,7 @@ impl Task { pub struct CreateTaskResult { pub task: Task, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } impl CreateTaskResult { @@ -149,7 +149,7 @@ impl CreateTaskResult { } /// Sets the protocol-level metadata for this result. - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } @@ -165,7 +165,7 @@ impl CreateTaskResult { #[non_exhaustive] pub struct GetTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, #[serde(flatten)] pub task: Task, } @@ -222,7 +222,7 @@ impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult { #[non_exhaustive] pub struct CancelTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, #[serde(flatten)] pub task: Task, } diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index 25d9def53..ec2ad741a 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -7,7 +7,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::{Icon, JsonObject, Meta}; +use super::{Icon, JsonObject, MetaObject}; /// A tool that can be used by a model. #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -39,7 +39,7 @@ pub struct Tool { pub icons: Option>, /// Optional additional metadata for this tool #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, + pub meta: Option, } /// Per-tool task support mode as defined in the MCP specification. @@ -286,7 +286,7 @@ impl Tool { } /// Set the metadata - pub fn with_meta(mut self, meta: Meta) -> Self { + pub fn with_meta(mut self, meta: MetaObject) -> Self { self.meta = Some(meta); self } diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 2345a5e45..6a7aa53e7 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -52,8 +52,8 @@ use crate::{ error::ErrorData as McpError, model::{ CancelledNotification, CancelledNotificationParam, Extensions, GetExtensions, GetMeta, - JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Meta, - NumberOrString, ProgressToken, RequestId, + JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, + NotificationMetaObject, NumberOrString, ProgressToken, RequestId, RequestMetaObject, }, transport::{DynamicTransportError, IntoTransport, Transport}, }; @@ -109,17 +109,17 @@ impl TransferObject for T where #[allow(private_bounds, reason = "there's no the third implementation")] pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { - type Req: TransferObject + GetMeta + GetExtensions; + type Req: TransferObject + GetMeta + GetExtensions; type Resp: TransferObject; type Not: TryInto + From + TransferObject; - type PeerReq: TransferObject + GetMeta + GetExtensions; + type PeerReq: TransferObject + GetMeta + GetExtensions; type PeerResp: TransferObject; type PeerNot: TryInto + From + TransferObject - + GetMeta + + GetMeta + GetExtensions; type InitializeError; const IS_CLIENT: bool; @@ -544,7 +544,7 @@ type ProxyOutbound = mpsc::Receiver>; #[non_exhaustive] pub struct PeerRequestOptions { pub timeout: Option, - pub meta: Option, + pub meta: Option, /// Reset the request timeout when a matching progress notification is received. pub reset_timeout_on_progress: bool, /// Maximum total time to wait for the request, regardless of progress notifications. @@ -862,7 +862,7 @@ pub struct RequestContext { /// this token will be cancelled when the [`CancelledNotification`] is received. pub ct: CancellationToken, pub id: RequestId, - pub meta: Meta, + pub meta: RequestMetaObject, pub extensions: Extensions, /// An interface to fetch the remote client or server pub peer: Peer, @@ -874,7 +874,7 @@ impl RequestContext { Self { ct: CancellationToken::new(), id, - meta: Meta::default(), + meta: RequestMetaObject::default(), extensions: Extensions::default(), peer, } @@ -895,7 +895,7 @@ impl RequestContext { #[derive(Debug, Clone)] #[non_exhaustive] pub struct NotificationContext { - pub meta: Meta, + pub meta: NotificationMetaObject, pub extensions: Extensions, /// An interface to fetch the remote client or server pub peer: Peer, @@ -1171,7 +1171,7 @@ where let context_ct = request_ct.child_token(); local_ct_pool.insert(id.clone(), request_ct); let mut extensions = Extensions::new(); - let mut meta = Meta::new(); + let mut meta = RequestMetaObject::new(); // avoid clone // swap meta firstly, otherwise progress token will be lost std::mem::swap(&mut meta, request.get_meta_mut()); @@ -1226,7 +1226,7 @@ where { let service = shared_service.clone(); let mut extensions = Extensions::new(); - let mut meta = Meta::new(); + let mut meta = NotificationMetaObject::new(); // avoid clone std::mem::swap(&mut extensions, notification.extensions_mut()); std::mem::swap(&mut meta, notification.get_meta_mut()); diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 929512615..e61dea36c 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -115,11 +115,11 @@ where let mut context = NotificationContext { peer: peer.clone(), - meta: Meta::default(), + meta: NotificationMetaObject::default(), extensions: Extensions::default(), }; - if let Some(meta) = logging.extensions.get_mut::() { + if let Some(meta) = logging.extensions.get_mut::() { std::mem::swap(&mut context.meta, meta); } std::mem::swap(&mut context.extensions, &mut logging.extensions); @@ -890,7 +890,7 @@ where fn input_request_context(&self, key: &str, request: &mut T) -> RequestContext where - T: GetMeta + GetExtensions, + T: GetMeta + GetExtensions, { let mut meta = Default::default(); let mut extensions = Default::default(); diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 0c294aa49..b2bc39a70 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -125,7 +125,7 @@ async fn test_elicitation_result_serialization() { assert_eq!(deserialized.meta, None); // Test protocol-level metadata round-trips as _meta. - let meta_result = ElicitResult::new(ElicitationAction::Accept).with_meta(Meta(object!({ + let meta_result = ElicitResult::new(ElicitationAction::Accept).with_meta(MetaObject(object!({ "traceId": "elicitation-123" }))); @@ -139,7 +139,7 @@ async fn test_elicitation_result_serialization() { let deserialized: ElicitResult = serde_json::from_value(expected).unwrap(); assert_eq!( deserialized.meta, - Some(Meta(object!({ "traceId": "elicitation-123" }))) + Some(MetaObject(object!({ "traceId": "elicitation-123" }))) ); } diff --git a/crates/rmcp/tests/test_embedded_resource_meta.rs b/crates/rmcp/tests/test_embedded_resource_meta.rs index 167108e8e..5854f3f54 100644 --- a/crates/rmcp/tests/test_embedded_resource_meta.rs +++ b/crates/rmcp/tests/test_embedded_resource_meta.rs @@ -1,12 +1,12 @@ -use rmcp::model::{ContentBlock, EmbeddedResource, Meta, ResourceContents}; +use rmcp::model::{ContentBlock, EmbeddedResource, MetaObject, ResourceContents}; use serde_json::json; #[test] fn serialize_embedded_text_resource_with_meta() { - let mut resource_content_meta = Meta::new(); + let mut resource_content_meta = MetaObject::new(); resource_content_meta.insert("inner".to_string(), json!(2)); - let mut resource_meta = Meta::new(); + let mut resource_meta = MetaObject::new(); resource_meta.insert("top".to_string(), json!(1)); let content = ContentBlock::Resource( @@ -90,10 +90,10 @@ fn deserialize_embedded_text_resource_with_meta() { #[test] fn serialize_embedded_blob_resource_with_meta() { - let mut resource_content_meta = Meta::new(); + let mut resource_content_meta = MetaObject::new(); resource_content_meta.insert("blob_inner".to_string(), json!(true)); - let mut resource_meta = Meta::new(); + let mut resource_meta = MetaObject::new(); resource_meta.insert("blob_top".to_string(), json!("t")); let content = ContentBlock::Resource( diff --git a/crates/rmcp/tests/test_message_schema.rs b/crates/rmcp/tests/test_message_schema.rs index f05c263cc..2b0990d71 100644 --- a/crates/rmcp/tests/test_message_schema.rs +++ b/crates/rmcp/tests/test_message_schema.rs @@ -61,6 +61,60 @@ mod tests { ); } + /// The three metadata definitions must expose the MCP 2026-07-28 draft + /// vocabulary: `MetaObject` is an open map, `RequestMetaObject` reserves + /// `progressToken` plus the SEP-2575 keys, and `NotificationMetaObject` + /// reserves `io.modelcontextprotocol/subscriptionId`. The keys the draft + /// marks as required stay optional because rmcp generates one schema + /// shared by every supported protocol version; draft-strict validation is + /// a runtime concern (`RequestMetaObject::missing_required_keys`). + #[test] + fn test_metadata_definitions_match_draft_schema() { + let settings = SchemaSettings::draft07(); + let schema = settings + .into_generator() + .into_root_schema_for::(); + let schema = serde_json::to_value(&schema).expect("Failed to serialize schema"); + let definitions = &schema["definitions"]; + + assert_eq!( + definitions["MetaObject"], + serde_json::json!({ + "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "type": "object", + "additionalProperties": true, + }) + ); + + assert_eq!( + definitions["RequestMetaObject"], + serde_json::json!({ + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "progressToken": { "$ref": "#/definitions/ProgressToken" }, + "io.modelcontextprotocol/protocolVersion": { "type": "string" }, + "io.modelcontextprotocol/clientInfo": { "$ref": "#/definitions/Implementation" }, + "io.modelcontextprotocol/clientCapabilities": { "$ref": "#/definitions/ClientCapabilities" }, + "io.modelcontextprotocol/logLevel": { "$ref": "#/definitions/LoggingLevel" }, + }, + "additionalProperties": true, + }) + ); + + assert_eq!( + definitions["NotificationMetaObject"], + serde_json::json!({ + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { "$ref": "#/definitions/NumberOrString" }, + }, + "additionalProperties": true, + }) + ); + } + #[test] fn test_server_json_rpc_message_schema() { let settings = SchemaSettings::draft07(); diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 81a5ab250..9a5871be9 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -86,11 +86,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -128,11 +131,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "arguments": { "description": "Arguments to pass to the tool (must match the tool's input schema)", @@ -187,11 +193,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "taskId": { "type": "string" @@ -210,11 +219,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "reason": { "type": [ @@ -334,11 +346,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "argument": { "$ref": "#/definitions/ArgumentInfo" @@ -474,11 +489,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "description": "The actual content of the message (text, image, audio, tool use, or tool result)", @@ -550,11 +568,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this result.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "action": { "description": "The user's decision on how to handle the elicitation request", @@ -638,11 +659,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -727,11 +751,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "arguments": { "type": [ @@ -773,11 +800,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "taskId": { "type": "string" @@ -797,11 +827,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "taskId": { "type": "string" @@ -873,11 +906,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -952,11 +988,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "capabilities": { "description": "The capabilities this client supports (sampling, roots, etc.)", @@ -1169,11 +1208,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "roots": { "type": "array", @@ -1212,6 +1254,11 @@ "emergency" ] }, + "MetaObject": { + "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, "Notification": { "type": "object", "properties": { @@ -1257,6 +1304,16 @@ "params" ] }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, "NotificationNoParam": { "type": "object", "properties": { @@ -1294,11 +1351,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "cursor": { "type": [ @@ -1322,11 +1382,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "message": { "description": "An optional message describing the current progress.", @@ -1397,11 +1460,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "inputResponses": { "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", @@ -1641,6 +1707,28 @@ "params" ] }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, "RequestNoParam": { "type": "object", "properties": { @@ -1763,11 +1851,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this resource.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this resource.", @@ -1841,11 +1932,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "mimeType": { "type": [ @@ -1869,11 +1963,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "blob": { "type": "string" @@ -1925,11 +2022,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "name": { "type": [ @@ -2116,11 +2216,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "level": { "description": "The desired logging level", @@ -2147,11 +2250,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "uri": { "description": "The URI of the resource to subscribe to", @@ -2252,11 +2358,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "createdAt": { "description": "ISO-8601 creation timestamp.", @@ -2347,11 +2456,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -2378,11 +2490,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "type": "array", @@ -2412,11 +2527,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "id": { "type": "string" @@ -2459,11 +2577,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "uri": { "description": "The URI of the resource to unsubscribe from", diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 81a5ab250..9a5871be9 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -86,11 +86,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -128,11 +131,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "arguments": { "description": "Arguments to pass to the tool (must match the tool's input schema)", @@ -187,11 +193,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "taskId": { "type": "string" @@ -210,11 +219,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "reason": { "type": [ @@ -334,11 +346,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "argument": { "$ref": "#/definitions/ArgumentInfo" @@ -474,11 +489,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "description": "The actual content of the message (text, image, audio, tool use, or tool result)", @@ -550,11 +568,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this result.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "action": { "description": "The user's decision on how to handle the elicitation request", @@ -638,11 +659,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -727,11 +751,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "arguments": { "type": [ @@ -773,11 +800,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "taskId": { "type": "string" @@ -797,11 +827,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "taskId": { "type": "string" @@ -873,11 +906,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -952,11 +988,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "capabilities": { "description": "The capabilities this client supports (sampling, roots, etc.)", @@ -1169,11 +1208,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "roots": { "type": "array", @@ -1212,6 +1254,11 @@ "emergency" ] }, + "MetaObject": { + "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, "Notification": { "type": "object", "properties": { @@ -1257,6 +1304,16 @@ "params" ] }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, "NotificationNoParam": { "type": "object", "properties": { @@ -1294,11 +1351,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "cursor": { "type": [ @@ -1322,11 +1382,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "message": { "description": "An optional message describing the current progress.", @@ -1397,11 +1460,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "inputResponses": { "description": "Client responses to server-initiated input requests from a previous\n[`InputRequiredResult`].", @@ -1641,6 +1707,28 @@ "params" ] }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, "RequestNoParam": { "type": "object", "properties": { @@ -1763,11 +1851,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this resource.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this resource.", @@ -1841,11 +1932,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "mimeType": { "type": [ @@ -1869,11 +1963,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "blob": { "type": "string" @@ -1925,11 +2022,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "name": { "type": [ @@ -2116,11 +2216,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "level": { "description": "The desired logging level", @@ -2147,11 +2250,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "uri": { "description": "The URI of the resource to subscribe to", @@ -2252,11 +2358,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "createdAt": { "description": "ISO-8601 creation timestamp.", @@ -2347,11 +2456,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -2378,11 +2490,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "type": "array", @@ -2412,11 +2527,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "id": { "type": "string" @@ -2459,11 +2577,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "uri": { "description": "The URI of the resource to unsubscribe from", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index ddd0eaad2..ee7ecf561 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -76,11 +76,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -171,11 +174,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this result", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "description": "The content returned by the tool (text, images, etc.)", @@ -211,11 +217,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "createdAt": { "description": "ISO-8601 creation timestamp.", @@ -279,11 +288,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "reason": { "type": [ @@ -303,15 +315,89 @@ } } }, - "CompleteResult": { + "ClientCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", "type": "object", "properties": { - "_meta": { + "elicitation": { + "description": "Capability to handle elicitation requests from servers for interactive user input", + "anyOf": [ + { + "$ref": "#/definitions/ElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "experimental": { "type": [ "object", "null" ], - "additionalProperties": true + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/RootsCapabilities" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] + }, + "tasks": { + "anyOf": [ + { + "$ref": "#/definitions/TasksCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "CompleteResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "completion": { "$ref": "#/definitions/CompletionInfo" @@ -495,11 +581,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "includeContext": { "description": "How much context to include from MCP servers", @@ -608,11 +697,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "task": { "$ref": "#/definitions/Task" @@ -658,11 +750,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "message": { "type": "string" @@ -685,11 +780,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "elicitationId": { "type": "string" @@ -716,11 +814,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "message": { "type": "string" @@ -742,11 +843,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this result.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "action": { "description": "The user's decision on how to handle the elicitation request", @@ -784,6 +888,34 @@ } ] }, + "ElicitationCapability": { + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "type": "object", + "properties": { + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } + ] + } + } + }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", @@ -856,11 +988,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -935,15 +1070,31 @@ "message" ] }, - "GetPromptResult": { + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", "type": "object", "properties": { - "_meta": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", "type": [ - "object", + "boolean", "null" - ], - "additionalProperties": true + ] + } + } + }, + "GetPromptResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "description": { "type": [ @@ -979,11 +1130,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "createdAt": { "description": "ISO-8601 creation timestamp.", @@ -1100,11 +1254,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -1178,11 +1335,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "capabilities": { "description": "The capabilities this server provides (tools, resources, prompts, etc.)", @@ -1257,11 +1417,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "inputRequests": { "description": "Server-initiated requests that the client must fulfill before retrying.", @@ -1524,11 +1687,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1577,14 +1743,17 @@ ] }, "ListResourceTemplatesResult": { - "type": "object", - "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1636,11 +1805,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1697,11 +1869,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "nextCursor": { "type": [ @@ -1724,11 +1899,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1801,11 +1979,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "data": { "description": "The actual log data" @@ -1832,6 +2013,11 @@ "data" ] }, + "MetaObject": { + "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, "ModelHint": { "description": "A hint suggesting a preferred model name or family.\n\nModel hints are advisory suggestions that help clients choose appropriate\nmodels. They can be specific model names or general families like \"claude\" or \"gpt\".", "type": "object", @@ -1973,6 +2159,16 @@ "params" ] }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, "NotificationNoParam": { "type": "object", "properties": { @@ -2140,11 +2336,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "message": { "description": "An optional message describing the current progress.", @@ -2188,11 +2387,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "arguments": { "type": [ @@ -2303,11 +2505,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -2382,6 +2587,28 @@ "params" ] }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, "RequestNoParam": { "type": "object", "properties": { @@ -2410,11 +2637,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this resource.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this resource.", @@ -2488,11 +2718,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "mimeType": { "type": [ @@ -2516,11 +2749,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "blob": { "type": "string" @@ -2553,11 +2789,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this resource template.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this template.", @@ -2625,11 +2864,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "uri": { "description": "The URI of the resource that was updated", @@ -2676,6 +2918,40 @@ } ] }, + "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "SamplingContent": { "description": "Single or array content wrapper (SEP-1577).", "anyOf": [ @@ -2695,11 +2971,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "description": "The actual content of the message (text, image, audio, tool use, or tool result)", @@ -3226,11 +3505,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "createdAt": { "description": "ISO-8601 creation timestamp.", @@ -3341,11 +3623,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -3478,11 +3763,14 @@ "properties": { "_meta": { "description": "Optional additional metadata for this tool", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional additional tool information.", @@ -3658,11 +3946,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "type": "array", @@ -3692,11 +3983,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "id": { "type": "string" @@ -3848,6 +4142,10 @@ "type", "enum" ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" } } } \ No newline at end of file diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index ddd0eaad2..ee7ecf561 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -76,11 +76,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -171,11 +174,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this result", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "description": "The content returned by the tool (text, images, etc.)", @@ -211,11 +217,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "createdAt": { "description": "ISO-8601 creation timestamp.", @@ -279,11 +288,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "reason": { "type": [ @@ -303,15 +315,89 @@ } } }, - "CompleteResult": { + "ClientCapabilities": { + "title": "Builder", + "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", "type": "object", "properties": { - "_meta": { + "elicitation": { + "description": "Capability to handle elicitation requests from servers for interactive user input", + "anyOf": [ + { + "$ref": "#/definitions/ElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "experimental": { "type": [ "object", "null" ], - "additionalProperties": true + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "extensions": { + "description": "Optional MCP extensions that the client supports (SEP-1724).\nKeys are extension identifiers (e.g., `\"io.modelcontextprotocol/ui\"`),\nvalues are per-extension settings objects. An empty object indicates\nsupport with no settings.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "roots": { + "description": "Capability for filesystem roots (deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/RootsCapabilities" + }, + { + "type": "null" + } + ] + }, + "sampling": { + "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", + "anyOf": [ + { + "$ref": "#/definitions/SamplingCapability" + }, + { + "type": "null" + } + ] + }, + "tasks": { + "anyOf": [ + { + "$ref": "#/definitions/TasksCapability" + }, + { + "type": "null" + } + ] + } + } + }, + "CompleteResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "completion": { "$ref": "#/definitions/CompletionInfo" @@ -495,11 +581,14 @@ "properties": { "_meta": { "description": "Protocol-level metadata for this request (SEP-1319)", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "includeContext": { "description": "How much context to include from MCP servers", @@ -608,11 +697,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "task": { "$ref": "#/definitions/Task" @@ -658,11 +750,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "message": { "type": "string" @@ -685,11 +780,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "elicitationId": { "type": "string" @@ -716,11 +814,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] }, "message": { "type": "string" @@ -742,11 +843,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this result.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "action": { "description": "The user's decision on how to handle the elicitation request", @@ -784,6 +888,34 @@ } ] }, + "ElicitationCapability": { + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "type": "object", + "properties": { + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } + ] + } + } + }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", @@ -856,11 +988,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -935,15 +1070,31 @@ "message" ] }, - "GetPromptResult": { + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.\n\nCapability for form mode elicitation.", "type": "object", "properties": { - "_meta": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", "type": [ - "object", + "boolean", "null" - ], - "additionalProperties": true + ] + } + } + }, + "GetPromptResult": { + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "description": { "type": [ @@ -979,11 +1130,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "createdAt": { "description": "ISO-8601 creation timestamp.", @@ -1100,11 +1254,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -1178,11 +1335,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "capabilities": { "description": "The capabilities this server provides (tools, resources, prompts, etc.)", @@ -1257,11 +1417,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "inputRequests": { "description": "Server-initiated requests that the client must fulfill before retrying.", @@ -1524,11 +1687,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1577,14 +1743,17 @@ ] }, "ListResourceTemplatesResult": { - "type": "object", - "properties": { - "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1636,11 +1805,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1697,11 +1869,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "nextCursor": { "type": [ @@ -1724,11 +1899,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1801,11 +1979,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "data": { "description": "The actual log data" @@ -1832,6 +2013,11 @@ "data" ] }, + "MetaObject": { + "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "type": "object", + "additionalProperties": true + }, "ModelHint": { "description": "A hint suggesting a preferred model name or family.\n\nModel hints are advisory suggestions that help clients choose appropriate\nmodels. They can be specific model names or general families like \"claude\" or \"gpt\".", "type": "object", @@ -1973,6 +2159,16 @@ "params" ] }, + "NotificationMetaObject": { + "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true + }, "NotificationNoParam": { "type": "object", "properties": { @@ -2140,11 +2336,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "message": { "description": "An optional message describing the current progress.", @@ -2188,11 +2387,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "arguments": { "type": [ @@ -2303,11 +2505,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "cacheScope": { "description": "Scope describing who may cache this result (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -2382,6 +2587,28 @@ "params" ] }, + "RequestMetaObject": { + "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true + }, "RequestNoParam": { "type": "object", "properties": { @@ -2410,11 +2637,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this resource.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this resource.", @@ -2488,11 +2718,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "mimeType": { "type": [ @@ -2516,11 +2749,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "blob": { "type": "string" @@ -2553,11 +2789,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this resource template.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this template.", @@ -2625,11 +2864,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "uri": { "description": "The URI of the resource that was updated", @@ -2676,6 +2918,40 @@ } ] }, + "RootsCapabilities": { + "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee .", + "type": "object", + "properties": { + "listChanged": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "SamplingCapability": { + "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee .", + "type": "object", + "properties": { + "context": { + "description": "Support for `includeContext` (soft-deprecated)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "tools": { + "description": "Support for `tools` and `toolChoice` parameters", + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + } + }, "SamplingContent": { "description": "Single or array content wrapper (SEP-1577).", "anyOf": [ @@ -2695,11 +2971,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "description": "The actual content of the message (text, image, audio, tool use, or tool result)", @@ -3226,11 +3505,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/NotificationMetaObject" + }, + { + "type": "null" + } + ] }, "createdAt": { "description": "ISO-8601 creation timestamp.", @@ -3341,11 +3623,14 @@ "properties": { "_meta": { "description": "Optional protocol-level metadata for this content block.", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional annotations describing how the client should use this content.", @@ -3478,11 +3763,14 @@ "properties": { "_meta": { "description": "Optional additional metadata for this tool", - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "annotations": { "description": "Optional additional tool information.", @@ -3658,11 +3946,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "content": { "type": "array", @@ -3692,11 +3983,14 @@ "type": "object", "properties": { "_meta": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] }, "id": { "type": "string" @@ -3848,6 +4142,10 @@ "type", "enum" ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" } } } \ No newline at end of file diff --git a/crates/rmcp/tests/test_meta_helpers.rs b/crates/rmcp/tests/test_meta_helpers.rs index a22a420f3..a45c6a4e2 100644 --- a/crates/rmcp/tests/test_meta_helpers.rs +++ b/crates/rmcp/tests/test_meta_helpers.rs @@ -1,6 +1,8 @@ #![allow(deprecated)] -use rmcp::model::{ClientCapabilities, Implementation, LoggingLevel, Meta, ProtocolVersion}; +use rmcp::model::{ + ClientCapabilities, Implementation, LoggingLevel, ProtocolVersion, RequestMetaObject, +}; use serde_json::json; const META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; @@ -10,7 +12,7 @@ const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; #[test] fn meta_setters_store_sep_2575_values() { - let mut meta = Meta::new(); + let mut meta = RequestMetaObject::new(); meta.set_protocol_version(ProtocolVersion::V_2026_07_28); meta.set_client_info(Implementation::new("test-client", "1.0.0")); meta.set_client_capabilities(ClientCapabilities::default()); @@ -30,7 +32,7 @@ fn meta_setters_store_sep_2575_values() { #[test] fn meta_accessors_decode_wire_values() { - let meta: Meta = serde_json::from_value(json!({ + let meta: RequestMetaObject = serde_json::from_value(json!({ "progressToken": "progress-1", "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { @@ -58,7 +60,7 @@ fn meta_accessors_decode_wire_values() { #[test] fn meta_accessors_ignore_missing_or_malformed_values() { - let meta: Meta = serde_json::from_value(json!({ + let meta: RequestMetaObject = serde_json::from_value(json!({ "io.modelcontextprotocol/protocolVersion": 20260728, "io.modelcontextprotocol/clientInfo": "not an implementation", "io.modelcontextprotocol/clientCapabilities": "not capabilities", diff --git a/crates/rmcp/tests/test_progress_subscriber.rs b/crates/rmcp/tests/test_progress_subscriber.rs index 18f91c218..8df1b7cad 100644 --- a/crates/rmcp/tests/test_progress_subscriber.rs +++ b/crates/rmcp/tests/test_progress_subscriber.rs @@ -3,7 +3,9 @@ use futures::StreamExt; use rmcp::{ ClientHandler, Peer, RoleServer, ServerHandler, ServiceExt, handler::{client::progress::ProgressDispatcher, server::tool::ToolRouter}, - model::{CallToolRequestParams, ClientRequest, Meta, ProgressNotificationParam, Request}, + model::{ + CallToolRequestParams, ClientRequest, ProgressNotificationParam, Request, RequestMetaObject, + }, service::PeerRequestOptions, tool, tool_handler, tool_router, }; @@ -61,7 +63,7 @@ impl Default for MyServer { impl MyServer { #[tool] pub async fn some_progress( - meta: Meta, + meta: RequestMetaObject, client: Peer, ) -> Result<(), rmcp::ErrorData> { let progress_token = meta diff --git a/crates/rmcp/tests/test_request_timeout_progress.rs b/crates/rmcp/tests/test_request_timeout_progress.rs index 6eeac42e9..f37101f8e 100644 --- a/crates/rmcp/tests/test_request_timeout_progress.rs +++ b/crates/rmcp/tests/test_request_timeout_progress.rs @@ -11,8 +11,8 @@ use std::{ use rmcp::{ ClientHandler, Peer, RoleServer, ServiceError, ServiceExt, model::{ - CallToolRequestParams, ClientRequest, Meta, NumberOrString, ProgressNotificationParam, - ProgressToken, Request, + CallToolRequestParams, ClientRequest, NumberOrString, ProgressNotificationParam, + ProgressToken, Request, RequestMetaObject, }, service::PeerRequestOptions, tool, tool_router, @@ -46,7 +46,7 @@ impl ProgressTimeoutServer { #[tool] async fn delayed_with_progress( &self, - meta: Meta, + meta: RequestMetaObject, client: Peer, ) -> Result<(), rmcp::ErrorData> { let progress_token = meta @@ -173,7 +173,7 @@ async fn generated_progress_token_overrides_option_meta_token() -> anyhow::Resul let client = start_pair().await?; let mut options = PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(); - options.meta = Some(Meta::with_progress_token(ProgressToken( + options.meta = Some(RequestMetaObject::with_progress_token(ProgressToken( NumberOrString::Number(999_999), ))); diff --git a/crates/rmcp/tests/test_tool_result_meta.rs b/crates/rmcp/tests/test_tool_result_meta.rs index d164e843e..37585ac76 100644 --- a/crates/rmcp/tests/test_tool_result_meta.rs +++ b/crates/rmcp/tests/test_tool_result_meta.rs @@ -1,10 +1,10 @@ -use rmcp::model::{CallToolResult, ContentBlock, Meta}; +use rmcp::model::{CallToolResult, ContentBlock, MetaObject}; use serde_json::{Value, json}; #[test] fn serialize_tool_result_with_meta() { let content = vec![ContentBlock::text("ok")]; - let mut meta = Meta::new(); + let mut meta = MetaObject::new(); meta.insert("foo".to_string(), json!("bar")); let result = CallToolResult::success(content).with_meta(Some(meta)); let v = serde_json::to_value(&result).unwrap(); diff --git a/crates/rmcp/tests/test_trace_context.rs b/crates/rmcp/tests/test_trace_context.rs index 50214b715..15354a497 100644 --- a/crates/rmcp/tests/test_trace_context.rs +++ b/crates/rmcp/tests/test_trace_context.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use rmcp::{ RoleServer, ServerHandler, ServiceExt, - model::{ClientRequest, CustomRequest, CustomResult, Meta}, + model::{ClientRequest, CustomRequest, CustomResult, RequestMetaObject}, service::{PeerRequestOptions, RequestContext}, }; use serde_json::json; @@ -17,7 +17,7 @@ const BAGGAGE: &str = "userId=alice,region=us-east-1"; /// Records the `_meta` it receives on the incoming request so the test can assert passthrough. struct TraceCapturingServer { receive_signal: Arc, - seen: Arc>>, + seen: Arc>>, } impl ServerHandler for TraceCapturingServer { @@ -56,7 +56,7 @@ async fn trace_context_meta_survives_round_trip() -> anyhow::Result<()> { let client = ().serve(client_transport).await?; // Client attaches trace context to the outgoing request's `_meta`. - let mut meta = Meta::new(); + let mut meta = RequestMetaObject::new(); meta.set_traceparent(TRACEPARENT); meta.set_tracestate(TRACESTATE); meta.set_baggage(BAGGAGE); diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index 29258a981..3cac2b2ba 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -179,7 +179,7 @@ impl Counter { /// This is an example prompt that takes one required argument, message #[prompt( name = "example_prompt", - meta = Meta(rmcp::object!({"meta_key": "meta_value"})) + meta = MetaObject(rmcp::object!({"meta_key": "meta_value"})) )] async fn example_prompt( &self, @@ -225,8 +225,8 @@ impl Counter { } } -#[tool_handler(meta = Meta(rmcp::object!({"tool_meta_key": "tool_meta_value"})))] -#[prompt_handler(meta = Meta(rmcp::object!({"router_meta_key": "router_meta_value"})))] +#[tool_handler(meta = MetaObject(rmcp::object!({"tool_meta_key": "tool_meta_value"})))] +#[prompt_handler(meta = MetaObject(rmcp::object!({"router_meta_key": "router_meta_value"})))] #[task_handler] impl ServerHandler for Counter { fn get_info(&self) -> ServerInfo { From b93220d4a741fbda75ed6d4bf5873e3bf773b5ad Mon Sep 17 00:00:00 2001 From: King Star Date: Fri, 17 Jul 2026 01:11:45 +0800 Subject: [PATCH 235/333] feat(conformance): add SEP-2243 header validation tool (#997) --- .github/workflows/conformance.yml | 10 +++- conformance/src/bin/server.rs | 50 +++++++++++++++++++ .../test_streamable_http_standard_headers.rs | 23 +++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index e57fa9729..a5c3a7636 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -34,6 +34,9 @@ jobs: - name: Build conformance binaries run: cargo build -p mcp-conformance + - name: Test conformance server + run: cargo test -p mcp-conformance --bin conformance-server + - name: Start conformance server run: | PORT=8001 ./target/debug/conformance-server & @@ -80,7 +83,12 @@ jobs: - name: Run draft SEP scenarios run: | - for scenario in sep-2164-resource-not-found caching http-header-validation; do + for scenario in \ + sep-2164-resource-not-found \ + caching \ + http-header-validation \ + http-custom-header-server-validation \ + ; do npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ --url http://127.0.0.1:8002/mcp \ --scenario "$scenario" \ diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 09bdd5e91..4df4812d6 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -28,6 +28,20 @@ fn json_object(v: Value) -> JsonObject { } } +fn custom_header_tool() -> Tool { + Tool::new( + "test_custom_header", + "Validates SEP-2243 custom parameter headers", + json_object(json!({ + "type": "object", + "properties": { + "value": { "type": "string", "x-mcp-header": "Value" } + }, + "required": ["value"] + })), + ) +} + /// Signing key for SEP-2322 `requestState` sealing. A fixed key is fine for a /// conformance harness; real servers must load a secret out of clients' reach. const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!"; @@ -361,6 +375,10 @@ impl ConformanceServer { } impl ServerHandler for ConformanceServer { + fn get_tool(&self, name: &str) -> Option { + (name == "test_custom_header").then(custom_header_tool) + } + async fn initialize( &self, request: InitializeRequestParams, @@ -521,6 +539,7 @@ impl ServerHandler for ConformanceServer { "properties": {} })), ), + custom_header_tool(), ]; // SEP-2322 MRTR test tools; all take no arguments. let mrtr_tools = [ @@ -668,6 +687,14 @@ impl ServerHandler for ConformanceServer { )])) } + "test_custom_header" => { + let value = args + .get("value") + .and_then(Value::as_str) + .ok_or_else(|| ErrorData::invalid_params("value must be a string", None))?; + Ok(CallToolResult::success(vec![ContentBlock::text(value)])) + } + "test_sampling" => { let prompt = args .get("prompt") @@ -1218,3 +1245,26 @@ async fn main() -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_exposes_custom_header_tool_for_transport_validation() { + let tool = ConformanceServer::new() + .get_tool("test_custom_header") + .expect("custom-header conformance tool"); + let value = Value::Object((*tool.input_schema).clone()); + + assert_eq!( + value.pointer("/properties/value/type"), + Some(&json!("string")) + ); + assert_eq!( + value.pointer("/properties/value/x-mcp-header"), + Some(&json!("Value")) + ); + assert_eq!(value.pointer("/required/0"), Some(&json!("value"))); + } +} diff --git a/crates/rmcp/tests/test_streamable_http_standard_headers.rs b/crates/rmcp/tests/test_streamable_http_standard_headers.rs index 5316725c0..c2c51a7c4 100644 --- a/crates/rmcp/tests/test_streamable_http_standard_headers.rs +++ b/crates/rmcp/tests/test_streamable_http_standard_headers.rs @@ -267,6 +267,29 @@ async fn rejects_param_mismatch_with_32020() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn rejects_decoded_base64_param_mismatch_with_32020() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server().await; + + let response = post_tool_call( + &client, + &url, + SEP_VERSION, + "deploy", + serde_json::json!({ "region": "us-west1" }), + Some("tools/call"), + Some("deploy"), + Some("=?base64?ZXUtY2VudHJhbDE=?="), + ) + .await; + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["error"]["code"], -32020); + + ct.cancel(); + Ok(()) +} + #[tokio::test] async fn rejects_missing_param_header_with_32020() -> anyhow::Result<()> { let (client, url, ct) = spawn_server().await; From 3549e86d28fb69debc3088fefbf3bc52dd534ff0 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:47:30 -0400 Subject: [PATCH 236/333] feat!: add server discovery and negotiation (SEP-2575) (#973) * feat!: add server discovery and negotiation * feat!: make protocol unions extensible * ci: add discovery conformance coverage --- .github/workflows/conformance.yml | 112 ++++-- crates/rmcp/src/handler/server.rs | 62 +++- crates/rmcp/src/lib.rs | 2 +- crates/rmcp/src/model.rs | 139 ++++++- crates/rmcp/src/model/meta.rs | 1 + crates/rmcp/src/service/client.rs | 51 ++- .../transport/streamable_http_server/tower.rs | 203 +++++++++- .../client_json_rpc_message_schema.json | 81 ++-- ...lient_json_rpc_message_schema_current.json | 81 ++-- .../server_json_rpc_message_schema.json | 80 ++++ ...erver_json_rpc_message_schema_current.json | 80 ++++ crates/rmcp/tests/test_server_discover.rs | 108 ++++++ .../rmcp/tests/test_server_discover_client.rs | 77 ++++ .../rmcp/tests/test_server_discover_http.rs | 346 ++++++++++++++++++ 14 files changed, 1325 insertions(+), 98 deletions(-) create mode 100644 crates/rmcp/tests/test_server_discover.rs create mode 100644 crates/rmcp/tests/test_server_discover_client.rs create mode 100644 crates/rmcp/tests/test_server_discover_http.rs diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a5c3a7636..297fad8f3 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -37,7 +37,7 @@ jobs: - name: Test conformance server run: cargo test -p mcp-conformance --bin conformance-server - - name: Start conformance server + - name: Start 2025-11-25 server run: | PORT=8001 ./target/debug/conformance-server & echo $! > server.pid @@ -50,7 +50,7 @@ jobs: echo "conformance server did not become ready" >&2 exit 1 - - name: Run server conformance suite + - name: Run 2025-11-25 server suite run: | npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ --url http://127.0.0.1:8001/mcp \ @@ -59,7 +59,7 @@ jobs: # These pass today but are excluded from the default "active" suite; # run them explicitly so regressions are still caught. - - name: Run pending scenarios + - name: Run 2025-11-25 pending scenarios run: | for scenario in json-schema-2020-12 server-sse-polling; do npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ @@ -68,7 +68,7 @@ jobs: -o conformance-results done - - name: Start draft conformance server + - name: Start draft server run: | STATELESS=1 PORT=8002 ./target/debug/conformance-server & echo $! > draft-server.pid @@ -81,27 +81,85 @@ jobs: echo "draft conformance server did not become ready" >&2 exit 1 - - name: Run draft SEP scenarios + # Run discovery separately until #985 enables the full draft suite. + - name: Run SEP-2575 discovery contract + run: | + endpoint=http://127.0.0.1:8002/mcp + common_headers=( + -H "Content-Type: application/json" + -H "Accept: application/json, text/event-stream" + -H "Mcp-Method: server/discover" + ) + + discover_response="$( + curl --fail-with-body --silent --show-error \ + "${common_headers[@]}" \ + -H "MCP-Protocol-Version: 2026-07-28" \ + --data '{ + "jsonrpc": "2.0", + "id": "discover", + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "conformance-workflow", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }' \ + "$endpoint" + )" + jq -e ' + .result.resultType == "complete" and + (.result.supportedVersions | index("2026-07-28") != null) and + (.result.capabilities | type == "object") and + (.result.serverInfo.name | type == "string") and + .result.ttlMs == 0 and + .result.cacheScope == "private" + ' <<<"$discover_response" + + status="$( + curl --silent --show-error \ + --output /tmp/unsupported-version.json \ + --write-out "%{http_code}" \ + "${common_headers[@]}" \ + -H "MCP-Protocol-Version: 2099-01-01" \ + --data '{ + "jsonrpc": "2.0", + "id": "unsupported", + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2099-01-01", + "io.modelcontextprotocol/clientInfo": { + "name": "conformance-workflow", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }' \ + "$endpoint" + )" + test "$status" = "400" + jq -e ' + .id == "unsupported" and + .error.code == -32022 and + .error.data.requested == "2099-01-01" and + (.error.data.supported | index("2026-07-28") != null) + ' /tmp/unsupported-version.json + + # Keep this explicit list until the full draft suite is enabled by #985. + - name: Run supported draft server scenarios run: | for scenario in \ sep-2164-resource-not-found \ caching \ http-header-validation \ http-custom-header-server-validation \ - ; do - npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ - --url http://127.0.0.1:8002/mcp \ - --scenario "$scenario" \ - --spec-version draft \ - -o conformance-results - done - - # SEP-2322 MRTR scenarios (spec 2026-07-28). They speak the stateless - # lifecycle (bare JSON-RPC POSTs, no initialize handshake), so they run - # against the stateless draft server. - - name: Run SEP-2322 MRTR scenarios - run: | - for scenario in \ input-required-result-basic-elicitation \ input-required-result-basic-sampling \ input-required-result-basic-list-roots \ @@ -120,17 +178,15 @@ jobs: npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ --url http://127.0.0.1:8002/mcp \ --scenario "$scenario" \ + --spec-version draft \ -o conformance-results done - - name: Stop draft conformance server + - name: Stop conformance servers if: always() - run: kill "$(cat draft-server.pid)" 2>/dev/null || true - - - - name: Stop conformance server - if: always() - run: kill "$(cat server.pid)" 2>/dev/null || true + run: | + kill "$(cat draft-server.pid)" 2>/dev/null || true + kill "$(cat server.pid)" 2>/dev/null || true - name: Upload results if: always() @@ -154,7 +210,7 @@ jobs: - name: Build conformance binaries run: cargo build -p mcp-conformance - - name: Run full client conformance suite + - name: Run 2025-11-25 client suite run: | npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \ --command "$(pwd)/target/debug/conformance-client" \ @@ -163,7 +219,7 @@ jobs: -o conformance-client-results/full # SEP-2322 MRTR client scenario (spec 2026-07-28). - - name: Run SEP-2322 MRTR client scenario + - name: Run draft SEP-2322 client scenario run: | npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ --command "$(pwd)/target/debug/conformance-client" \ diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 779901764..c4db90f37 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -1,6 +1,6 @@ // Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] -use std::sync::Arc; +use std::{borrow::Cow, sync::Arc}; use crate::{ error::ErrorData as McpError, @@ -30,11 +30,46 @@ impl Service for H { let mrtr_supported = protocol_version .as_ref() .is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); + let requested_version = context.meta.protocol_version(); + let uses_inline_negotiation = !matches!(&request, ClientRequest::InitializeRequest(_)); + if uses_inline_negotiation && let Some(requested_version) = requested_version.as_ref() { + let supported_versions = self.supported_protocol_versions(); + if !supported_versions.contains(requested_version) { + return Err(McpError::unsupported_protocol_version( + requested_version.clone(), + &supported_versions, + )); + } + } + if matches!(&request, ClientRequest::DiscoverRequest(_)) { + if requested_version.is_none() { + return Err(McpError::invalid_params( + "server/discover requires protocolVersion in request _meta", + None, + )); + } + if context.meta.client_info().is_none() { + return Err(McpError::invalid_params( + "server/discover requires clientInfo in request _meta", + None, + )); + } + if context.meta.client_capabilities().is_none() { + return Err(McpError::invalid_params( + "server/discover requires clientCapabilities in request _meta", + None, + )); + } + } let result = match request { ClientRequest::InitializeRequest(request) => self .initialize(request.params, context) .await .map(ServerResult::InitializeResult), + ClientRequest::DiscoverRequest(_request) => self + .discover(context) + .await + .map(ServerResult::DiscoverResult), ClientRequest::PingRequest(_request) => { self.ping(context).await.map(ServerResult::empty) } @@ -225,6 +260,20 @@ macro_rules! server_handler_methods { ); std::future::ready(Ok(info)) } + /// Return the protocol versions supported by this server. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } + /// Return this server's discovery information. + fn discover( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(DiscoverResult::from_server_info( + self.supported_protocol_versions().into_owned(), + self.get_info(), + ))) + } fn complete( &self, request: CompleteRequestParams, @@ -479,6 +528,17 @@ macro_rules! impl_server_handler_for_wrapper { (**self).initialize(request, context) } + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + (**self).supported_protocol_versions() + } + + fn discover( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).discover(context) + } + fn complete( &self, request: CompleteRequestParams, diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 022514c9d..78ba2e27f 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -19,7 +19,7 @@ pub use handler::server::wrapper::Json; #[cfg(any(feature = "client", feature = "server"))] pub use service::{Peer, Service, ServiceError, ServiceExt}; #[cfg(feature = "client")] -pub use service::{RoleClient, serve_client}; +pub use service::{RoleClient, select_protocol_version, serve_client}; #[cfg(feature = "server")] pub use service::{RoleServer, serve_server}; diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 366511198..8da14fe53 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -524,6 +524,10 @@ pub struct JsonRpcNotification { pub struct ErrorCode(pub i32); impl ErrorCode { + /// The request used a protocol version the server does not support. + pub const UNSUPPORTED_PROTOCOL_VERSION: Self = Self(-32022); + /// Processing the request requires a client capability that was not declared. + pub const MISSING_REQUIRED_CLIENT_CAPABILITY: Self = Self(-32021); pub const HEADER_MISMATCH: Self = Self(-32020); pub const RESOURCE_NOT_FOUND: Self = Self(-32002); pub const INVALID_REQUEST: Self = Self(-32600); @@ -573,6 +577,30 @@ impl ErrorData { pub fn header_mismatch(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::HEADER_MISMATCH, message, data) } + /// Create an unsupported-protocol-version error. + pub fn unsupported_protocol_version( + requested: ProtocolVersion, + supported: &[ProtocolVersion], + ) -> Self { + Self::new( + ErrorCode::UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + Some(serde_json::json!({ + "requested": requested, + "supported": supported, + })), + ) + } + /// Create a missing-required-capability error. + pub fn missing_required_client_capability(required: ClientCapabilities) -> Self { + Self::new( + ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY, + "Missing required client capability", + Some(serde_json::json!({ + "requiredCapabilities": required, + })), + ) + } pub fn parse_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::PARSE_ERROR, message, data) } @@ -1000,6 +1028,112 @@ impl InitializeResult { pub type ServerInfo = InitializeResult; pub type ClientInfo = InitializeRequestParams; +const_string!(DiscoverRequestMethod = "server/discover"); + +/// Parameters for [`DiscoverRequest`]. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(deny_unknown_fields)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct DiscoverRequestParams {} + +#[cfg(feature = "schemars")] +#[derive(schemars::JsonSchema)] +#[expect(dead_code, reason = "schema-only representation of request parameters")] +struct DiscoverRequestParamsSchema { + #[schemars(rename = "_meta")] + meta: RequestMetaObject, +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for DiscoverRequestParams { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("DiscoverRequestParams") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + DiscoverRequestParamsSchema::json_schema(generator) + } +} + +/// A request for the server's supported protocol versions and capabilities. +pub type DiscoverRequest = Request; + +/// The server's response to a [`DiscoverRequest`]. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct DiscoverResult { + /// Identifies how the result should be parsed. + pub result_type: ResultType, + /// Protocol versions implemented by this server. + pub supported_versions: Vec, + /// Capabilities provided by this server. + pub capabilities: ServerCapabilities, + /// Information about the server implementation. + pub server_info: Implementation, + /// Optional guidance for using the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// How long clients may consider this response fresh, in milliseconds. + pub ttl_ms: u64, + /// Whether the cached result may be shared across authorization contexts. + pub cache_scope: CacheScope, + /// Protocol-level response metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl DiscoverResult { + /// Create a non-cacheable private discovery result. + pub fn new( + supported_versions: Vec, + capabilities: ServerCapabilities, + server_info: Implementation, + ) -> Self { + Self { + result_type: ResultType::COMPLETE, + supported_versions, + capabilities, + server_info, + instructions: None, + ttl_ms: 0, + cache_scope: CacheScope::Private, + meta: None, + } + } + + /// Create a discovery result from the server's initialization information. + pub fn from_server_info( + supported_versions: Vec, + server_info: ServerInfo, + ) -> Self { + let ServerInfo { + capabilities, + server_info, + instructions, + meta, + .. + } = server_info; + let mut result = Self::new(supported_versions, capabilities, server_info); + result.instructions = instructions; + result.meta = meta; + result + } + + /// Set the cache lifetime hint in milliseconds. + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = ttl_ms; + self + } + + /// Set the cache scope. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + self.cache_scope = cache_scope; + self + } +} + #[allow(clippy::derivable_impls)] impl Default for ServerInfo { fn default() -> Self { @@ -3765,7 +3899,7 @@ macro_rules! ts_union { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] - #[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] + #[non_exhaustive] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub enum $U { $($declared)* @@ -3795,6 +3929,7 @@ ts_union!( export type ClientRequest = | PingRequest | InitializeRequest + | DiscoverRequest | CompleteRequest | SetLevelRequest | GetPromptRequest @@ -3818,6 +3953,7 @@ impl ClientRequest { match &self { ClientRequest::PingRequest(r) => r.method.as_str(), ClientRequest::InitializeRequest(r) => r.method.as_str(), + ClientRequest::DiscoverRequest(r) => r.method.as_str(), ClientRequest::CompleteRequest(r) => r.method.as_str(), ClientRequest::SetLevelRequest(r) => r.method.as_str(), ClientRequest::GetPromptRequest(r) => r.method.as_str(), @@ -3889,6 +4025,7 @@ ts_union!( ts_union!( export type ServerResult = + | DiscoverResult | InitializeResult | CompleteResult | GetPromptResult diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 53675ecc5..9b1e1b0f6 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -189,6 +189,7 @@ variant_extension! { ClientRequest: RequestMetaObject { PingRequest InitializeRequest + DiscoverRequest CompleteRequest SetLevelRequest GetPromptRequest diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index e61dea36c..d922f59f9 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -10,17 +10,19 @@ use crate::{ ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResponse, CallToolResult, CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, - CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, ErrorData, - GetExtensions, GetMeta, GetPromptRequest, GetPromptRequestParams, GetPromptResponse, - GetPromptResult, InitializeRequest, InitializedNotification, InputRequest, - InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, - ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, - ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, - NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, + CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, + DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, + GetPromptRequest, GetPromptRequestParams, GetPromptResponse, GetPromptResult, + InitializeRequest, InitializedNotification, InputRequest, InputRequiredResult, + InputResponses, JsonRpcResponse, ListPromptsRequest, ListPromptsResult, + ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest, + ListResourcesResult, ListToolsRequest, ListToolsResult, NumberOrString, + PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, - Reference, RequestId, RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, - ServerNotification, ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, - SubscribeRequest, SubscribeRequestParams, UnsubscribeRequest, UnsubscribeRequestParams, + Reference, RequestId, RequestMetaObject, RootsListChangedNotification, ServerInfo, + ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, SetLevelRequest, + SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, UnsubscribeRequest, + UnsubscribeRequestParams, }, transport::DynamicTransportError, }; @@ -147,6 +149,19 @@ where #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct RoleClient; +/// Select the first client-preferred protocol version supported by the server. +/// +/// Returns `None` when no version is shared. +pub fn select_protocol_version( + client_preference: &[ProtocolVersion], + server_supported: &[ProtocolVersion], +) -> Option { + client_preference + .iter() + .find(|version| server_supported.contains(version)) + .cloned() +} + impl ServiceRole for RoleClient { type Req = ClientRequest; type Resp = ClientResult; @@ -363,6 +378,22 @@ macro_rules! method { } impl Peer { + /// Discover the server's supported protocol versions and capabilities. + /// + /// The high-level client currently exposes this peer only after initialization; + /// pre-initialization probing is planned as follow-up work. + pub async fn discover(&self, meta: RequestMetaObject) -> Result { + let mut request = DiscoverRequest::new(DiscoverRequestParams {}); + request.extensions.insert(meta); + let result = self + .send_request(ClientRequest::DiscoverRequest(request)) + .await?; + match result { + ServerResult::DiscoverResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + /// Send one `tools/call` request and return either a final result or an MRTR /// `InputRequiredResult` without driving any follow-up rounds. pub async fn call_tool_once( diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 5d81051d0..1994d554a 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -16,10 +16,10 @@ use super::session::{ use crate::{ RoleServer, model::{ - ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, - GetExtensions, Implementation, InitializeRequest, InitializeRequestParams, - InitializedNotification, JsonObject, JsonRpcError, ProtocolVersion, RequestId, - ServerJsonRpcMessage, + ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode, + ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest, + InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, + ProtocolVersion, RequestId, ServerJsonRpcMessage, }, serve_server, service::serve_directly, @@ -199,7 +199,10 @@ impl StreamableHttpServerConfig { /// Per the MCP 2025-06-18 spec: /// - If the header is present but contains an unsupported version, return 400 Bad Request. /// - If the header is absent, assume `2025-03-26` for backwards compatibility (no error). -fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), BoxResponse> { +fn validate_protocol_version_header( + headers: &http::HeaderMap, + allow_unknown: bool, +) -> Result<(), BoxResponse> { if let Some(value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) { let version_str = value.to_str().map_err(|_| { Response::builder() @@ -215,7 +218,7 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box let is_known = ProtocolVersion::KNOWN_VERSIONS .iter() .any(|v| v.as_str() == version_str); - if !is_known { + if !allow_unknown && !is_known { return Err(Response::builder() .status(http::StatusCode::BAD_REQUEST) .body( @@ -230,6 +233,15 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box Ok(()) } +fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> bool { + match message { + ClientJsonRpcMessage::Request(request) => { + request.request.get_meta().protocol_version().is_some() + } + _ => false, + } +} + fn invalid_request_jsonrpc_response( id: Option, message: impl Into>, @@ -243,6 +255,19 @@ fn invalid_request_jsonrpc_response( .expect("valid response") } +fn invalid_params_jsonrpc_response( + id: Option, + message: impl Into>, +) -> BoxResponse { + let err = JsonRpcError::new(id, ErrorData::invalid_params(message, None)); + let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response") +} + #[expect( clippy::result_large_err, reason = "BoxResponse is intentionally large; matches other handlers in this file" @@ -278,6 +303,83 @@ fn validate_header_matches_init_body( Ok(()) } +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_request_protocol_version_meta( + headers: &HeaderMap, + message: &ClientJsonRpcMessage, +) -> Result<(), BoxResponse> { + let ClientJsonRpcMessage::Request(request) = message else { + return Ok(()); + }; + if matches!(&request.request, ClientRequest::InitializeRequest(_)) { + return Ok(()); + } + let is_discover = matches!(&request.request, ClientRequest::DiscoverRequest(_)); + let Some(meta_version) = request.request.get_meta().protocol_version() else { + if is_discover { + return Err(invalid_params_jsonrpc_response( + Some(request.id.clone()), + "Invalid params: server/discover requires protocolVersion in request _meta", + )); + } + return Ok(()); + }; + let Some(header_version) = headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + else { + return Err(invalid_request_jsonrpc_response( + Some(request.id.clone()), + "Invalid Request: request _meta protocolVersion requires MCP-Protocol-Version header", + )); + }; + if header_version != meta_version.as_str() { + return Err(header_mismatch_jsonrpc_response( + Some(request.id.clone()), + format!( + "MCP-Protocol-Version header ({header_version}) does not match request _meta protocolVersion ({meta_version})" + ), + )); + } + Ok(()) +} + +fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { + let ServerJsonRpcMessage::Error(error) = message else { + return http::StatusCode::OK; + }; + // Modern per-request HTTP treats invalid params as a malformed request. + // Legacy requests bypass this mapper and retain HTTP 200 JSON-RPC errors. + match error.error.code { + ErrorCode::UNSUPPORTED_PROTOCOL_VERSION + | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY + | ErrorCode::INVALID_PARAMS => http::StatusCode::BAD_REQUEST, + ErrorCode::METHOD_NOT_FOUND => http::StatusCode::NOT_FOUND, + _ => http::StatusCode::OK, + } +} + +fn jsonrpc_message_response( + message: ServerJsonRpcMessage, + map_protocol_status: bool, +) -> Result { + let status = if map_protocol_status { + jsonrpc_http_status(&message) + } else { + http::StatusCode::OK + }; + let body = + serde_json::to_vec(&message).map_err(internal_error_response("serialize json response"))?; + Ok(Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Full::new(Bytes::from(body)).boxed()) + .expect("valid response")) +} + fn header_mismatch_jsonrpc_response( id: Option, message: impl Into>, @@ -739,6 +841,51 @@ where (self.service_factory)() } + // The HTTP status must be known before opening an SSE stream. + async fn serve_negotiated_request_directly( + &self, + service: S, + mut request: crate::model::JsonRpcRequest, + parts: http::request::Parts, + ) -> Result { + let peer_info = Self::peer_info_for_stateless_request(&request, &parts.headers); + request.request.extensions_mut().insert(parts); + let (transport, mut receiver) = + OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); + let service = serve_directly(service, transport, peer_info); + tokio::spawn(async move { + let _ = service.waiting().await; + }); + + let cancel = self.config.cancellation_token.child_token(); + let first = tokio::select! { + message = receiver.recv() => message, + _ = cancel.cancelled() => None, + } + .ok_or_else(|| { + internal_error_response("empty response")(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "no response message received from handler", + )) + })?; + + if self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK { + return jsonrpc_message_response(first, true); + } + + let stream = futures::stream::once(async move { first }) + .chain(ReceiverStream::new(receiver)) + .map(|message| { + tracing::trace!(?message); + ServerSseMessage::from_message(message) + }); + Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )) + } + /// Returns the cached input schema for `name`, constructing a service once /// per name to read its `ServerHandler::get_tool` definition. Used to /// validate SEP-2243 `Mcp-Param-*` headers against the request body. @@ -1061,7 +1208,7 @@ where } } // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(&parts.headers)?; + validate_protocol_version_header(&parts.headers, false)?; // check if last event id is provided let last_event_id = parts .headers @@ -1191,7 +1338,9 @@ where } // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(&part.headers)?; + let has_per_request_version = message_has_per_request_protocol_version(&message); + validate_protocol_version_header(&part.headers, has_per_request_version)?; + validate_request_protocol_version_meta(&part.headers, &message)?; // Validate SEP-2243 standard headers against the body validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; @@ -1236,6 +1385,29 @@ where } } } else { + if matches!( + &message, + ClientJsonRpcMessage::Request(request) + if matches!(&request.request, ClientRequest::DiscoverRequest(_)) + ) { + validate_protocol_version_header( + &part.headers, + message_has_per_request_protocol_version(&message), + )?; + validate_standard_headers(&part.headers, &message, |name| { + self.tool_schema(name) + })?; + validate_request_protocol_version_meta(&part.headers, &message)?; + let ClientJsonRpcMessage::Request(request) = message else { + unreachable!("guarded as a request above"); + }; + let service = self + .get_service() + .map_err(internal_error_response("get service"))?; + return self + .serve_negotiated_request_directly(service, request, part) + .await; + } // Capture init params for external store persistence before // extensions are injected (which would require Clone). let stored_init_params = match &mut message { @@ -1330,6 +1502,7 @@ where // Stateless mode: // - on initialize: the header (if present) must match `params.protocolVersion` // - on every other request: the header must name a known version. + let has_per_request_version = message_has_per_request_protocol_version(&message); match &message { ClientJsonRpcMessage::Request(req) => { if let ClientRequest::InitializeRequest(init_req) = &req.request { @@ -1339,20 +1512,28 @@ where Some(req.id.clone()), )?; } else { - validate_protocol_version_header(&part.headers)?; + validate_protocol_version_header(&part.headers, has_per_request_version)?; } } _ => { - validate_protocol_version_header(&part.headers)?; + validate_protocol_version_header(&part.headers, has_per_request_version)?; } } // Validate SEP-2243 standard headers against the body validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; + validate_request_protocol_version_meta(&part.headers, &message)?; let service = self .get_service() .map_err(internal_error_response("get service"))?; match message { ClientJsonRpcMessage::Request(mut request) => { + let negotiates_per_request = has_per_request_version + || matches!(&request.request, ClientRequest::DiscoverRequest(_)); + if negotiates_per_request { + return self + .serve_negotiated_request_directly(service, request, part) + .await; + } // Build a peer_info so context.protocol_version() works inside handlers. // serve_directly skips the handshake and receives None by default, making // protocol_version() always return None in stateless mode. We reconstruct it: @@ -1453,7 +1634,7 @@ where .expect("valid response")); }; // Validate MCP-Protocol-Version header (per 2025-06-18 spec) - validate_protocol_version_header(request.headers())?; + validate_protocol_version_header(request.headers(), false)?; // close session self.session_manager .close_session(&session_id) diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 9a5871be9..15e6e2945 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -562,6 +562,22 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "DiscoverRequestMethod": { + "type": "string", + "format": "const", + "const": "server/discover" + }, + "DiscoverRequestParams": { + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/RequestMetaObject" + } + }, + "required": [ + "_meta" + ] + }, "ElicitResult": { "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", "type": "object", @@ -1120,6 +1136,9 @@ { "$ref": "#/definitions/Request4" }, + { + "$ref": "#/definitions/Request5" + }, { "$ref": "#/definitions/RequestOptionalParam" }, @@ -1129,9 +1148,6 @@ { "$ref": "#/definitions/RequestOptionalParam3" }, - { - "$ref": "#/definitions/Request5" - }, { "$ref": "#/definitions/Request6" }, @@ -1141,20 +1157,23 @@ { "$ref": "#/definitions/Request8" }, + { + "$ref": "#/definitions/Request9" + }, { "$ref": "#/definitions/RequestOptionalParam4" }, { - "$ref": "#/definitions/Request9" + "$ref": "#/definitions/Request10" }, { "$ref": "#/definitions/RequestOptionalParam5" }, { - "$ref": "#/definitions/Request10" + "$ref": "#/definitions/Request11" }, { - "$ref": "#/definitions/Request11" + "$ref": "#/definitions/Request12" }, { "$ref": "#/definitions/CustomRequest" @@ -1548,6 +1567,22 @@ ] }, "Request10": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request11": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1563,7 +1598,7 @@ "params" ] }, - "Request11": { + "Request12": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1584,10 +1619,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CompleteRequestMethod" + "$ref": "#/definitions/DiscoverRequestMethod" }, "params": { - "$ref": "#/definitions/CompleteRequestParams" + "$ref": "#/definitions/DiscoverRequestParams" } }, "required": [ @@ -1600,10 +1635,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SetLevelRequestMethod" + "$ref": "#/definitions/CompleteRequestMethod" }, "params": { - "$ref": "#/definitions/SetLevelRequestParams" + "$ref": "#/definitions/CompleteRequestParams" } }, "required": [ @@ -1616,10 +1651,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetPromptRequestMethod" + "$ref": "#/definitions/SetLevelRequestMethod" }, "params": { - "$ref": "#/definitions/GetPromptRequestParams" + "$ref": "#/definitions/SetLevelRequestParams" } }, "required": [ @@ -1632,10 +1667,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/ReadResourceRequestMethod" + "$ref": "#/definitions/GetPromptRequestMethod" }, "params": { - "$ref": "#/definitions/ReadResourceRequestParams" + "$ref": "#/definitions/GetPromptRequestParams" } }, "required": [ @@ -1648,10 +1683,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SubscribeRequestMethod" + "$ref": "#/definitions/ReadResourceRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParams" + "$ref": "#/definitions/ReadResourceRequestParams" } }, "required": [ @@ -1664,10 +1699,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" + "$ref": "#/definitions/SubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" + "$ref": "#/definitions/SubscribeRequestParams" } }, "required": [ @@ -1680,10 +1715,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CallToolRequestMethod" + "$ref": "#/definitions/UnsubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/CallToolRequestParams" + "$ref": "#/definitions/UnsubscribeRequestParams" } }, "required": [ @@ -1696,10 +1731,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskMethod" + "$ref": "#/definitions/CallToolRequestMethod" }, "params": { - "$ref": "#/definitions/GetTaskParams" + "$ref": "#/definitions/CallToolRequestParams" } }, "required": [ diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 9a5871be9..15e6e2945 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -562,6 +562,22 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "DiscoverRequestMethod": { + "type": "string", + "format": "const", + "const": "server/discover" + }, + "DiscoverRequestParams": { + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/RequestMetaObject" + } + }, + "required": [ + "_meta" + ] + }, "ElicitResult": { "description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.", "type": "object", @@ -1120,6 +1136,9 @@ { "$ref": "#/definitions/Request4" }, + { + "$ref": "#/definitions/Request5" + }, { "$ref": "#/definitions/RequestOptionalParam" }, @@ -1129,9 +1148,6 @@ { "$ref": "#/definitions/RequestOptionalParam3" }, - { - "$ref": "#/definitions/Request5" - }, { "$ref": "#/definitions/Request6" }, @@ -1141,20 +1157,23 @@ { "$ref": "#/definitions/Request8" }, + { + "$ref": "#/definitions/Request9" + }, { "$ref": "#/definitions/RequestOptionalParam4" }, { - "$ref": "#/definitions/Request9" + "$ref": "#/definitions/Request10" }, { "$ref": "#/definitions/RequestOptionalParam5" }, { - "$ref": "#/definitions/Request10" + "$ref": "#/definitions/Request11" }, { - "$ref": "#/definitions/Request11" + "$ref": "#/definitions/Request12" }, { "$ref": "#/definitions/CustomRequest" @@ -1548,6 +1567,22 @@ ] }, "Request10": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request11": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1563,7 +1598,7 @@ "params" ] }, - "Request11": { + "Request12": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1584,10 +1619,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CompleteRequestMethod" + "$ref": "#/definitions/DiscoverRequestMethod" }, "params": { - "$ref": "#/definitions/CompleteRequestParams" + "$ref": "#/definitions/DiscoverRequestParams" } }, "required": [ @@ -1600,10 +1635,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SetLevelRequestMethod" + "$ref": "#/definitions/CompleteRequestMethod" }, "params": { - "$ref": "#/definitions/SetLevelRequestParams" + "$ref": "#/definitions/CompleteRequestParams" } }, "required": [ @@ -1616,10 +1651,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetPromptRequestMethod" + "$ref": "#/definitions/SetLevelRequestMethod" }, "params": { - "$ref": "#/definitions/GetPromptRequestParams" + "$ref": "#/definitions/SetLevelRequestParams" } }, "required": [ @@ -1632,10 +1667,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/ReadResourceRequestMethod" + "$ref": "#/definitions/GetPromptRequestMethod" }, "params": { - "$ref": "#/definitions/ReadResourceRequestParams" + "$ref": "#/definitions/GetPromptRequestParams" } }, "required": [ @@ -1648,10 +1683,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SubscribeRequestMethod" + "$ref": "#/definitions/ReadResourceRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParams" + "$ref": "#/definitions/ReadResourceRequestParams" } }, "required": [ @@ -1664,10 +1699,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" + "$ref": "#/definitions/SubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" + "$ref": "#/definitions/SubscribeRequestParams" } }, "required": [ @@ -1680,10 +1715,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CallToolRequestMethod" + "$ref": "#/definitions/UnsubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/CallToolRequestParams" + "$ref": "#/definitions/UnsubscribeRequestParams" } }, "required": [ @@ -1696,10 +1731,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskMethod" + "$ref": "#/definitions/CallToolRequestMethod" }, "params": { - "$ref": "#/definitions/GetTaskParams" + "$ref": "#/definitions/CallToolRequestParams" } }, "required": [ diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index ee7ecf561..4cf0a0baf 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -743,6 +743,83 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "DiscoverResult": { + "description": "The server's response to a [`DiscoverRequest`].", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level response metadata.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Whether the cached result may be shared across authorization contexts.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ] + }, + "capabilities": { + "description": "Capabilities provided by this server.", + "allOf": [ + { + "$ref": "#/definitions/ServerCapabilities" + } + ] + }, + "instructions": { + "description": "Optional guidance for using the server.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Identifies how the result should be parsed.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "serverInfo": { + "description": "Information about the server implementation.", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, + "supportedVersions": { + "description": "Protocol versions implemented by this server.", + "type": "array", + "items": { + "$ref": "#/definitions/ProtocolVersion" + } + }, + "ttlMs": { + "description": "How long clients may consider this response fresh, in milliseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resultType", + "supportedVersions", + "capabilities", + "serverInfo", + "ttlMs", + "cacheScope" + ] + }, "ElicitRequestParams": { "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", "anyOf": [ @@ -3194,6 +3271,9 @@ }, "ServerResult": { "anyOf": [ + { + "$ref": "#/definitions/DiscoverResult" + }, { "$ref": "#/definitions/InitializeResult" }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index ee7ecf561..4cf0a0baf 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -743,6 +743,83 @@ "CustomResult": { "description": "A catch-all response either side can use for custom requests." }, + "DiscoverResult": { + "description": "The server's response to a [`DiscoverRequest`].", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level response metadata.", + "anyOf": [ + { + "$ref": "#/definitions/MetaObject" + }, + { + "type": "null" + } + ] + }, + "cacheScope": { + "description": "Whether the cached result may be shared across authorization contexts.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ] + }, + "capabilities": { + "description": "Capabilities provided by this server.", + "allOf": [ + { + "$ref": "#/definitions/ServerCapabilities" + } + ] + }, + "instructions": { + "description": "Optional guidance for using the server.", + "type": [ + "string", + "null" + ] + }, + "resultType": { + "description": "Identifies how the result should be parsed.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "serverInfo": { + "description": "Information about the server implementation.", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, + "supportedVersions": { + "description": "Protocol versions implemented by this server.", + "type": "array", + "items": { + "$ref": "#/definitions/ProtocolVersion" + } + }, + "ttlMs": { + "description": "How long clients may consider this response fresh, in milliseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "resultType", + "supportedVersions", + "capabilities", + "serverInfo", + "ttlMs", + "cacheScope" + ] + }, "ElicitRequestParams": { "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = ElicitRequestParams::FormElicitationParams {\n meta: None,\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = ElicitRequestParams::UrlElicitationParams {\n meta: None,\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", "anyOf": [ @@ -3194,6 +3271,9 @@ }, "ServerResult": { "anyOf": [ + { + "$ref": "#/definitions/DiscoverResult" + }, { "$ref": "#/definitions/InitializeResult" }, diff --git a/crates/rmcp/tests/test_server_discover.rs b/crates/rmcp/tests/test_server_discover.rs new file mode 100644 index 000000000..3f689b988 --- /dev/null +++ b/crates/rmcp/tests/test_server_discover.rs @@ -0,0 +1,108 @@ +use rmcp::model::{ + ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, + JsonRpcRequest, JsonRpcResponse, ProtocolVersion, ServerJsonRpcMessage, ServerResult, +}; +use serde_json::json; + +#[test] +fn discover_request_deserializes_with_request_meta() { + let message: ClientJsonRpcMessage = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test-client", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })) + .expect("discover request should deserialize"); + + let ClientJsonRpcMessage::Request(JsonRpcRequest { request, .. }) = message else { + panic!("expected request"); + }; + let ClientRequest::DiscoverRequest(request) = request else { + panic!("expected discover request"); + }; + + assert_eq!( + request + .extensions + .get::() + .and_then(|meta| meta.protocol_version()), + Some(ProtocolVersion::V_2026_07_28) + ); +} + +#[test] +fn discover_result_deserializes_to_typed_variant() { + let message: ServerJsonRpcMessage = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "resultType": "complete", + "supportedVersions": ["2025-11-25", "2026-07-28"], + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + }, + "ttlMs": 0, + "cacheScope": "private" + } + })) + .expect("discover result should deserialize"); + + let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else { + panic!("expected response"); + }; + let ServerResult::DiscoverResult(DiscoverResult { + supported_versions, .. + }) = result + else { + panic!("expected discover result"); + }; + + assert_eq!( + supported_versions, + vec![ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28] + ); +} + +#[test] +fn unsupported_protocol_version_error_matches_draft_schema() { + let error = ErrorData::unsupported_protocol_version( + ProtocolVersion::V_2026_07_28, + &[ProtocolVersion::V_2025_11_25], + ); + + assert_eq!(error.code, ErrorCode::UNSUPPORTED_PROTOCOL_VERSION); + assert_eq!( + error.data, + Some(json!({ + "requested": "2026-07-28", + "supported": ["2025-11-25"] + })) + ); +} + +#[test] +fn missing_required_capability_error_matches_draft_schema() { + let required = ClientCapabilities::builder().enable_elicitation().build(); + let error = ErrorData::missing_required_client_capability(required); + + assert_eq!(error.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + assert_eq!( + error.data, + Some(json!({ + "requiredCapabilities": { + "elicitation": {} + } + })) + ); +} diff --git a/crates/rmcp/tests/test_server_discover_client.rs b/crates/rmcp/tests/test_server_discover_client.rs new file mode 100644 index 000000000..adbe577ed --- /dev/null +++ b/crates/rmcp/tests/test_server_discover_client.rs @@ -0,0 +1,77 @@ +#![cfg(all(feature = "client", not(feature = "local")))] + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::{ + ClientCapabilities, Implementation, ProtocolVersion, RequestMetaObject, ServerCapabilities, + ServerInfo, + }, + select_protocol_version, +}; + +#[derive(Clone, Default)] +struct DiscoveryServer; + +impl ServerHandler for DiscoveryServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("discovery-server", "1.0.0")) + } +} + +#[derive(Clone, Default)] +struct DiscoveryClient; + +impl ClientHandler for DiscoveryClient {} + +#[test] +fn select_protocol_version_uses_client_preference_order() { + let selected = select_protocol_version( + &[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25], + &[ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28], + ); + + assert_eq!(selected, Some(ProtocolVersion::V_2026_07_28)); +} + +#[test] +fn select_protocol_version_returns_none_without_overlap() { + let selected = select_protocol_version( + &[ProtocolVersion::V_2026_07_28], + &[ProtocolVersion::V_2025_11_25], + ); + + assert_eq!(selected, None); +} + +#[tokio::test] +async fn client_discover_helper_returns_typed_result() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + tokio::spawn(async move { + let _ = DiscoveryServer + .serve(server_transport) + .await + .expect("server should start") + .waiting() + .await; + }); + let client = DiscoveryClient + .serve(client_transport) + .await + .expect("client should connect"); + let mut meta = RequestMetaObject::new(); + meta.set_protocol_version(ProtocolVersion::V_2026_07_28); + meta.set_client_info(Implementation::new("discovery-client", "1.0.0")); + meta.set_client_capabilities(ClientCapabilities::default()); + + let result = client + .discover(meta) + .await + .expect("discover should succeed"); + + assert_eq!( + result.server_info, + Implementation::new("discovery-server", "1.0.0") + ); + client.cancel().await.expect("client should cancel"); +} diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs new file mode 100644 index 000000000..ad9a16d0a --- /dev/null +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -0,0 +1,346 @@ +#![cfg(all( + not(feature = "local"), + feature = "reqwest", + feature = "transport-streamable-http-server" +))] + +use std::borrow::Cow; + +use rmcp::{ + ServerHandler, + model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use serde_json::json; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Default)] +struct DiscoveryServer; + +impl ServerHandler for DiscoveryServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("discovery-server", "1.0.0")) + .with_instructions("Use the tools carefully") + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(&[ProtocolVersion::V_2025_11_25]) + } +} + +async fn spawn_server(json_response: bool) -> (reqwest::Client, String, CancellationToken) { + spawn_server_with_stateful_mode(json_response, false).await +} + +async fn spawn_server_with_stateful_mode( + json_response: bool, + stateful_mode: bool, +) -> (reqwest::Client, String, CancellationToken) { + let cancellation_token = CancellationToken::new(); + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(stateful_mode) + .with_json_response(json_response) + .with_sse_keep_alive(None) + .with_cancellation_token(cancellation_token.clone()); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(DiscoveryServer), Default::default(), config); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + + tokio::spawn({ + let cancellation_token = cancellation_token.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { + cancellation_token.cancelled_owned().await; + }) + .await; + } + }); + + ( + reqwest::Client::new(), + format!("http://{address}/mcp"), + cancellation_token, + ) +} + +fn discover_body(version: Option<&str>) -> serde_json::Value { + let meta = version.map(|version| { + json!({ + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { + "name": "test-client", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }) + }); + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": meta.map(|meta| json!({ "_meta": meta })).unwrap_or_else(|| json!({})) + }) +} + +async fn post_discover( + client: &reqwest::Client, + url: &str, + header_version: &str, + body_version: Option<&str>, +) -> reqwest::Response { + client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", header_version) + .header("Mcp-Method", "server/discover") + .json(&discover_body(body_version)) + .send() + .await + .expect("discover request should send") +} + +#[tokio::test] +async fn discover_returns_server_metadata_without_session() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await; + + assert_eq!(response.status(), 200); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!( + body["result"], + json!({ + "resultType": "complete", + "supportedVersions": ["2025-11-25"], + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "discovery-server", + "version": "1.0.0" + }, + "instructions": "Use the tools carefully", + "ttlMs": 0, + "cacheScope": "private" + }) + ); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_does_not_require_initialization_in_stateful_mode() { + let (client, url, cancellation_token) = spawn_server_with_stateful_mode(true, true).await; + + let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await; + + assert_eq!(response.status(), 200); + assert!(response.headers().get("Mcp-Session-Id").is_none()); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_unsupported_version_with_http_400() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await; + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32022); + assert_eq!( + body["error"]["data"], + json!({ + "requested": "2026-07-28", + "supported": ["2025-11-25"] + }) + ); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_unknown_version_with_typed_error() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2099-01-01", Some("2099-01-01")).await; + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32022); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn regular_request_rejects_server_unsupported_meta_version() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28" + } + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/list") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32022); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn unknown_rpc_uses_http_404_for_per_request_protocol() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "unknown/method", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2025-11-25" + } + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-11-25") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 404); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn legacy_unknown_rpc_preserves_http_200_jsonrpc_error() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "unknown/method", + "params": {} + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-11-25") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 200); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32601); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_header_meta_version_mismatch() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2026-07-28", Some("2025-11-25")).await; + + assert_eq!(response.status(), 400); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_missing_request_meta() { + let (client, url, cancellation_token) = spawn_server(true).await; + + let response = post_discover(&client, &url, "2025-11-25", None).await; + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_rejects_missing_client_capabilities() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2025-11-25", + "io.modelcontextprotocol/clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-11-25") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + + cancellation_token.cancel(); +} + +#[tokio::test] +async fn discover_error_uses_http_400_when_sse_is_configured() { + let (client, url, cancellation_token) = spawn_server(false).await; + + let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await; + + assert_eq!(response.status(), 400); + assert_eq!( + response + .headers() + .get("Content-Type") + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + + cancellation_token.cancel(); +} From 7824bfdada57692dbf53dbcef68a161e14ab9468 Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Thu, 16 Jul 2026 22:47:52 +0300 Subject: [PATCH 237/333] feat(auth): accumulate client-side scopes during step-up authorization (#888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEP-2350 [1] clarifies that scope accumulation is a client-side responsibility: during re-authorization the client requests the union of its previously requested scopes and the newly challenged scopes, because servers report only the scopes needed for the current operation in 403 / insufficient_scope challenges (RFC 6750 §3.1), not the union of everything granted so far. select_base_scopes returned a single source, so a 403 challenge replaced the previously requested scopes instead of widening them, dropping prior permissions across step-up rounds. I make it union the previously requested scopes, the WWW-Authenticate challenge, and the protected resource metadata scopes (RFC 9728), treating each server-reported set as an operational requirement for the current operation rather than an exclusive directive; AS metadata and caller defaults only seed the request when nothing has been requested or challenged yet. exchange_code_for_token treats an explicit scope list as authoritative, so a server may still narrow the grant. When the server omits scope it has granted exactly what the client requested (RFC 6749 §5.1), so the grant has to fall back to the scopes requested in this round, not the previously granted set; otherwise a step-up that the server confirms by omitting scope would silently drop the just-added permission and the client would loop on the same 403. The widened request was not persisted anywhere the exchange could read it, so I record it on StoredAuthorizationState per authorization (defaulting empty for states stored before this field existed) and resolve the grant from there. This addresses review feedback [2] that the earlier fallback returned the previous grant rather than the request. Deduplication preserves first-seen order for stable, testable output. Tests cover multi-round accumulation, dedup, resource-metadata unioning, and grant resolution when the response omits scope. Implements [3]. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/authorization.mdx#L682 [2]: https://github.com/modelcontextprotocol/rust-sdk/pull/888#pullrequestreview-4454854582 [3]: https://github.com/modelcontextprotocol/rust-sdk/issues/877 Signed-off-by: Stefano Amorelli --- crates/rmcp/src/transport/auth.rs | 209 ++++++++++++++++++++++++++---- 1 file changed, 183 insertions(+), 26 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index b1f505ea0..e8189f6e1 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -293,6 +293,10 @@ pub struct StoredAuthorizationState { #[serde(default)] pub require_issuer: bool, pub created_at: u64, + /// scopes requested in this round, used to resolve the grant when the token response omits + /// `scope` (RFC 6749 §5.1) + #[serde(default)] + pub requested_scopes: Vec, } impl std::fmt::Debug for StoredAuthorizationState { @@ -303,6 +307,7 @@ impl std::fmt::Debug for StoredAuthorizationState { .field("expected_issuer", &self.expected_issuer) .field("require_issuer", &self.require_issuer) .field("created_at", &self.created_at) + .field("requested_scopes", &self.requested_scopes) .finish() } } @@ -354,9 +359,16 @@ impl StoredAuthorizationState { .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0), + requested_scopes: Vec::new(), } } + /// record the scopes requested in this authorization round (SEP-2350) + pub fn with_requested_scopes(mut self, scopes: Vec) -> Self { + self.requested_scopes = scopes; + self + } + pub fn into_pkce_verifier(self) -> PkceCodeVerifier { PkceCodeVerifier::new(self.pkce_verifier) } @@ -1365,7 +1377,7 @@ impl AuthorizationManager { let (auth_url, csrf_token) = auth_request.url(); - // store pkce verifier and expected issuer for later use via state store + // store pkce verifier, expected issuer, and the requested scopes for later use via state store let expected_issuer = self .metadata .as_ref() @@ -1385,7 +1397,8 @@ impl AuthorizationManager { &csrf_token, expected_issuer, require_issuer, - ); + ) + .with_requested_scopes(scopes.iter().map(|s| s.to_string()).collect()); self.state_store .save(csrf_token.secret(), stored_state) .await?; @@ -1400,11 +1413,33 @@ impl AuthorizationManager { /// compute the union of current scopes and required scopes fn compute_scope_union(current: &[String], required: &str) -> Vec { - let mut scope_set: std::collections::HashSet = current.iter().cloned().collect(); - for scope in required.split_whitespace() { - scope_set.insert(scope.to_string()); + let mut scopes = current.to_vec(); + scopes.extend(required.split_whitespace().map(|s| s.to_string())); + Self::dedup_scopes(scopes) + } + + /// deduplicate scopes preserving first-seen order (SEP-2350: stable for testability) + fn dedup_scopes(scopes: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + scopes + .into_iter() + .filter(|s| seen.insert(s.clone())) + .collect() + } + + /// resolve the granted scope set from a token response (SEP-2350, RFC 6749 §5.1): an explicit + /// `scope` may narrow the grant; an omitted one means the request was granted in full, so fall + /// back to the requested scopes (or the previously granted set when none were recorded). + fn resolve_granted_scopes( + response_scopes: Option>, + requested_scopes: &[String], + current_scopes: &[String], + ) -> Vec { + match response_scopes { + Some(scopes) => scopes, + None if !requested_scopes.is_empty() => requested_scopes.to_vec(), + None => current_scopes.to_vec(), } - scope_set.into_iter().collect() } /// check if a scope upgrade is possible and allowed @@ -1427,35 +1462,42 @@ impl AuthorizationManager { scopes } - /// select scopes based on SEP-835 priority: - /// 1. scope from WWW-Authenticate header (argument or stored from initial 401 probe) - /// 2. scopes_supported from protected resource metadata (RFC 9728) - /// 3. scopes_supported from authorization server metadata - /// 4. provided default scopes + /// select scopes following SEP-2350: re-authorization requests the union of the + /// previously requested scopes and the newly challenged scopes. Server-reported + /// scopes (WWW-Authenticate challenge, protected resource metadata) are operational + /// requirements for the current operation, never an exclusive directive, so they + /// accumulate rather than replace. The AS metadata and caller defaults only seed the + /// request when nothing has been requested or challenged yet. fn select_base_scopes( &self, www_authenticate_scope: Option<&str>, default_scopes: &[&str], ) -> Vec { - if let Some(scope) = www_authenticate_scope { - return scope.split_whitespace().map(|s| s.to_string()).collect(); + let mut accumulated: Vec = Vec::new(); + + // previously requested scopes + if let Ok(guard) = self.current_scopes.try_read() { + accumulated.extend(guard.iter().cloned()); } - // use scopes from initial 401 WWW-Authenticate header + // newly challenged scopes for the current operation (RFC 6750 §3.1) + if let Some(scope) = www_authenticate_scope { + accumulated.extend(scope.split_whitespace().map(|s| s.to_string())); + } if let Ok(guard) = self.www_auth_scopes.try_read() { - if !guard.is_empty() { - return guard.clone(); - } + accumulated.extend(guard.iter().cloned()); } - // use scopes_supported from protected resource metadata (RFC 9728) + // scopes required for the current operation per protected resource metadata (RFC 9728) if let Ok(guard) = self.resource_scopes.try_read() { - if !guard.is_empty() { - return guard.clone(); - } + accumulated.extend(guard.iter().cloned()); + } + + if !accumulated.is_empty() { + return Self::dedup_scopes(accumulated); } - // use scopes_supported from authorization server metadata + // nothing requested or challenged yet: seed from AS metadata, then caller defaults if let Some(metadata) = &self.metadata { if let Some(scopes_supported) = &metadata.scopes_supported { if !scopes_supported.is_empty() { @@ -1595,6 +1637,9 @@ impl AuthorizationManager { Self::validate_authorization_response_issuer(&stored_state, received_issuer)?; + // capture requested scopes before the state is consumed + let requested_scopes = stored_state.requested_scopes.clone(); + // Reconstruct the PKCE verifier let pkce_verifier = stored_state.into_pkce_verifier(); @@ -1632,10 +1677,14 @@ impl AuthorizationManager { debug!("exchange token result: {:?}", token_result); - let granted_scopes: Vec = token_result + // SEP-2350: an omitted `scope` means the grant equals the request (RFC 6749 §5.1). + let response_scopes = token_result .scopes() - .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) - .unwrap_or_default(); + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()); + let granted_scopes = { + let current = self.current_scopes.read().await; + Self::resolve_granted_scopes(response_scopes, &requested_scopes, ¤t) + }; *self.current_scopes.write().await = granted_scopes.clone(); *self.scope_upgrade_attempts.write().await = 0; @@ -4283,17 +4332,27 @@ mod tests { fn test_stored_authorization_state_serialization() { let pkce = PkceCodeVerifier::new("my-verifier".to_string()); let csrf = CsrfToken::new("my-csrf".to_string()); - let state = StoredAuthorizationState::new(&pkce, &csrf); + let state = StoredAuthorizationState::new(&pkce, &csrf) + .with_requested_scopes(vec!["read".to_string(), "write".to_string()]); let json = serde_json::to_string(&state).unwrap(); let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.pkce_verifier, "my-verifier"); assert_eq!(deserialized.csrf_token, "my-csrf"); + assert_eq!(deserialized.requested_scopes, vec!["read", "write"]); assert_eq!(deserialized.expected_issuer, None); assert!(!deserialized.require_issuer); } + #[test] + fn stored_authorization_state_defaults_requested_scopes_when_absent() { + let json = r#"{"pkce_verifier":"v","csrf_token":"c","created_at":1}"#; + let state: StoredAuthorizationState = serde_json::from_str(json).unwrap(); + + assert!(state.requested_scopes.is_empty()); + } + #[test] fn test_stored_authorization_state_records_expected_issuer() { let pkce = PkceCodeVerifier::new("my-verifier".to_string()); @@ -5091,6 +5150,104 @@ mod tests { assert!(scopes.contains(&"email".to_string())); } + // -- SEP-2350: client-side scope accumulation in step-up authorization -- + + #[tokio::test] + async fn select_scopes_unions_challenge_with_previously_requested() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string()]; + + let scopes = mgr.select_scopes(Some("write"), &[]); + + assert_eq!(scopes, vec!["read".to_string(), "write".to_string()]); + } + + #[tokio::test] + async fn select_scopes_does_not_replace_previously_requested_with_challenge() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string(), "profile".to_string()]; + + let scopes = mgr.select_scopes(Some("write"), &[]); + + assert!(scopes.contains(&"read".to_string())); + assert!(scopes.contains(&"profile".to_string())); + assert!(scopes.contains(&"write".to_string())); + } + + #[tokio::test] + async fn select_scopes_accumulates_across_multiple_step_up_rounds() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string()]; + + // round one: server challenges for "write" + let round_one = mgr.select_scopes(Some("write"), &[]); + assert_eq!(round_one, vec!["read".to_string(), "write".to_string()]); + *mgr.current_scopes.write().await = round_one; + + // round two: server challenges for "admin", earlier scopes are retained + let round_two = mgr.select_scopes(Some("admin"), &[]); + assert_eq!( + round_two, + vec!["read".to_string(), "write".to_string(), "admin".to_string()] + ); + } + + #[tokio::test] + async fn select_scopes_deduplicates_challenge_already_requested() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string(), "write".to_string()]; + + let scopes = mgr.select_scopes(Some("write admin"), &[]); + + assert_eq!( + scopes, + vec!["read".to_string(), "write".to_string(), "admin".to_string()] + ); + } + + #[tokio::test] + async fn select_scopes_unions_resource_metadata_as_operational_requirement() { + let mgr = manager_with_metadata(None).await; + *mgr.current_scopes.write().await = vec!["read".to_string()]; + *mgr.resource_scopes.write().await = vec!["profile".to_string()]; + + let scopes = mgr.select_scopes(Some("write"), &[]); + + assert!(scopes.contains(&"read".to_string())); + assert!(scopes.contains(&"write".to_string())); + assert!(scopes.contains(&"profile".to_string())); + } + + #[test] + fn resolve_granted_scopes_uses_requested_when_response_omits_scope() { + let granted = AuthorizationManager::resolve_granted_scopes( + None, + &["read".to_string(), "write".to_string()], + &["read".to_string()], + ); + + assert_eq!(granted, vec!["read".to_string(), "write".to_string()]); + } + + #[test] + fn resolve_granted_scopes_honors_explicit_server_downgrade() { + let granted = AuthorizationManager::resolve_granted_scopes( + Some(vec!["read".to_string()]), + &["read".to_string(), "write".to_string()], + &["read".to_string()], + ); + + assert_eq!(granted, vec!["read".to_string()]); + } + + #[test] + fn resolve_granted_scopes_falls_back_to_current_when_nothing_requested() { + let granted = + AuthorizationManager::resolve_granted_scopes(None, &[], &["read".to_string()]); + + assert_eq!(granted, vec!["read".to_string()]); + } + #[tokio::test] async fn add_offline_access_if_supported_works_with_explicit_scopes() { let mgr = manager_with_metadata(Some(AuthorizationMetadata { From 77eb607ccbd7ee67b50a7968d5d698452f133147 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:51:05 -0400 Subject: [PATCH 238/333] chore(deps): update hmac requirement from 0.12 to 0.13 (#988) * chore(deps): update hmac requirement from 0.12 to 0.13 Updates the requirements on [hmac](https://github.com/RustCrypto/MACs) to permit the latest version. - [Commits](https://github.com/RustCrypto/MACs/compare/hmac-v0.12.0...hmac-v0.13.0) --- updated-dependencies: - dependency-name: hmac dependency-version: 0.13.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * fix: align hmac and sha2 digest versions --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp/Cargo.toml | 4 ++-- crates/rmcp/src/model/request_state.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index a963c156d..e0c44c279 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -66,8 +66,8 @@ schemars = { version = "1.0", optional = true, features = ["chrono04"] } base64 = { version = "0.22", optional = true } # for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature) -hmac = { version = "0.12", optional = true } -sha2 = { version = "0.10", optional = true } +hmac = { version = "0.13", optional = true } +sha2 = { version = "0.11", optional = true } # for HTTP client reqwest = { version = "0.13.2", default-features = false, features = [ diff --git a/crates/rmcp/src/model/request_state.rs b/crates/rmcp/src/model/request_state.rs index 76d922663..5ed0f7de8 100644 --- a/crates/rmcp/src/model/request_state.rs +++ b/crates/rmcp/src/model/request_state.rs @@ -63,7 +63,7 @@ use std::time::Duration; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, KeyInit, Mac}; use serde::{Serialize, de::DeserializeOwned}; use sha2::Sha256; use thiserror::Error; From 81745f1375303e309c857d27ba75a229e5bd064f Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 16 Jul 2026 16:55:11 -0400 Subject: [PATCH 239/333] fix(auth): validate discovered metadata issuer (#996) * fix(auth): validate discovered metadata issuer Validate authorization server metadata issuers against the issuer implied by standard discovery URLs before trusting advertised endpoints. fixes #983 * test(auth): add required issuer to authorization-server mock metadata --------- Co-authored-by: Alex Hancock --- crates/rmcp/src/transport/auth.rs | 198 ++++++++++++++++++- crates/rmcp/tests/test_client_credentials.rs | 27 ++- 2 files changed, 218 insertions(+), 7 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index e8189f6e1..e7cc22437 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1925,7 +1925,10 @@ impl AuthorizationManager { } match serde_json::from_slice::(response.body()) { - Ok(metadata) => Ok(Some(metadata)), + Ok(metadata) => { + Self::validate_authorization_metadata_issuer(discovery_url, &metadata)?; + Ok(Some(metadata)) + } Err(err) => { debug!("Failed to parse metadata for {}: {}", discovery_url, err); Ok(None) // malformed JSON ⇒ try next candidate @@ -1933,6 +1936,78 @@ impl AuthorizationManager { } } + fn expected_issuer_for_authorization_metadata_url(discovery_url: &Url) -> Option { + let path = discovery_url.path(); + let oauth_prefix = "/.well-known/oauth-authorization-server"; + let oidc_prefix = "/.well-known/openid-configuration"; + + let issuer_path = if path == oauth_prefix || path == oidc_prefix { + "" + } else if let Some(suffix) = path.strip_prefix(&format!("{oauth_prefix}/")) { + // RFC 8414 path-insertion form + suffix + } else if let Some(suffix) = path.strip_prefix(&format!("{oidc_prefix}/")) { + // MCP-required OpenID Connect path-insertion compatibility form + suffix + } else if let Some(prefix) = path.strip_suffix(oidc_prefix) { + // OpenID Connect path-appended form + prefix.trim_start_matches('/') + } else { + return None; + }; + + let mut issuer = discovery_url.clone(); + issuer.set_query(None); + issuer.set_fragment(None); + if issuer_path.is_empty() { + issuer.set_path(""); + } else { + issuer.set_path(&format!("/{issuer_path}")); + } + Some(issuer.to_string()) + } + + fn issuer_identifiers_match(received_issuer: &str, expected_issuer: &str) -> bool { + if received_issuer == expected_issuer { + return true; + } + + let trim_root_slash = |issuer: &str| -> String { + issuer + .strip_suffix('/') + .filter(|without_slash| { + Url::parse(without_slash) + .map(|url| url.path().is_empty() || url.path() == "/") + .unwrap_or(false) + }) + .unwrap_or(issuer) + .to_string() + }; + + trim_root_slash(received_issuer) == trim_root_slash(expected_issuer) + } + + fn validate_authorization_metadata_issuer( + discovery_url: &Url, + metadata: &AuthorizationMetadata, + ) -> Result<(), AuthError> { + let Some(expected_issuer) = + Self::expected_issuer_for_authorization_metadata_url(discovery_url) + else { + return Ok(()); + }; + let Some(received_issuer) = metadata.issuer.as_deref() else { + return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer }); + }; + if !Self::issuer_identifiers_match(received_issuer, &expected_issuer) { + return Err(AuthError::AuthorizationServerMismatch { + expected_issuer, + received_issuer: received_issuer.to_string(), + }); + } + Ok(()) + } + async fn discover_oauth_server_via_resource_metadata( &self, ) -> Result, AuthError> { @@ -3416,6 +3491,7 @@ mod tests { http_response( 200, serde_json::json!({ + "issuer": "https://auth.example.com", "authorization_endpoint": "https://auth.example.com/authorize", "token_endpoint": "https://auth.example.com/token" }), @@ -3516,6 +3592,125 @@ mod tests { ); } + #[tokio::test] + async fn authorization_metadata_rejects_mismatched_issuer() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(401), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://evil.example.com/tenant1", + "authorization_endpoint": "https://evil.example.com/tenant1/authorize", + "token_endpoint": "https://evil.example.com/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let error = manager.discover_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::AuthorizationServerMismatch { + ref expected_issuer, + ref received_issuer + } if expected_issuer == "https://auth.example.com/tenant1" + && received_issuer == "https://evil.example.com/tenant1" + ), + "expected authorization server issuer mismatch, got: {error:?}" + ); + } + + #[test] + fn authorization_metadata_accepts_oidc_path_appended_issuer() { + let discovery_url = + Url::parse("https://auth.example.com/tenant1/.well-known/openid-configuration") + .unwrap(); + let metadata = AuthorizationMetadata { + issuer: Some("https://auth.example.com/tenant1".to_string()), + authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), + token_endpoint: "https://auth.example.com/tenant1/token".to_string(), + ..Default::default() + }; + + AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap(); + } + + #[test] + fn authorization_metadata_allows_only_root_trailing_slash_equivalence() { + assert!(AuthorizationManager::issuer_identifiers_match( + "https://auth.example.com/", + "https://auth.example.com" + )); + assert!(!AuthorizationManager::issuer_identifiers_match( + "https://auth.example.com/tenant1/", + "https://auth.example.com/tenant1" + )); + } + + #[test] + fn authorization_metadata_accepts_oidc_path_inserted_issuer() { + let discovery_url = + Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") + .unwrap(); + let metadata = AuthorizationMetadata { + issuer: Some("https://auth.example.com/tenant1".to_string()), + authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), + token_endpoint: "https://auth.example.com/tenant1/token".to_string(), + ..Default::default() + }; + + AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap(); + } + + #[test] + fn authorization_metadata_rejects_missing_issuer_for_standard_discovery_url() { + let discovery_url = + Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") + .unwrap(); + let metadata = AuthorizationMetadata { + issuer: None, + authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), + token_endpoint: "https://auth.example.com/tenant1/token".to_string(), + ..Default::default() + }; + + let error = + AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap_err(); + + assert!( + matches!( + error, + AuthError::AuthorizationServerMissingIssuer { ref expected_issuer } + if expected_issuer == "https://auth.example.com/tenant1" + ), + "expected missing issuer error, got: {error:?}" + ); + } + #[tokio::test] async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() { let challenge = oauth2::http::Response::builder() @@ -3721,6 +3916,7 @@ mod tests { http_response( 200, serde_json::json!({ + "issuer": "https://auth.example.com", "authorization_endpoint": "https://auth.example.com/authorize", "token_endpoint": "https://auth.example.com/token" }), diff --git a/crates/rmcp/tests/test_client_credentials.rs b/crates/rmcp/tests/test_client_credentials.rs index b2698d1b3..f21c85814 100644 --- a/crates/rmcp/tests/test_client_credentials.rs +++ b/crates/rmcp/tests/test_client_credentials.rs @@ -37,14 +37,29 @@ async fn resource_metadata_handler(req: Request) -> Result, async fn auth_server_metadata_handler(req: Request) -> Result, Infallible> { let host = req.headers().get("host").unwrap().to_str().unwrap(); let base_url = format!("http://{}", host); - Ok(json_response(serde_json::json!({ - "issuer": base_url, - "authorization_endpoint": format!("{}/authorize", base_url), - "token_endpoint": format!("{}/token", base_url), + Ok(auth_server_metadata_response(&base_url, &base_url)) +} + +async fn path_inserted_auth_server_metadata_handler( + req: Request, +) -> Result, Infallible> { + let host = req.headers().get("host").unwrap().to_str().unwrap(); + let base_url = format!("http://{}", host); + Ok(auth_server_metadata_response( + &format!("{}/mcp", base_url), + &base_url, + )) +} + +fn auth_server_metadata_response(issuer: &str, endpoint_base_url: &str) -> Response { + json_response(serde_json::json!({ + "issuer": issuer, + "authorization_endpoint": format!("{}/authorize", endpoint_base_url), + "token_endpoint": format!("{}/token", endpoint_base_url), "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], "grant_types_supported": ["client_credentials"], "scopes_supported": ["read", "write"] - }))) + })) } async fn token_handler(req: Request) -> Result, Infallible> { @@ -144,7 +159,7 @@ async fn start_path_insert_metadata_server() -> (String, SocketAddr) { let app = Router::new() .route( "/.well-known/oauth-authorization-server/mcp", - get(auth_server_metadata_handler), + get(path_inserted_auth_server_metadata_handler), ) .route("/token", post(token_handler)); From 2e2c79173cafb7b7966eeab91f60a241c440b640 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:12:16 -0400 Subject: [PATCH 240/333] feat: add modern client lifecycle modes (SEP-2575) (#995) * feat: add modern client lifecycle modes * refactor: drop modern naming and dead cleanup * fix: gate request metadata on inline lifecycle only --- .github/workflows/conformance.yml | 8 + README.md | 43 +- conformance/src/bin/client.rs | 103 +--- crates/rmcp/Cargo.toml | 20 + crates/rmcp/src/handler/server.rs | 38 +- crates/rmcp/src/lib.rs | 7 +- crates/rmcp/src/model/meta.rs | 13 + crates/rmcp/src/service.rs | 81 ++- crates/rmcp/src/service/client.rs | 271 +++++++++- crates/rmcp/src/service/server.rs | 48 +- .../src/transport/streamable_http_client.rs | 467 ++++++++++++------ .../rmcp/tests/test_client_lifecycle_modes.rs | 312 ++++++++++++ .../test_discover_http_client_startup.rs | 137 +++++ crates/rmcp/tests/test_meta_helpers.rs | 23 + .../tests/test_stateless_server_requests.rs | 196 ++++++++ .../test_streamable_http_stale_session.rs | 24 +- docs/readme/README.zh-cn.md | 40 +- 17 files changed, 1525 insertions(+), 306 deletions(-) create mode 100644 crates/rmcp/tests/test_client_lifecycle_modes.rs create mode 100644 crates/rmcp/tests/test_discover_http_client_startup.rs create mode 100644 crates/rmcp/tests/test_stateless_server_requests.rs diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 297fad8f3..817c7d4ed 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -226,6 +226,14 @@ jobs: --scenario sep-2322-client-request-state \ -o conformance-client-results/mrtr + - name: Run draft SEP-2575 client scenario + run: | + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ + --command "$(pwd)/target/debug/conformance-client" \ + --scenario request-metadata \ + --spec-version draft \ + -o conformance-client-results/sep-2575 + - name: Upload results if: always() uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index 68f3343c9..0f8367377 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,44 @@ async fn main() -> Result<(), Box> { ``` +### Client lifecycle modes + +`serve()` uses the legacy MCP lifecycle: the client sends `initialize`, receives +the negotiated server information, and then sends `notifications/initialized`. +Use [`ClientServiceExt::serve_with_lifecycle`](crates/rmcp/src/service/client.rs) to +select another lifecycle explicitly: + +```rust, ignore +use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion}; + +// Start directly with server/discover and include client metadata on every request. +let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + +// Or probe the discover lifecycle and fall back when a legacy server reports +// that server/discover is not implemented. +let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await?; +``` + +`ClientLifecycleMode::Initialize` is equivalent to the existing `serve()` behavior. +Discover startup does not send `notifications/initialized`; discovery completes +startup, and each subsequent request carries its protocol version, client +information, and capabilities in `_meta`. + ### Build a Server

@@ -812,10 +850,11 @@ impl ServerHandler for MyServer { ### Initialized notification -Clients send `initialized` after the handshake completes: +Legacy clients send `initialized` after the `initialize` handshake completes. +Clients using `ClientLifecycleMode::Discover` do not send this notification: ```rust -// Sent automatically by rmcp during the serve() handshake. +// Sent automatically by rmcp during the legacy serve() handshake. // Servers handle it via: impl ServerHandler for MyServer { async fn on_initialized( diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index cd4671f1f..63a116ff4 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -1,7 +1,7 @@ use rmcp::{ - ClientHandler, ErrorData, RoleClient, ServiceExt, + ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt, model::*, - service::{RequestContext, serve_directly}, + service::RequestContext, transport::{ AuthClient, AuthorizationManager, StreamableHttpClientTransport, auth::{AuthorizationCallback, OAuthState}, @@ -846,33 +846,6 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()> Ok(()) } -/// A minimal stateless client transport: every outgoing message is one HTTP -/// POST and the JSON response body (if any) is queued for `receive()`. -/// -/// The SEP-2322 client scenario's mock server speaks the stateless lifecycle -/// (no `initialize` handshake, plain JSON responses), which the session-based -/// `StreamableHttpClientTransport` cannot do. The transport is harness -/// plumbing; the behavior under test — the SDK's MRTR retry driver — runs -/// unchanged on top of it. -struct StatelessHttpTransport { - http: reqwest::Client, - uri: std::sync::Arc, - tx: tokio::sync::mpsc::Sender, - rx: tokio::sync::mpsc::Receiver, -} - -impl StatelessHttpTransport { - fn new(uri: &str) -> Self { - let (tx, rx) = tokio::sync::mpsc::channel(16); - Self { - http: reqwest::Client::new(), - uri: uri.into(), - tx, - rx, - } - } -} - fn conformance_protocol_version() -> ProtocolVersion { std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION") .ok() @@ -880,62 +853,22 @@ fn conformance_protocol_version() -> ProtocolVersion { .unwrap_or(ProtocolVersion::V_2026_07_28) } -impl rmcp::transport::Transport for StatelessHttpTransport { - type Error = std::io::Error; - - fn send( - &mut self, - item: rmcp::model::ClientJsonRpcMessage, - ) -> impl std::future::Future> + Send + 'static { - let http = self.http.clone(); - let uri = self.uri.clone(); - let tx = self.tx.clone(); - async move { - let response = http - .post(uri.as_ref()) - .header( - "MCP-Protocol-Version", - conformance_protocol_version().as_str(), - ) - .json(&item) - .send() - .await - .map_err(std::io::Error::other)?; - match response.json::().await { - Ok(message) => { - let _ = tx.send(message).await; - } - Err(_) => { - // No JSON-RPC body (e.g. 202/204 for notifications). - } - } - Ok(()) +/// Runs draft stateless scenarios through the public discover lifecycle and +/// Streamable HTTP transport. +async fn run_discover_client(server_url: &str) -> anyhow::Result<()> { + let mut preferred_versions = vec![conformance_protocol_version()]; + for version in ProtocolVersion::KNOWN_VERSIONS.iter().rev() { + if !preferred_versions.contains(version) { + preferred_versions.push(version.clone()); } } - - async fn receive(&mut self) -> Option { - self.rx.recv().await - } - - async fn close(&mut self) -> Result<(), Self::Error> { - Ok(()) - } -} - -/// Runs a client using the draft stateless lifecycle. -/// -/// Stateless servers do not implement the `initialize` handshake, so this -/// uses `serve_directly`. The protocol version comes from -/// `MCP_CONFORMANCE_PROTOCOL_VERSION` (defaulting to `2026-07-28`) and is -/// used for both peer configuration and outgoing HTTP request headers. -/// -/// Lists available tools and calls each one, allowing the SDK's high-level -/// tool-call handling to process any request retries. -async fn run_stateless_client(server_url: &str) -> anyhow::Result<()> { - let transport = StatelessHttpTransport::new(server_url); - let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_protocol_version(conformance_protocol_version()); - let client = serve_directly(FullClientHandler, transport, Some(peer_info)); + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = FullClientHandler + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { preferred_versions }, + ) + .await?; let tools = client.list_tools(Default::default()).await?; tracing::debug!("Listed {} tools", tools.tools.len()); @@ -989,14 +922,14 @@ async fn main() -> anyhow::Result<()> { match scenario.as_str() { // Non-auth scenarios "initialize" => run_basic_client(&server_url).await?, - "json-schema-ref-no-deref" => run_stateless_client(&server_url).await?, + "json-schema-ref-no-deref" => run_discover_client(&server_url).await?, "tools_call" => run_tools_call_client(&server_url, &ctx).await?, "elicitation-sep1034-client-defaults" => { run_elicitation_defaults_client(&server_url).await? } "sse-retry" => run_sse_retry_client(&server_url).await?, "request-metadata" | "sep-2322-client-request-state" => { - run_stateless_client(&server_url).await? + run_discover_client(&server_url).await? } "http-standard-headers" | "http-custom-headers" | "http-invalid-tool-headers" => { run_tools_call_client(&server_url, &ctx).await? diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index e0c44c279..8989eb061 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -302,6 +302,26 @@ name = "test_protocol_version_negotiation" required-features = ["server", "client"] path = "tests/test_protocol_version_negotiation.rs" +[[test]] +name = "test_client_lifecycle_modes" +required-features = ["client", "server"] +path = "tests/test_client_lifecycle_modes.rs" + +[[test]] +name = "test_stateless_server_requests" +required-features = ["client", "server"] +path = "tests/test_stateless_server_requests.rs" + +[[test]] +name = "test_discover_http_client_startup" +required-features = [ + "client", + "reqwest", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] +path = "tests/test_discover_http_client_startup.rs" + [[test]] name = "test_streamable_http_standard_headers" required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index c4db90f37..689bcfc78 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -41,22 +41,30 @@ impl Service for H { )); } } - if matches!(&request, ClientRequest::DiscoverRequest(_)) { - if requested_version.is_none() { + // Self-contained metadata is required only when the request itself uses + // the inline lifecycle: a discover opener, a session that started without + // `initialize`, or a request that declares 2026-07-28+ in its own _meta. + // Sessions that negotiated via `initialize` (or `serve_directly`) keep the + // session model and may omit per-request metadata. + let requires_request_metadata = uses_inline_negotiation + && (matches!(&request, ClientRequest::DiscoverRequest(_)) + || context.peer.request_metadata_required() + || requested_version.as_ref().is_some_and(|version| { + version.as_str() >= ProtocolVersion::V_2026_07_28.as_str() + })); + if requires_request_metadata { + // Inline lifecycle requests are defined by the 2026-07-28 protocol. + // Validate that lifecycle contract even when a request selects an + // older application protocol version. + let missing = context + .meta + .missing_required_keys(&ProtocolVersion::V_2026_07_28); + if !missing.is_empty() { return Err(McpError::invalid_params( - "server/discover requires protocolVersion in request _meta", - None, - )); - } - if context.meta.client_info().is_none() { - return Err(McpError::invalid_params( - "server/discover requires clientInfo in request _meta", - None, - )); - } - if context.meta.client_capabilities().is_none() { - return Err(McpError::invalid_params( - "server/discover requires clientCapabilities in request _meta", + format!( + "request _meta is missing or has malformed required fields: {}", + missing.join(", ") + ), None, )); } diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 78ba2e27f..ca195a6e8 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -16,10 +16,13 @@ pub use handler::client::ClientHandler; pub use handler::server::ServerHandler; #[cfg(feature = "server")] pub use handler::server::wrapper::Json; +#[cfg(feature = "client")] +pub use service::{ + ClientLifecycleMode, ClientServiceExt, RoleClient, select_protocol_version, serve_client, + serve_client_with_lifecycle, +}; #[cfg(any(feature = "client", feature = "server"))] pub use service::{Peer, Service, ServiceError, ServiceExt}; -#[cfg(feature = "client")] -pub use service::{RoleClient, select_protocol_version, serve_client}; #[cfg(feature = "server")] pub use service::{RoleServer, serve_server}; diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 9b1e1b0f6..c51fc6d7c 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -439,6 +439,19 @@ impl RequestMetaObject { meta } + /// Create request metadata with the client context SEP-2575 requires on every request. + pub fn with_client_context( + protocol_version: ProtocolVersion, + client_info: Implementation, + client_capabilities: ClientCapabilities, + ) -> Self { + let mut meta = Self::new(); + meta.set_protocol_version(protocol_version); + meta.set_client_info(client_info); + meta.set_client_capabilities(client_capabilities); + meta + } + pub(crate) fn static_empty() -> &'static Self { static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); EMPTY.get_or_init(Default::default) diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 6a7aa53e7..69c3f2105 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -1,3 +1,5 @@ +use std::sync::OnceLock; + use futures::FutureExt; #[cfg(not(feature = "local"))] use futures::future::BoxFuture; @@ -51,9 +53,10 @@ use crate::model::ServerNotification; use crate::{ error::ErrorData as McpError, model::{ - CancelledNotification, CancelledNotificationParam, Extensions, GetExtensions, GetMeta, - JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, - NotificationMetaObject, NumberOrString, ProgressToken, RequestId, RequestMetaObject, + CancelledNotification, CancelledNotificationParam, ClientCapabilities, Extensions, + GetExtensions, GetMeta, Implementation, JsonRpcError, JsonRpcMessage, JsonRpcNotification, + JsonRpcRequest, JsonRpcResponse, NotificationMetaObject, NumberOrString, ProgressToken, + ProtocolVersion, RequestId, RequestMetaObject, }, transport::{DynamicTransportError, IntoTransport, Transport}, }; @@ -515,6 +518,13 @@ pub(crate) enum PeerSinkMessage { }, } +#[derive(Debug, Clone)] +pub(crate) struct ClientRequestMetadata { + pub protocol_version: ProtocolVersion, + pub client_info: Implementation, + pub client_capabilities: ClientCapabilities, +} + /// An interface to fetch the remote client or server /// /// For general purpose, call [`Peer::send_request`] or [`Peer::send_notification`] to send message to remote peer. @@ -527,6 +537,8 @@ pub struct Peer { progress_token_provider: Arc, progress_timeout_watchers: ProgressTimeoutWatchers, info: Arc>>>, + client_request_metadata: Arc>, + request_metadata_required: Arc, } impl std::fmt::Debug for Peer { @@ -563,6 +575,14 @@ impl PeerRequestOptions { } } + /// Adds request metadata while preserving any other configured options. + /// + /// Explicit values take precedence over discover-lifecycle metadata defaults. + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { + self.meta = Some(meta); + self + } + pub fn reset_timeout_on_progress(mut self) -> Self { self.reset_timeout_on_progress = true; self @@ -588,6 +608,8 @@ impl Peer { progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()), progress_timeout_watchers: Default::default(), info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), + client_request_metadata: Default::default(), + request_metadata_required: Default::default(), }, rx, ) @@ -625,6 +647,12 @@ impl Peer { ) -> Result, ServiceError> { let id = self.request_id_provider.next_request_id(); let progress_token = self.progress_token_provider.next_progress_token(); + if let Some(metadata) = self.client_request_metadata.get() { + let meta = request.get_meta_mut(); + meta.set_protocol_version(metadata.protocol_version.clone()); + meta.set_client_info(metadata.client_info.clone()); + meta.set_client_capabilities(metadata.client_capabilities.clone()); + } if let Some(meta) = options.meta.clone() { request.get_meta_mut().extend(meta); } @@ -703,6 +731,21 @@ impl Peer { *self.info.write().expect("peer info lock poisoned") = Some(Arc::new(info)); } + pub(crate) fn set_client_request_metadata(&self, metadata: ClientRequestMetadata) { + let result = self.client_request_metadata.set(metadata); + debug_assert!(result.is_ok(), "client request metadata set more than once"); + } + + pub(crate) fn require_request_metadata(&self) { + self.request_metadata_required + .store(true, std::sync::atomic::Ordering::Release); + } + + pub(crate) fn request_metadata_required(&self) -> bool { + self.request_metadata_required + .load(std::sync::atomic::Ordering::Acquire) + } + pub fn is_transport_closed(&self) -> bool { self.tx.is_closed() } @@ -883,11 +926,35 @@ impl RequestContext { #[cfg(feature = "server")] impl RequestContext { - /// The protocol version the client negotiated, or `None` before peer info is recorded. + /// The current request's protocol version, falling back to legacy handshake state. pub fn protocol_version(&self) -> Option { - self.peer - .peer_info() - .map(|info| info.protocol_version.clone()) + self.meta.protocol_version().or_else(|| { + self.peer + .peer_info() + .map(|info| info.protocol_version.clone()) + }) + } + + /// The current request's client implementation, falling back only for legacy sessions. + pub fn client_info(&self) -> Option { + if self.peer.request_metadata_required() { + self.meta.client_info() + } else { + self.meta + .client_info() + .or_else(|| self.peer.peer_info().map(|info| info.client_info.clone())) + } + } + + /// The current request's client capabilities, falling back only for legacy sessions. + pub fn client_capabilities(&self) -> Option { + if self.peer.request_metadata_required() { + self.meta.client_capabilities() + } else { + self.meta + .client_capabilities() + .or_else(|| self.peer.peer_info().map(|info| info.capabilities.clone())) + } } } diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index d922f59f9..9ba3024d0 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -54,6 +54,17 @@ pub enum ClientInitializeError { #[error("JSON-RPC error: {0}")] JsonRpcError(ErrorData), + #[error( + "no compatible protocol version (client: {client_supported:?}, server: {server_supported:?})" + )] + NoCompatibleProtocolVersion { + client_supported: Vec, + server_supported: Vec, + }, + + #[error("discover startup requires at least one preferred protocol version")] + NoPreferredProtocolVersion, + #[error("Cancelled")] Cancelled, } @@ -177,6 +188,43 @@ impl ServiceRole for RoleClient { pub type ServerSink = Peer; +/// Selects how a client establishes its MCP lifecycle. +/// +/// Existing [`ServiceExt::serve`] behavior remains legacy initialization. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ClientLifecycleMode { + /// Use the legacy `initialize` / `notifications/initialized` handshake. + Initialize, + /// Use `server/discover` and send self-contained per-request metadata. + Discover { + preferred_versions: Vec, + }, + /// Probe with `server/discover`, falling back only when the peer proves it is legacy. + Auto { + preferred_versions: Vec, + legacy_version: Option, + }, +} + +/// Client-specific lifecycle entry points. +pub trait ClientServiceExt: Service + Sized { + fn serve_with_lifecycle( + self, + transport: T, + lifecycle: ClientLifecycleMode, + ) -> impl Future, ClientInitializeError>> + + MaybeSendFuture + where + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, + { + serve_client_with_lifecycle(self, transport, lifecycle) + } +} + +impl> ClientServiceExt for S {} + impl> ServiceExt for S { fn serve_with_ct( self, @@ -202,7 +250,13 @@ where T: IntoTransport, E: std::error::Error + Send + Sync + 'static, { - serve_client_with_ct(service, transport, Default::default()).await + serve_client_with_lifecycle_and_ct( + service, + transport, + ClientLifecycleMode::Initialize, + Default::default(), + ) + .await } pub async fn serve_client_with_ct( @@ -210,13 +264,41 @@ pub async fn serve_client_with_ct( transport: T, ct: CancellationToken, ) -> Result, ClientInitializeError> +where + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + serve_client_with_lifecycle_and_ct(service, transport, ClientLifecycleMode::Initialize, ct) + .await +} + +pub async fn serve_client_with_lifecycle( + service: S, + transport: T, + lifecycle: ClientLifecycleMode, +) -> Result, ClientInitializeError> +where + S: Service, + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + serve_client_with_lifecycle_and_ct(service, transport, lifecycle, Default::default()).await +} + +pub async fn serve_client_with_lifecycle_and_ct( + service: S, + transport: T, + lifecycle: ClientLifecycleMode, + ct: CancellationToken, +) -> Result, ClientInitializeError> where S: Service, T: IntoTransport, E: std::error::Error + Send + Sync + 'static, { tokio::select! { - result = serve_client_with_ct_inner(service, transport.into_transport(), ct.clone()) => { result } + result = serve_client_with_ct_inner(service, transport.into_transport(), lifecycle, ct.clone()) => { result } _ = ct.cancelled() => { Err(ClientInitializeError::Cancelled) } @@ -226,6 +308,7 @@ where async fn serve_client_with_ct_inner( service: S, transport: T, + lifecycle: ClientLifecycleMode, ct: CancellationToken, ) -> Result, ClientInitializeError> where @@ -234,12 +317,71 @@ where { let mut transport = transport.into_transport(); let id_provider = >::default(); + let (peer, peer_rx) = Peer::new(id_provider.clone(), None); + let client_info = service.get_info(); - // service + match lifecycle { + ClientLifecycleMode::Initialize => { + legacy_startup(&service, &mut transport, &id_provider, &peer, client_info).await?; + } + ClientLifecycleMode::Discover { preferred_versions } => { + discover_startup( + &service, + &mut transport, + &id_provider, + &peer, + &client_info, + preferred_versions, + ) + .await?; + } + ClientLifecycleMode::Auto { + preferred_versions, + legacy_version, + } => { + let discover_result = discover_startup( + &service, + &mut transport, + &id_provider, + &peer, + &client_info, + preferred_versions, + ) + .await; + match discover_result { + Ok(()) => {} + Err(ClientInitializeError::JsonRpcError(error)) + if error.code == crate::model::ErrorCode::METHOD_NOT_FOUND => + { + let mut legacy_info = client_info; + if let Some(version) = legacy_version { + legacy_info.protocol_version = version; + } + legacy_startup(&service, &mut transport, &id_provider, &peer, legacy_info) + .await?; + } + Err(error) => return Err(error), + } + } + } + Ok(serve_inner(service, transport, peer, peer_rx, ct)) +} + +async fn legacy_startup( + service: &S, + transport: &mut T, + id_provider: &Arc, + peer: &Peer, + client_info: ClientInfo, +) -> Result<(), ClientInitializeError> +where + S: Service, + T: Transport + 'static, +{ let id = id_provider.next_request_id(); let init_request = InitializeRequest { method: Default::default(), - params: service.get_info(), + params: client_info, extensions: Default::default(), }; transport @@ -253,15 +395,8 @@ where context: "send initialize request".into(), })?; - let (peer, peer_rx) = Peer::new(id_provider, None); - - let (response, response_id) = expect_response( - &mut transport, - "initialize response", - &service, - peer.clone(), - ) - .await?; + let (response, response_id) = + expect_response(transport, "initialize response", service, peer.clone()).await?; if id != response_id { return Err(ClientInitializeError::ConflictInitResponseId( @@ -285,7 +420,115 @@ where transport.send(notification).await.map_err(|error| { ClientInitializeError::transport::(error, "send initialized notification") })?; - Ok(serve_inner(service, transport, peer, peer_rx, ct)) + Ok(()) +} + +async fn discover_startup( + service: &S, + transport: &mut T, + id_provider: &Arc, + peer: &Peer, + client_info: &ClientInfo, + preferred_versions: Vec, +) -> Result<(), ClientInitializeError> +where + S: Service, + T: Transport + 'static, +{ + if preferred_versions.is_empty() { + return Err(ClientInitializeError::NoPreferredProtocolVersion); + } + + let mut attempted = Vec::new(); + let mut candidate = preferred_versions[0].clone(); + loop { + attempted.push(candidate.clone()); + + let meta = RequestMetaObject::with_client_context( + candidate.clone(), + client_info.client_info.clone(), + client_info.capabilities.clone(), + ); + let mut discover = DiscoverRequest::new(DiscoverRequestParams {}); + discover.extensions.insert(meta); + let id = id_provider.next_request_id(); + transport + .send(ClientJsonRpcMessage::request( + ClientRequest::DiscoverRequest(discover), + id.clone(), + )) + .await + .map_err(|error| { + ClientInitializeError::transport::(error, "send discover request") + })?; + + match expect_response(transport, "discover response", service, peer.clone()).await { + Ok((ServerResult::DiscoverResult(result), response_id)) => { + if response_id != id { + return Err(ClientInitializeError::ConflictInitResponseId( + id, + response_id, + )); + } + let Some(selected) = + select_protocol_version(&preferred_versions, &result.supported_versions) + else { + return Err(ClientInitializeError::NoCompatibleProtocolVersion { + client_supported: preferred_versions, + server_supported: result.supported_versions, + }); + }; + peer.set_peer_info(ServerInfo { + protocol_version: selected.clone(), + capabilities: result.capabilities, + server_info: result.server_info, + instructions: result.instructions, + meta: result.meta, + }); + peer.set_client_request_metadata(ClientRequestMetadata { + protocol_version: selected, + client_info: client_info.client_info.clone(), + client_capabilities: client_info.capabilities.clone(), + }); + return Ok(()); + } + Ok((response, _)) => { + return Err(ClientInitializeError::ExpectedInitResult(Some(response))); + } + Err(ClientInitializeError::JsonRpcError(error)) + if error.code == crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION => + { + let supported = error + .data + .as_ref() + .and_then(|data| data.get("supported")) + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default(); + let may_retry_current = attempted + .iter() + .filter(|version| *version == &candidate) + .count() + == 1; + let next = preferred_versions + .iter() + .find(|version| { + supported.contains(version) + && (!attempted.contains(version) + || (may_retry_current && *version == &candidate)) + }) + .cloned(); + let Some(next) = next else { + return Err(ClientInitializeError::NoCompatibleProtocolVersion { + client_supported: preferred_versions, + server_supported: supported, + }); + }; + candidate = next; + } + Err(error) => return Err(error), + } + } } macro_rules! method { diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 8c7a87dda..e24ae085f 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -217,12 +217,41 @@ where } }; - let ClientRequest::InitializeRequest(peer_info) = &request else { - return Err(ServerInitializeError::ExpectedInitializeRequest(Some( - ClientJsonRpcMessage::request(request, id), - ))); + let initialize_request = match request { + ClientRequest::InitializeRequest(request) => request, + mut request => { + if !request + .get_meta() + .missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty() + { + return Err(ServerInitializeError::ExpectedInitializeRequest(Some( + ClientJsonRpcMessage::request(request, id), + ))); + } + let (peer, peer_rx) = Peer::new(id_provider, None); + peer.require_request_metadata(); + let context = RequestContext { + ct: ct.child_token(), + id: id.clone(), + meta: std::mem::take(request.get_meta_mut()), + extensions: std::mem::take(request.extensions_mut()), + peer: peer.clone(), + }; + let response = match service.handle_request(request, context).await { + Ok(result) => ServerJsonRpcMessage::response(result, id), + Err(error) => ServerJsonRpcMessage::error(error, Some(id)), + }; + transport.send(response).await.map_err(|error| { + ServerInitializeError::transport::(error, "sending negotiated request response") + })?; + return Ok(serve_inner(service, transport, peer, peer_rx, ct)); + } }; - let (peer, peer_rx) = Peer::new(id_provider, Some(peer_info.params.clone())); + let requested_protocol_version = initialize_request.params.protocol_version.clone(); + let mut negotiated_peer_info = initialize_request.params.clone(); + let (peer, peer_rx) = Peer::new(id_provider, Some(negotiated_peer_info.clone())); + let request = ClientRequest::InitializeRequest(initialize_request); let context = RequestContext { ct: ct.child_token(), id: id.clone(), @@ -231,7 +260,7 @@ where peer: peer.clone(), }; // Send initialize response - let init_response = service.handle_request(request.clone(), context).await; + let init_response = service.handle_request(request, context).await; let mut init_response = match init_response { Ok(ServerResult::InitializeResult(init_response)) => init_response, Ok(result) => { @@ -247,13 +276,10 @@ where return Err(ServerInitializeError::InitializeFailed(e)); } }; - init_response.protocol_version = negotiate_protocol_version( - &peer_info.params.protocol_version, - init_response.protocol_version, - ); + init_response.protocol_version = + negotiate_protocol_version(&requested_protocol_version, init_response.protocol_version); // Update peer_info so context.protocol_version() reflects the negotiated // version in all subsequent request handlers. - let mut negotiated_peer_info = peer_info.params.clone(); negotiated_peer_info.protocol_version = init_response.protocol_version.clone(); peer.set_peer_info(negotiated_peer_info); transport diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 8fe6d7621..8ba3e423c 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -19,8 +19,9 @@ use super::common::client_side_sse::{ use crate::{ RoleClient, model::{ - ClientJsonRpcMessage, ClientNotification, ErrorData, InitializedNotification, JsonObject, - ProtocolVersion, RequestId, ServerJsonRpcMessage, ServerResult, + ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetMeta, + InitializedNotification, JsonObject, ProtocolVersion, RequestId, ServerJsonRpcMessage, + ServerResult, }, transport::{ common::{client_side_sse::SseAutoReconnectStream, mcp_headers}, @@ -29,6 +30,7 @@ use crate::{ }; type BoxedSseStream = BoxStream<'static, Result>; +const SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); fn build_request_headers( base: &HashMap, @@ -58,6 +60,27 @@ fn build_request_headers( headers } +fn request_version_headers( + base: &HashMap, + message: &ClientJsonRpcMessage, + fallback: &ProtocolVersion, + tool_cache: &HashMap>, +) -> (ProtocolVersion, HashMap) { + let version = match message { + ClientJsonRpcMessage::Request(request) => request + .request + .get_meta() + .protocol_version() + .unwrap_or_else(|| fallback.clone()), + _ => fallback.clone(), + }; + let mut headers = build_request_headers(base, message, tool_cache, &version); + if let Ok(value) = HeaderValue::from_str(version.as_str()) { + headers.insert(HeaderName::from_static("mcp-protocol-version"), value); + } + (version, headers) +} + fn cache_tools_from_response( cache: &mut HashMap>, message: &ServerJsonRpcMessage, @@ -184,7 +207,10 @@ pub enum StreamableHttpProtocolError { MissingSessionIdInResponse, } -#[allow(clippy::large_enum_variant)] +#[expect( + clippy::large_enum_variant, + reason = "boxing the streaming response would add an allocation to the common response path" +)] #[non_exhaustive] pub enum StreamableHttpPostResponse { Accepted, @@ -651,6 +677,67 @@ impl StreamableHttpClientWorker { Ok(()) } + fn spawn_common_stream( + streams: &mut tokio::task::JoinSet>>, + client: C, + session_id: Arc, + config: &StreamableHttpClientTransportConfig, + protocol_headers: HashMap, + sse_worker_tx: tokio::sync::mpsc::Sender, + transport_task_ct: CancellationToken, + ) { + let uri = config.uri.clone(); + let auth_header = config.auth_header.clone(); + let retry_config = config.retry_config.clone(); + let reconnect_uri = config.uri.clone(); + let reconnect_auth_header = config.auth_header.clone(); + let max_sse_event_size = config.max_sse_event_size; + + streams.spawn(async move { + match client + .get_stream_with_max_sse_event_size( + uri, + session_id.clone(), + None, + auth_header, + protocol_headers.clone(), + max_sse_event_size, + ) + .await + { + Ok(stream) => { + let sse_stream = SseAutoReconnectStream::new( + stream, + StreamableHttpClientReconnect { + client, + session_id, + uri: reconnect_uri, + auth_header: reconnect_auth_header, + custom_headers: protocol_headers, + max_sse_event_size, + }, + retry_config, + ); + Self::execute_sse_stream( + sse_stream, + sse_worker_tx, + false, + transport_task_ct.child_token(), + ) + .await + } + Err(StreamableHttpError::ServerDoesNotSupportSse) => { + tracing::debug!("server doesn't support sse, skip common stream"); + Ok(()) + } + Err(error) => { + tracing::error!("fail to get common stream: {error}"); + Err(error) + } + } + }); + } + /// Performs a transparent re-initialization handshake after a session-expired 404. /// /// Takes an owned clone of the client (avoiding `&self` across `.await` so the @@ -667,8 +754,14 @@ impl StreamableHttpClientWorker { auth_header: Option, custom_headers: HashMap, max_sse_event_size: usize, - ) -> Result<(Option>, HashMap), StreamableHttpError> - { + ) -> Result< + ( + Option>, + ProtocolVersion, + HashMap, + ), + StreamableHttpError, + > { let (init_msg, new_session_id_str) = client .post_message_with_max_sse_event_size( uri.clone(), @@ -712,7 +805,7 @@ impl StreamableHttpClientWorker { .await? .expect_accepted_or_json::()?; - Ok((new_session_id, new_protocol_headers)) + Ok((new_session_id, negotiated_version, new_protocol_headers)) } } @@ -743,17 +836,33 @@ impl Worker for StreamableHttpClientWorker { let _drop_guard = transport_task_ct.clone().drop_guard(); let WorkerSendRequest { responder, - message: initialize_request, + message: startup_request, } = context.recv_from_handler().await?; - let saved_init_request = initialize_request.clone(); + let is_legacy_startup = matches!( + &startup_request, + ClientJsonRpcMessage::Request(request) + if matches!(&request.request, ClientRequest::InitializeRequest(_)) + ); + let mut saved_init_request = is_legacy_startup.then(|| startup_request.clone()); + let empty_tool_cache = HashMap::new(); + let (bootstrap_version, bootstrap_headers) = if is_legacy_startup { + (ProtocolVersion::default(), config.custom_headers.clone()) + } else { + request_version_headers( + &config.custom_headers, + &startup_request, + &ProtocolVersion::default(), + &empty_tool_cache, + ) + }; let (message, session_id) = match self .client .post_message_with_max_sse_event_size( config.uri.clone(), - initialize_request, + startup_request, None, config.auth_header.clone(), - config.custom_headers.clone(), + bootstrap_headers.clone(), config.max_sse_event_size, ) .await @@ -784,8 +893,11 @@ impl Worker for StreamableHttpClientWorker { } None }; - let (negotiated_version, mut protocol_headers) = - negotiate_version_headers(&message, config.custom_headers.clone()); + let (mut negotiated_version, mut protocol_headers) = if is_legacy_startup { + negotiate_version_headers(&message, config.custom_headers.clone()) + } else { + (bootstrap_version, bootstrap_headers) + }; // SEP-2243: tool input schemas (name -> schema) cached from tools/list responses, // used to promote annotated tools/call arguments to Mcp-Param-* headers. let mut tool_header_cache: HashMap> = HashMap::new(); @@ -800,33 +912,37 @@ impl Worker for StreamableHttpClientWorker { }); context.send_to_handler(message).await?; - let initialized_notification = context.recv_from_handler().await?; - // expect a initialized response - let initialized_headers = build_request_headers( - &protocol_headers, - &initialized_notification.message, - &tool_header_cache, - &negotiated_version, - ); - self.client - .post_message_with_max_sse_event_size( - config.uri.clone(), - initialized_notification.message, - session_id.clone(), - config.auth_header.clone(), - initialized_headers, - config.max_sse_event_size, - ) - .await - .map_err(WorkerQuitReason::fatal_context( - "send initialized notification", - ))? - .expect_accepted_or_json::() - .map_err(WorkerQuitReason::fatal_context( - "process initialized notification response", - ))?; - let _ = initialized_notification.responder.send(Ok(())); - #[allow(clippy::large_enum_variant)] + if is_legacy_startup { + let initialized_notification = context.recv_from_handler().await?; + let initialized_headers = build_request_headers( + &protocol_headers, + &initialized_notification.message, + &tool_header_cache, + &negotiated_version, + ); + self.client + .post_message_with_max_sse_event_size( + config.uri.clone(), + initialized_notification.message, + session_id.clone(), + config.auth_header.clone(), + initialized_headers, + config.max_sse_event_size, + ) + .await + .map_err(WorkerQuitReason::fatal_context( + "send initialized notification", + ))? + .expect_accepted_or_json::() + .map_err(WorkerQuitReason::fatal_context( + "process initialized notification response", + ))?; + let _ = initialized_notification.responder.send(Ok(())); + } + #[expect( + clippy::large_enum_variant, + reason = "the event is short-lived and boxing would add allocation in the event loop" + )] enum Event { ClientMessage(WorkerSendRequest), ServerMessage(ServerJsonRpcMessage), @@ -834,63 +950,17 @@ impl Worker for StreamableHttpClientWorker { } let mut streams = tokio::task::JoinSet::new(); let mut pending_stream_response_ids = HashSet::new(); + let mut awaiting_fallback_initialized = false; if let Some(session_id) = &session_id { - let client = self.client.clone(); - let uri = config.uri.clone(); - let session_id = session_id.clone(); - let auth_header = config.auth_header.clone(); - let retry_config = self.config.retry_config.clone(); - let sse_worker_tx = sse_worker_tx.clone(); - let transport_task_ct = transport_task_ct.clone(); - let config_uri = config.uri.clone(); - let config_auth_header = config.auth_header.clone(); - let spawn_headers = protocol_headers.clone(); - let max_sse_event_size = config.max_sse_event_size; - - streams.spawn(async move { - match client - .get_stream_with_max_sse_event_size( - uri.clone(), - session_id.clone(), - None, - auth_header.clone(), - spawn_headers.clone(), - max_sse_event_size, - ) - .await - { - Ok(stream) => { - let sse_stream = SseAutoReconnectStream::new( - stream, - StreamableHttpClientReconnect { - client: client.clone(), - session_id: session_id.clone(), - uri: config_uri, - auth_header: config_auth_header, - custom_headers: spawn_headers, - max_sse_event_size, - }, - retry_config, - ); - Self::execute_sse_stream( - sse_stream, - sse_worker_tx, - false, - transport_task_ct.child_token(), - ) - .await - } - Err(StreamableHttpError::ServerDoesNotSupportSse) => { - tracing::debug!("server doesn't support sse, skip common stream"); - Ok(()) - } - Err(e) => { - // fail to get common stream - tracing::error!("fail to get common stream: {e}"); - Err(e) - } - } - }); + Self::spawn_common_stream( + &mut streams, + self.client.clone(), + session_id.clone(), + &config, + protocol_headers.clone(), + sse_worker_tx.clone(), + transport_task_ct.clone(), + ); } // Main event loop - capture exit reason so we can do cleanup before returning let loop_result: Result<(), WorkerQuitReason> = 'main_loop: loop { @@ -926,16 +996,112 @@ impl Worker for StreamableHttpClientWorker { match event { Event::ClientMessage(send_request) => { let WorkerSendRequest { message, responder } = send_request; + let is_fallback_initialize = saved_init_request.is_none() + && matches!( + &message, + ClientJsonRpcMessage::Request(request) + if matches!( + &request.request, + ClientRequest::InitializeRequest(_) + ) + ); + if is_fallback_initialize { + saved_init_request = Some(message.clone()); + // Servers do not assign sessions to `server/discover`, so a + // fallback initialize starts from a clean slate: no session + // ID, no cleanup state, and no streams to tear down. + debug_assert!( + session_id.is_none() + && session_cleanup_info.is_none() + && streams.is_empty(), + "discover bootstrap must not create session state" + ); + + let response = self + .client + .post_message_with_max_sse_event_size( + config.uri.clone(), + message, + None, + config.auth_header.clone(), + config.custom_headers.clone(), + config.max_sse_event_size, + ) + .await; + let response = match response { + Ok(response) => { + let _ = responder.send(Ok(())); + response + } + Err(error) => { + let _ = responder.send(Err(error)); + continue; + } + }; + let (initialize_response, new_session_id) = response + .expect_initialized::() + .await + .map_err(WorkerQuitReason::fatal_context( + "process fallback initialize response", + ))?; + session_id = new_session_id.map(Arc::from); + if session_id.is_none() && !config.allow_stateless { + return Err(WorkerQuitReason::fatal( + StreamableHttpError::::MissingSessionIdInResponse, + "process fallback initialize response", + )); + } + (negotiated_version, protocol_headers) = negotiate_version_headers( + &initialize_response, + config.custom_headers.clone(), + ); + session_cleanup_info = + session_id.as_ref().map(|session_id| SessionCleanupInfo { + client: self.client.clone(), + uri: config.uri.clone(), + session_id: session_id.clone(), + auth_header: config.auth_header.clone(), + protocol_headers: protocol_headers.clone(), + }); + context.send_to_handler(initialize_response).await?; + awaiting_fallback_initialized = true; + continue; + } + let request_id = Self::client_request_id(&message); + let inline_version = match &message { + ClientJsonRpcMessage::Request(request) => { + request.request.get_meta().protocol_version() + } + _ => None, + }; + let is_initialized_notification = matches!( + &message, + ClientJsonRpcMessage::Notification(notification) + if matches!( + ¬ification.notification, + ClientNotification::InitializedNotification(_) + ) + ); // Pass a clone to the first attempt so `message` is retained for a // potential re-init retry. `post_message` takes ownership and the // trait cannot be changed, so the clone is unavoidable. - let request_headers = build_request_headers( + let (request_version, request_headers) = request_version_headers( &protocol_headers, &message, - &tool_header_cache, &negotiated_version, + &tool_header_cache, ); + if inline_version.is_some() { + negotiated_version = request_version.clone(); + if let Ok(value) = HeaderValue::from_str(request_version.as_str()) { + protocol_headers + .insert(HeaderName::from_static("mcp-protocol-version"), value); + } + if let Some(cleanup) = &mut session_cleanup_info { + cleanup.protocol_headers = protocol_headers.clone(); + } + } let response = self .client .post_message_with_max_sse_event_size( @@ -949,9 +1115,10 @@ impl Worker for StreamableHttpClientWorker { .await; let send_result = match response { Err(StreamableHttpError::SessionExpired) => { - if !config.reinit_on_expired_session { - Err(StreamableHttpError::SessionExpired) - } else { + if let Some(saved_init_request) = saved_init_request + .as_ref() + .filter(|_| config.reinit_on_expired_session) + { // The server discarded the session (HTTP 404). Perform a // fresh handshake once and replay the original message. tracing::info!( @@ -967,7 +1134,11 @@ impl Worker for StreamableHttpClientWorker { ) .await { - Ok((new_session_id, new_protocol_headers)) => { + Ok(( + new_session_id, + new_negotiated_version, + new_protocol_headers, + )) => { // Old streams hold the stale session ID. Stop them first // so no late stale-session messages can arrive after the // pending requests below are completed. @@ -990,6 +1161,7 @@ impl Worker for StreamableHttpClientWorker { .await?; session_id = new_session_id; + negotiated_version = new_negotiated_version; protocol_headers = new_protocol_headers; session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { @@ -1001,71 +1173,22 @@ impl Worker for StreamableHttpClientWorker { }); if let Some(new_sid) = &session_id { - let client = self.client.clone(); - let uri = config.uri.clone(); - let new_sid = new_sid.clone(); - let auth_header = config.auth_header.clone(); - let retry_config = self.config.retry_config.clone(); - let sse_tx = sse_worker_tx.clone(); - let task_ct = transport_task_ct.clone(); - let config_uri = config.uri.clone(); - let config_auth = config.auth_header.clone(); - let spawn_headers = protocol_headers.clone(); - let max_sse_event_size = config.max_sse_event_size; - streams.spawn(async move { - match client - .get_stream_with_max_sse_event_size( - uri, - new_sid.clone(), - None, - auth_header.clone(), - spawn_headers.clone(), - max_sse_event_size, - ) - .await - { - Ok(stream) => { - let sse_stream = SseAutoReconnectStream::new( - stream, - StreamableHttpClientReconnect { - client: client.clone(), - session_id: new_sid, - uri: config_uri, - auth_header: config_auth, - custom_headers: spawn_headers, - max_sse_event_size, - }, - retry_config, - ); - Self::execute_sse_stream( - sse_stream, - sse_tx, - false, - task_ct.child_token(), - ) - .await - } - Err(StreamableHttpError::ServerDoesNotSupportSse) => { - tracing::debug!( - "server doesn't support sse after re-init" - ); - Ok(()) - } - Err(e) => { - tracing::error!( - "fail to get common stream after re-init: {e}" - ); - Err(e) - } - } - }); + Self::spawn_common_stream( + &mut streams, + self.client.clone(), + new_sid.clone(), + &config, + protocol_headers.clone(), + sse_worker_tx.clone(), + transport_task_ct.clone(), + ); } - let retry_headers = build_request_headers( + let (_, retry_headers) = request_version_headers( &protocol_headers, &message, - &tool_header_cache, &negotiated_version, + &tool_header_cache, ); let retry_response = self .client @@ -1126,7 +1249,9 @@ impl Worker for StreamableHttpClientWorker { } Err(reinit_err) => Err(reinit_err), } - } // else enable_reinit_on_expired_session + } else { + Err(StreamableHttpError::SessionExpired) + } } Err(e) => Err(e), Ok(StreamableHttpPostResponse::Accepted) => { @@ -1167,6 +1292,23 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } }; + if send_result.is_ok() + && awaiting_fallback_initialized + && is_initialized_notification + { + if let Some(session_id) = &session_id { + Self::spawn_common_stream( + &mut streams, + self.client.clone(), + session_id.clone(), + &config, + protocol_headers.clone(), + sse_worker_tx.clone(), + transport_task_ct.clone(), + ); + } + awaiting_fallback_initialized = false; + } let _ = responder.send(send_result); } Event::ServerMessage(json_rpc_message) => { @@ -1194,7 +1336,6 @@ impl Worker for StreamableHttpClientWorker { // Cleanup session before returning (ensures close() waits for session deletion) // Use a timeout to prevent indefinite hangs if the server is unresponsive if let Some(cleanup) = session_cleanup_info { - const SESSION_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); let cleanup_session_id = cleanup.session_id.clone(); match tokio::time::timeout( SESSION_CLEANUP_TIMEOUT, diff --git a/crates/rmcp/tests/test_client_lifecycle_modes.rs b/crates/rmcp/tests/test_client_lifecycle_modes.rs new file mode 100644 index 000000000..66638c1f2 --- /dev/null +++ b/crates/rmcp/tests/test_client_lifecycle_modes.rs @@ -0,0 +1,312 @@ +#![cfg(all(feature = "client", feature = "server", not(feature = "local")))] + +use rmcp::{ + ClientHandler, ClientLifecycleMode, ClientServiceExt, ServerHandler, ServiceExt, + model::{ + ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, GetMeta, + Implementation, InitializeResult, ProtocolVersion, ServerCapabilities, + ServerJsonRpcMessage, ServerResult, + }, + service::PeerRequestOptions, + transport::{IntoTransport, Transport}, +}; + +#[derive(Clone, Default)] +struct DiscoverClient; + +impl ClientHandler for DiscoverClient {} + +#[derive(Clone, Default)] +struct StatelessServer; + +impl ServerHandler for StatelessServer {} + +#[tokio::test] +async fn high_level_server_accepts_discover_startup_without_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { + StatelessServer + .serve(server_transport) + .await + .expect("server should accept discover") + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("discover client should start"); + client.list_tools(None).await.expect("list tools"); + client.cancel().await.expect("cancel client"); + let server = server_task.await.expect("server task"); + server.cancel().await.expect("cancel server"); +} + +#[tokio::test] +async fn discover_startup_omits_initialize() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + assert!(matches!(request.request, ClientRequest::DiscoverRequest(_))); + let meta = request.request.get_meta(); + assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); + assert!(meta.client_info().is_some()); + assert!(meta.client_capabilities().is_some()); + + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + Implementation::new("discover-server", "1.0.0"), + )), + request.id, + )) + .await + .expect("send discover response"); + + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected normal request") + else { + panic!("expected request"); + }; + assert!(!matches!( + request.request, + ClientRequest::InitializeRequest(_) + )); + let meta = request.request.get_meta(); + assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2025_11_25)); + assert!(meta.client_info().is_some()); + assert!(meta.client_capabilities().is_some()); + assert_eq!( + meta.get("example.test/extension"), + Some(&serde_json::json!(7)) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(Default::default()), + request.id, + )) + .await + .expect("send tools response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("discover client should start"); + let mut caller_meta = rmcp::model::RequestMetaObject::new(); + caller_meta.insert("example.test/extension".into(), serde_json::json!(7)); + caller_meta.set_protocol_version(ProtocolVersion::V_2025_11_25); + client + .send_request_with_option( + ClientRequest::ListToolsRequest(rmcp::model::ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }), + PeerRequestOptions::default().with_meta(caller_meta), + ) + .await + .expect("send list tools") + .await_response() + .await + .expect("list tools response"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn auto_startup_falls_back_after_discover_method_not_found() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(discover) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + assert!(matches!( + discover.request, + ClientRequest::DiscoverRequest(_) + )); + server + .send(ServerJsonRpcMessage::error( + ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "Method not found", None), + Some(discover.id), + )) + .await + .expect("send method-not-found"); + + let ClientJsonRpcMessage::Request(initialize) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected request"); + }; + assert!(matches!( + initialize.request, + ClientRequest::InitializeRequest(_) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult( + InitializeResult::new(ServerCapabilities::default()), + ), + initialize.id, + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await + .expect("auto client should fall back"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn discover_startup_retries_a_mutually_supported_version() { + let unsupported: ProtocolVersion = + serde_json::from_value(serde_json::json!("2099-01-01")).unwrap(); + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(first) = + server.receive().await.expect("expected first discover") + else { + panic!("expected request"); + }; + assert_eq!( + first.request.get_meta().protocol_version(), + Some(unsupported.clone()) + ); + server + .send(ServerJsonRpcMessage::error( + ErrorData::unsupported_protocol_version( + unsupported, + &[ProtocolVersion::V_2026_07_28], + ), + Some(first.id), + )) + .await + .expect("send unsupported error"); + + let ClientJsonRpcMessage::Request(second) = + server.receive().await.expect("expected retry discover") + else { + panic!("expected request"); + }; + assert_eq!( + second.request.get_meta().protocol_version(), + Some(ProtocolVersion::V_2026_07_28) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + Implementation::new("discover-server", "1.0.0"), + )), + second.id, + )) + .await + .expect("send discover response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ + serde_json::from_value(serde_json::json!("2099-01-01")).unwrap(), + ProtocolVersion::V_2026_07_28, + ], + }, + ) + .await + .expect("discover client should retry"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn discover_startup_retries_current_version_once_when_server_reports_it_supported() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(first) = + server.receive().await.expect("expected first discover") + else { + panic!("expected request"); + }; + server + .send(ServerJsonRpcMessage::error( + ErrorData::unsupported_protocol_version( + ProtocolVersion::V_2026_07_28, + &[ProtocolVersion::V_2026_07_28], + ), + Some(first.id), + )) + .await + .expect("send unsupported error"); + + let ClientJsonRpcMessage::Request(second) = + server.receive().await.expect("expected retry discover") + else { + panic!("expected request"); + }; + assert_eq!( + second.request.get_meta().protocol_version(), + Some(ProtocolVersion::V_2026_07_28) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + Implementation::new("discover-server", "1.0.0"), + )), + second.id, + )) + .await + .expect("send discover response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("discover client should retry once"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} diff --git a/crates/rmcp/tests/test_discover_http_client_startup.rs b/crates/rmcp/tests/test_discover_http_client_startup.rs new file mode 100644 index 000000000..6ce1dbe40 --- /dev/null +++ b/crates/rmcp/tests/test_discover_http_client_startup.rs @@ -0,0 +1,137 @@ +#![cfg(all( + not(feature = "local"), + feature = "client", + feature = "reqwest", + feature = "transport-streamable-http-server" +))] + +use std::borrow::Cow; + +use rmcp::{ + ClientLifecycleMode, ClientServiceExt, ServerHandler, + model::{ClientInfo, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion}, + service::{MaybeSendFuture, RequestContext, RoleServer}, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Default)] +struct DiscoverHttpServer; + +impl ServerHandler for DiscoverHttpServer { + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(&[ProtocolVersion::V_2026_07_28]) + } +} + +#[derive(Clone, Default)] +struct LegacyHttpServer; + +impl ServerHandler for LegacyHttpServer { + fn discover( + &self, + _context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err(ErrorData::new( + ErrorCode::METHOD_NOT_FOUND, + "Method not found", + None, + ))) + } +} + +#[tokio::test] +async fn discover_http_client_bootstraps_headers_without_initialize() { + let ct = CancellationToken::new(); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(DiscoverHttpServer), + Default::default(), + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_cancellation_token(ct.child_token()), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("discover HTTP client should start"); + client.list_tools(None).await.expect("list tools"); + client.cancel().await.expect("cancel client"); + + ct.cancel(); + server.await.expect("server task"); +} + +#[tokio::test] +async fn auto_http_client_falls_back_to_stateful_legacy_startup() { + let ct = CancellationToken::new(); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(LegacyHttpServer), + Default::default(), + StreamableHttpServerConfig::default() + .with_json_response(true) + .with_cancellation_token(ct.child_token()), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await + .expect("auto HTTP client should fall back"); + client.list_tools(None).await.expect("list tools"); + client.cancel().await.expect("cancel client"); + + ct.cancel(); + server.await.expect("server task"); +} diff --git a/crates/rmcp/tests/test_meta_helpers.rs b/crates/rmcp/tests/test_meta_helpers.rs index a45c6a4e2..5f9128a42 100644 --- a/crates/rmcp/tests/test_meta_helpers.rs +++ b/crates/rmcp/tests/test_meta_helpers.rs @@ -30,6 +30,29 @@ fn meta_setters_store_sep_2575_values() { assert_eq!(meta.get(META_KEY_LOG_LEVEL), Some(&json!("warning"))); } +#[test] +fn with_client_context_sets_all_required_sep_2575_fields() { + let meta = RequestMetaObject::with_client_context( + ProtocolVersion::V_2026_07_28, + Implementation::new("test-client", "1.0.0"), + ClientCapabilities::default(), + ); + + assert!( + meta.missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty() + ); + assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28)); + assert_eq!( + meta.client_info(), + Some(Implementation::new("test-client", "1.0.0")) + ); + assert_eq!( + meta.client_capabilities(), + Some(ClientCapabilities::default()) + ); +} + #[test] fn meta_accessors_decode_wire_values() { let meta: RequestMetaObject = serde_json::from_value(json!({ diff --git a/crates/rmcp/tests/test_stateless_server_requests.rs b/crates/rmcp/tests/test_stateless_server_requests.rs new file mode 100644 index 000000000..4404e08cf --- /dev/null +++ b/crates/rmcp/tests/test_stateless_server_requests.rs @@ -0,0 +1,196 @@ +#![cfg(all(feature = "server", not(feature = "local")))] + +use std::sync::{Arc, Mutex}; + +use rmcp::{ + ServerHandler, ServiceExt, + model::{ + ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest, + DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest, + ListToolsResult, PaginatedRequestParams, ProtocolVersion, RequestId, RequestMetaObject, + ServerJsonRpcMessage, + }, + service::{MaybeSendFuture, RequestContext, RoleServer, ServerInitializeError}, + transport::{IntoTransport, Transport}, +}; + +#[derive(Clone, Default)] +struct StatelessServer; + +impl ServerHandler for StatelessServer {} + +fn complete_meta() -> RequestMetaObject { + complete_meta_for("stateless-client") +} + +fn complete_meta_for(client_name: &str) -> RequestMetaObject { + let mut meta = RequestMetaObject::new(); + meta.set_protocol_version(ProtocolVersion::V_2026_07_28); + meta.set_client_info(Implementation::new(client_name, "1.0.0")); + meta.set_client_capabilities(ClientCapabilities::default()); + meta +} + +fn list_tools_request(meta: RequestMetaObject) -> ClientJsonRpcMessage { + let mut request = ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }; + request.extensions.insert(meta); + ClientJsonRpcMessage::request( + ClientRequest::ListToolsRequest(request), + RequestId::Number(1), + ) +} + +#[tokio::test] +async fn stateless_server_rejects_missing_metadata_on_every_request() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { + StatelessServer + .serve(server_transport) + .await + .expect("server should start") + }); + let mut client = IntoTransport::::into_transport(client_transport); + + let mut discover = DiscoverRequest::new(DiscoverRequestParams {}); + discover.extensions.insert(complete_meta()); + client + .send(ClientJsonRpcMessage::request( + ClientRequest::DiscoverRequest(discover), + RequestId::Number(1), + )) + .await + .expect("send discover"); + assert!(matches!( + client.receive().await, + Some(ServerJsonRpcMessage::Response(_)) + )); + + client + .send(ClientJsonRpcMessage::request( + ClientRequest::ListToolsRequest(ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }), + RequestId::Number(2), + )) + .await + .expect("send list tools"); + let Some(ServerJsonRpcMessage::Error(error)) = client.receive().await else { + panic!("expected invalid params"); + }; + assert_eq!(error.error.code, ErrorCode::INVALID_PARAMS); + + server_task + .await + .expect("server task") + .cancel() + .await + .expect("cancel server"); +} + +#[derive(Clone)] +struct ContextServer { + seen_clients: Arc>>, +} + +impl ServerHandler for ContextServer { + fn list_tools( + &self, + _request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let seen_clients = self.seen_clients.clone(); + async move { + seen_clients + .lock() + .expect("seen clients lock") + .push(context.client_info().expect("current client info").name); + Ok(ListToolsResult::default()) + } + } +} + +#[tokio::test] +async fn stateless_server_uses_each_requests_client_context() { + let seen_clients = Arc::new(Mutex::new(Vec::new())); + let handler = ContextServer { + seen_clients: seen_clients.clone(), + }; + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { + handler + .serve(server_transport) + .await + .expect("server should start") + }); + let mut client = IntoTransport::::into_transport(client_transport); + + client + .send(list_tools_request(complete_meta_for("first-client"))) + .await + .expect("send first request"); + assert!(matches!( + client.receive().await, + Some(ServerJsonRpcMessage::Response(_)) + )); + + let mut second = list_tools_request(complete_meta_for("second-client")); + if let ClientJsonRpcMessage::Request(request) = &mut second { + request.id = RequestId::Number(2); + } + client.send(second).await.expect("send second request"); + assert!(matches!( + client.receive().await, + Some(ServerJsonRpcMessage::Response(_)) + )); + + assert_eq!( + *seen_clients.lock().expect("seen clients lock"), + ["first-client", "second-client"] + ); + server_task + .await + .expect("server task") + .cancel() + .await + .expect("cancel server"); +} + +#[tokio::test] +async fn stateless_server_rejects_malformed_metadata_opener() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { StatelessServer.serve(server_transport).await }); + let mut client = IntoTransport::::into_transport(client_transport); + + let mut request = ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }; + let malformed: RequestMetaObject = serde_json::from_value(serde_json::json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": "wrong", + "io.modelcontextprotocol/clientCapabilities": null + })) + .unwrap(); + request.extensions.insert(malformed); + client + .send(ClientJsonRpcMessage::request( + ClientRequest::ListToolsRequest(request), + RequestId::Number(1), + )) + .await + .expect("send list tools"); + let Err(error) = server_task.await.expect("server task") else { + panic!("malformed opener should not start a session"); + }; + assert!(matches!( + error, + ServerInitializeError::ExpectedInitializeRequest(Some(_)) + )); +} diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index 9aa0309ad..137460f7b 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -16,7 +16,7 @@ use rmcp::{ ServiceError, ServiceExt, model::{ CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode, - ErrorData, InitializeResult, PingRequest, RequestId, ServerCapabilities, + ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities, ServerJsonRpcMessage, ServerResult, }, transport::{ @@ -90,7 +90,7 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient { message: ClientJsonRpcMessage, _session_id: Option>, _auth_header: Option, - _custom_headers: HashMap, + custom_headers: HashMap, ) -> Result> { let mut state = self.state.lock().await; match state @@ -100,15 +100,23 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient { { MockPost::Initialize => { state.session_counter += 1; + let protocol_version = if state.session_counter == 1 { + ProtocolVersion::V_2025_11_25 + } else { + ProtocolVersion::V_2025_06_18 + }; let id = match message { ClientJsonRpcMessage::Request(request) => request.id, other => panic!("expected initialize request, got {other:?}"), }; Ok(StreamableHttpPostResponse::Json( ServerJsonRpcMessage::response( - ServerResult::InitializeResult(InitializeResult::new( - ServerCapabilities::builder().enable_tools().build(), - )), + ServerResult::InitializeResult( + InitializeResult::new( + ServerCapabilities::builder().enable_tools().build(), + ) + .with_protocol_version(protocol_version), + ), id, ), Some(format!("session-{}", state.session_counter)), @@ -123,6 +131,12 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient { } MockPost::Accepted => { if state.posts.is_empty() { + assert_eq!( + custom_headers + .get(&HeaderName::from_static("mcp-protocol-version")) + .and_then(|value| value.to_str().ok()), + Some(ProtocolVersion::V_2025_06_18.as_str()) + ); self.final_retry_accepted.add_permits(1); } else { self.initial_request_accepted.add_permits(1); diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md index 84393849a..55518f095 100644 --- a/docs/readme/README.zh-cn.md +++ b/docs/readme/README.zh-cn.md @@ -74,6 +74,41 @@ async fn main() -> Result<(), Box> { ```
+### 客户端生命周期模式 + +`serve()` 使用传统 MCP 生命周期:客户端发送 `initialize`,接收协商后的服务端信息, +然后发送 `notifications/initialized`。如需显式选择其他生命周期,请使用 +[`ClientServiceExt::serve_with_lifecycle`](../../crates/rmcp/src/service/client.rs): + +```rust, ignore +use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion}; + +// 直接通过 server/discover 启动,并在每个请求中携带客户端元数据。 +let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + +// 或先尝试发现生命周期;当传统服务端报告未实现 server/discover 时回退。 +let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await?; +``` + +`ClientLifecycleMode::Initialize` 等同于现有的 `serve()` 行为。发现启动不会发送 +`notifications/initialized`;发现过程即完成启动,后续每个请求都会在 `_meta` +中携带协议版本、客户端信息和客户端能力。 + ### 构建服务端
@@ -804,10 +839,11 @@ impl ServerHandler for MyServer { ### 初始化通知 -客户端在握手完成后发送 `initialized` 通知: +传统客户端在 `initialize` 握手完成后发送 `initialized` 通知。 +使用 `ClientLifecycleMode::Discover` 的客户端不会发送此通知: ```rust -// 在 serve() 握手过程中由 rmcp 自动发送。 +// 在传统 serve() 握手过程中由 rmcp 自动发送。 // 服务端通过以下方式处理: impl ServerHandler for MyServer { async fn on_initialized( From 9df629e6314daaeadfd0a1a4592d11511282f9d6 Mon Sep 17 00:00:00 2001 From: stevenlee-oai Date: Thu, 16 Jul 2026 22:15:19 -0400 Subject: [PATCH 241/333] fix(auth): distinguish rejected refresh tokens from transient failures (#963) * fix(auth): distinguish rejected refresh tokens * docs(auth): clarify refresh failure handling --- crates/rmcp/src/transport/auth.rs | 107 +++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index e7cc22437..53cd9bdac 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -13,7 +13,8 @@ use oauth2::{ AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, ExtraTokenFields, HttpRequest, HttpResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, - TokenResponse, TokenUrl, basic::BasicTokenType, + TokenResponse, TokenUrl, + basic::{BasicErrorResponseType, BasicTokenType}, }; use reqwest::{ Client as ReqwestClient, IntoUrl, StatusCode, Url, @@ -483,9 +484,16 @@ pub enum AuthError { #[error("OAuth token exchange failed: {0}")] TokenExchangeFailed(String), + /// The refresh attempt failed without a definitive refresh-token rejection. + /// + /// Callers may retry this error because it includes transient request and provider failures. #[error("OAuth token refresh failed: {0}")] TokenRefreshFailed(String), + /// The authorization server definitively rejected the refresh token. + #[error("OAuth refresh token was rejected: {0}")] + TokenRefreshRejected(String), + #[error("HTTP error: {0}")] HttpError(#[from] reqwest::Error), @@ -1714,9 +1722,11 @@ impl AuthorizationManager { /// Get access token from local credential store. /// If expired, refresh it automatically when a refresh token is available. - /// When the access token has expired and no refresh token is available (or - /// the refresh itself fails), returns [`AuthError::AuthorizationRequired`] - /// so the caller can re-authenticate. + /// When the access token has expired and no refresh token is available, or the + /// authorization server rejects the refresh token, returns + /// [`AuthError::AuthorizationRequired`] so the caller can re-authenticate. + /// Transient refresh failures return [`AuthError::TokenRefreshFailed`] so the + /// caller can retry; other errors are propagated as-is. pub async fn get_access_token(&self) -> Result { let stored = self.credential_store.load().await?; let Some(stored_creds) = stored else { @@ -1757,7 +1767,7 @@ impl AuthorizationManager { tracing::info!("Refreshed access token."); Ok(new_creds.access_token().secret().to_string()) } - Err(e @ (AuthError::AuthorizationRequired | AuthError::TokenRefreshFailed(_))) => { + Err(e @ (AuthError::AuthorizationRequired | AuthError::TokenRefreshRejected(_))) => { tracing::warn!(error = %e, "Token refresh not possible, re-authorization required."); Err(AuthError::AuthorizationRequired) } @@ -1778,9 +1788,9 @@ impl AuthorizationManager { .token_response .ok_or(AuthError::AuthorizationRequired)?; - let refresh_token = current_credentials.refresh_token().ok_or_else(|| { - AuthError::TokenRefreshFailed("No refresh token available".to_string()) - })?; + let refresh_token = current_credentials + .refresh_token() + .ok_or(AuthError::AuthorizationRequired)?; debug!("refresh token present, attempting refresh"); let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string()); @@ -1799,7 +1809,14 @@ impl AuthorizationManager { redirect_policy: self.refresh_redirect_policy, }) .await - .map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?; + .map_err(|error| match &error { + RequestTokenError::ServerResponse(response) + if response.error() == &BasicErrorResponseType::InvalidGrant => + { + AuthError::TokenRefreshRejected(error.to_string()) + } + _ => AuthError::TokenRefreshFailed(error.to_string()), + })?; // RFC 6749 section 6: issuing a new refresh token on refresh is optional. // When the response omits one, keep the existing refresh token rather than @@ -5908,6 +5925,52 @@ mod tests { resp } + async fn manager_with_refresh_error(error: &'static str) -> AuthorizationManager { + use axum::{Router, body::Body, http::Response, routing::post}; + + let app = Router::new().route( + "/token", + post(move || async move { + Response::builder() + .status(400) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "error": error, + "error_description": "refresh failed", + }) + .to_string(), + )) + .unwrap() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("http://{addr}/authorize"), + token_endpoint: format!("http://{addr}/token"), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + manager + .credential_store + .save(StoredCredentials { + client_id: "my-client".to_string(), + token_response: Some(make_token_response_with_refresh( + "old-token", + "my-refresh-token", + )), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + }) + .await + .unwrap(); + manager + } + #[tokio::test] async fn refresh_token_returns_error_when_no_stored_credentials() { let mut manager = manager_with_metadata(None).await; @@ -5954,9 +6017,33 @@ mod tests { manager.credential_store.save(stored).await.unwrap(); let err = manager.refresh_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when no refresh token, got: {err:?}" + ); + } + + #[tokio::test] + async fn invalid_grant_refresh_requires_reauthorization() { + let manager = manager_with_refresh_error("invalid_grant").await; + + let err = manager.try_refresh_or_reauth().await.unwrap_err(); + + assert!( + matches!(err, AuthError::AuthorizationRequired), + "expected AuthorizationRequired when the refresh token is rejected, got: {err:?}" + ); + } + + #[tokio::test] + async fn temporary_refresh_failure_does_not_require_reauthorization() { + let manager = manager_with_refresh_error("temporarily_unavailable").await; + + let err = manager.try_refresh_or_reauth().await.unwrap_err(); + assert!( matches!(err, AuthError::TokenRefreshFailed(_)), - "expected TokenRefreshFailed when no refresh token, got: {err:?}" + "expected TokenRefreshFailed for a temporary provider failure, got: {err:?}" ); } From bc2c5f3c4a0bfdd91be6c3a49957dc4868a35321 Mon Sep 17 00:00:00 2001 From: Amey Pawar <138877912+ameyypawar@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:36:44 +0530 Subject: [PATCH 242/333] fix(transport): cancel in-flight request on stateless streamable-HTTP client disconnect (#857) (#967) A stateless streamable-HTTP request is one-shot (no session, no resumption), so if the client drops the response before the handler finishes, the request is terminal and should be cancelled. Previously the handler kept running with its `RequestContext::ct` never firing, so long-running or destructive tools could not observe a client disconnect. Give each stateless request its own cancellation token via `serve_directly_with_ct` and cancel it when the client disconnects. This covers both stateless paths: `serve_negotiated_request_directly` (per-request version negotiation) and the non-negotiated path. In each: - A disconnect while the handler is still producing its first message cancels it. The guard is disarmed once the handler emits anything, so a normal response is never cancelled. - The SSE response stream is wrapped in a guard that cancels the handler if the stream is dropped before it ends naturally. - When a negotiated request replies directly (JSON mode, or a non-OK status), the receiver is dropped, so a still-running handler is cancelled rather than left emitting into a closed channel. Without this its terminal send fails before adding the termination permit and the serve loop parks forever. Stateful (resumable) mode is intentionally left unchanged: there a disconnect may be recovered via `Last-Event-ID`, so cancelling on disconnect would break resumption. Adds a regression test covering both stateless sub-modes (SSE and JSON). --- crates/rmcp/Cargo.toml | 9 + .../transport/streamable_http_server/tower.rs | 122 ++++++++++- .../test_streamable_http_disconnect_cancel.rs | 196 ++++++++++++++++++ 3 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 8989eb061..280cfd376 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -426,3 +426,12 @@ required-features = [ "transport-streamable-http-client-reqwest", ] path = "tests/test_streamable_http_connection_reuse.rs" + +[[test]] +name = "test_streamable_http_disconnect_cancel" +required-features = [ + "server", + "transport-streamable-http-server", + "reqwest", +] +path = "tests/test_streamable_http_disconnect_cancel.rs" diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 1994d554a..117912e4d 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -1,12 +1,20 @@ use std::{ - borrow::Cow, collections::HashMap, convert::Infallible, fmt::Display, sync::Arc, time::Duration, + borrow::Cow, + collections::HashMap, + convert::Infallible, + fmt::Display, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, }; use bytes::Bytes; -use futures::{StreamExt, future::BoxFuture}; +use futures::{Stream, StreamExt, future::BoxFuture}; use http::{HeaderMap, Method, Request, Response, header::ALLOW}; use http_body::Body; use http_body_util::{BodyExt, Full, combinators::BoxBody}; +use pin_project_lite::pin_project; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; @@ -22,7 +30,7 @@ use crate::{ ProtocolVersion, RequestId, ServerJsonRpcMessage, }, serve_server, - service::serve_directly, + service::serve_directly_with_ct, transport::{ OneshotTransport, TransportAdapterIdentity, common::{ @@ -852,14 +860,28 @@ where request.request.extensions_mut().insert(parts); let (transport, mut receiver) = OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); - let service = serve_directly(service, transport, peer_info); + // Give this stateless request its own cancellation token so a client + // disconnect can cancel the in-flight handler (#857), as in the + // non-negotiated stateless path below. + let request_ct = CancellationToken::new(); + let service = serve_directly_with_ct(service, transport, peer_info, request_ct.clone()); tokio::spawn(async move { let _ = service.waiting().await; }); let cancel = self.config.cancellation_token.child_token(); + // Cancel the handler if the client disconnects while it is still + // producing its first message (this future is dropped before + // `receiver.recv()` completes). Disarmed once the handler emits + // anything, so a normal response is never cancelled. + let mut disconnect_guard = Some(request_ct.clone().drop_guard()); let first = tokio::select! { - message = receiver.recv() => message, + message = receiver.recv() => { + if let Some(guard) = disconnect_guard.take() { + guard.disarm(); + } + message + } _ = cancel.cancelled() => None, } .ok_or_else(|| { @@ -870,9 +892,18 @@ where })?; if self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK { + // This message is the whole reply, so `receiver` is dropped here and + // anything the handler emits afterwards is undeliverable. Cancel it so + // a still-running handler stops instead of running on unobserved: its + // terminal `send` would otherwise fail before adding the termination + // permit, leaving the serve loop parked forever. A no-op when the + // handler already completed. + request_ct.cancel(); return jsonrpc_message_response(first, true); } + // The handler may still be streaming, so guard the response: dropping it + // (client disconnect) must cancel the handler. let stream = futures::stream::once(async move { first }) .chain(ReceiverStream::new(receiver)) .map(|message| { @@ -880,7 +911,7 @@ where ServerSseMessage::from_message(message) }); Ok(sse_stream_response( - stream, + CancelOnDisconnect::new(stream, request_ct), self.config.sse_keep_alive, self.config.cancellation_token.child_token(), )) @@ -1544,7 +1575,13 @@ where request.request.extensions_mut().insert(part); let (transport, mut receiver) = OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); - let service = serve_directly(service, transport, peer_info); + // Give this stateless request its own cancellation token so a + // client disconnect can cancel the in-flight handler (#857). A + // stateless request is one-shot (no session, no resumption), so a + // dropped response is terminal and safe to cancel. + let request_ct = CancellationToken::new(); + let service = + serve_directly_with_ct(service, transport, peer_info, request_ct.clone()); tokio::spawn(async move { // on service created let _ = service.waiting().await; @@ -1554,8 +1591,19 @@ where // emits an intermediate notification or request, preserve // the complete message sequence by falling back to SSE. let cancel = self.config.cancellation_token.child_token(); + // Cancel the handler if the client disconnects while it is + // still producing its first message (this future is dropped + // before `receiver.recv()` completes). Disarmed once the + // handler emits anything, so a normal response is never + // cancelled. + let mut disconnect_guard = Some(request_ct.clone().drop_guard()); let Some(message) = (tokio::select! { - res = receiver.recv() => res, + res = receiver.recv() => { + if let Some(guard) = disconnect_guard.take() { + guard.disarm(); + } + res + } _ = cancel.cancelled() => None, }) else { return Err(internal_error_response("empty response")( @@ -1579,6 +1627,9 @@ where .body(Full::new(Bytes::from(body)).boxed()) .expect("valid response")) } else { + // The handler emitted an intermediate message and is still + // running, so guard the streamed sequence too: dropping it + // (client disconnect) must cancel the handler. let first = futures::stream::once(async move { ServerSseMessage::from_message(message) }); @@ -1587,17 +1638,19 @@ where ServerSseMessage::from_message(message) }); Ok(sse_stream_response( - first.chain(remaining), + CancelOnDisconnect::new(first.chain(remaining), request_ct), self.config.sse_keep_alive, self.config.cancellation_token.child_token(), )) } } else { - // SSE mode (default): original behaviour preserved unchanged + // SSE mode (default): cancel the handler if the client + // disconnects (drops the response stream) before it completes. let stream = ReceiverStream::new(receiver).map(|message| { tracing::trace!(?message); ServerSseMessage::from_message(message) }); + let stream = CancelOnDisconnect::new(stream, request_ct); Ok(sse_stream_response( stream, self.config.sse_keep_alive, @@ -1680,3 +1733,52 @@ where }) } } + +pin_project! { + /// Wraps a stateless SSE response stream so a client disconnect cancels the + /// in-flight request. + /// + /// A stateless streamable-HTTP request is one-shot: it has no session and no + /// resumption, so a dropped response stream means the client is gone for + /// good. When the stream is dropped *before* it ends naturally, the request's + /// cancellation token is fired, which stops the dedicated `serve_directly` + /// loop and cancels the handler's `RequestContext::ct` (see #857). If the + /// stream ends naturally (the request completed), the guard is disarmed so + /// normal completion cancels nothing. + struct CancelOnDisconnect { + #[pin] + inner: S, + ct: Option, + } + impl PinnedDrop for CancelOnDisconnect { + fn drop(this: Pin<&mut Self>) { + let this = this.project(); + if let Some(ct) = this.ct.take() { + ct.cancel(); + } + } + } +} + +impl CancelOnDisconnect { + fn new(inner: S, ct: CancellationToken) -> Self { + Self { + inner, + ct: Some(ct), + } + } +} + +impl Stream for CancelOnDisconnect { + type Item = S::Item; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.project(); + let polled = this.inner.poll_next(cx); + if let Poll::Ready(None) = &polled { + // Ended naturally: the request completed, so don't cancel on drop. + *this.ct = None; + } + polled + } +} diff --git a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs new file mode 100644 index 000000000..73d056fa1 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs @@ -0,0 +1,196 @@ +#![cfg(all( + feature = "server", + feature = "transport-streamable-http-server", + feature = "reqwest", + not(feature = "local") +))] + +//! Regression test for #857: when a stateless streamable-HTTP client disconnects +//! (drops the response) while a tool handler is still awaiting, the per-request +//! `RequestContext::ct` should fire so the handler can cancel cooperatively. +//! +//! Stateless requests are one-shot (no session, no resumption), so a dropped +//! response is terminal and safe to cancel — unlike the stateful/resumable path, +//! where a disconnect may be recovered via `Last-Event-ID`. + +use std::{sync::Arc, time::Duration}; + +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +struct CancelProbe { + started: Arc, + cancelled: Arc, +} + +impl ServerHandler for CancelProbe { + #[allow(deprecated)] + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + self.started.notify_one(); + // Wait until the per-request cancellation token fires, or give up after a + // generous timeout so a buggy build fails via the outer assertion rather + // than hanging the test. + tokio::select! { + _ = context.ct.cancelled() => { + self.cancelled.notify_one(); + Ok(CallToolResult::success(vec![ContentBlock::text("cancelled")]).into()) + } + _ = tokio::time::sleep(Duration::from_secs(30)) => { + Ok(CallToolResult::success(vec![ContentBlock::text("ran_to_completion")]).into()) + } + } + } +} + +const CALL_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"wait_for_cancel","arguments":{}}}"#; + +struct TestServer { + url: String, + server_ct: CancellationToken, + started: Arc, + cancelled: Arc, +} + +async fn spawn_stateless_server(json_response: bool) -> anyhow::Result { + let started = Arc::new(Notify::new()); + let cancelled = Arc::new(Notify::new()); + let probe = CancelProbe { + started: started.clone(), + cancelled: cancelled.clone(), + }; + + let server_ct = CancellationToken::new(); + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(json_response) + // A short keep-alive lets the SSE server notice a dropped connection + // quickly (hyper only observes the disconnect on its next write). + .with_sse_keep_alive(Some(Duration::from_millis(100))) + .with_cancellation_token(server_ct.child_token()); + + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(probe.clone()), + Arc::new(LocalSessionManager::default()), + config, + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn({ + let ct = server_ct.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + Ok(TestServer { + url: format!("http://{addr}/mcp"), + server_ct, + started, + cancelled, + }) +} + +/// SSE mode: the response is a stream; dropping it (client disconnect) must fire +/// the handler's cancellation token. +#[tokio::test] +async fn stateless_sse_client_disconnect_cancels_request() -> anyhow::Result<()> { + let server = spawn_stateless_server(false).await?; + let client = reqwest::Client::builder() + .pool_max_idle_per_host(0) + .build()?; + + // A single self-contained tools/call (no session, no initialize handshake). + let call = client + .post(&server.url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-03-26") + .body(CALL_BODY) + .send() + .await?; + assert!( + call.status().is_success(), + "tools/call failed: {:?}", + call.status() + ); + + tokio::time::timeout(Duration::from_secs(5), server.started.notified()) + .await + .expect("tool handler should start"); + + // Client disconnects mid-call: drop the streaming response (and the client). + drop(call); + drop(client); + + tokio::time::timeout(Duration::from_secs(10), server.cancelled.notified()) + .await + .expect("RequestContext::ct should fire after client disconnect (SSE)"); + + server.server_ct.cancel(); + Ok(()) +} + +/// JSON-direct mode: the server holds the connection open awaiting the single +/// response. A client that disconnects while the handler is running must still +/// fire the handler's cancellation token. +#[tokio::test] +async fn stateless_json_client_disconnect_cancels_request() -> anyhow::Result<()> { + let server = spawn_stateless_server(true).await?; + let client = reqwest::Client::builder() + .pool_max_idle_per_host(0) + .build()?; + + // In JSON mode the server does not respond until the handler completes, so + // the request stays pending; drive it from a task we can abort to disconnect. + let url = server.url.clone(); + let req_task = tokio::spawn(async move { + let _ = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-03-26") + .body(CALL_BODY) + .send() + .await; + // Keep the client alive until the request future is dropped by abort(). + drop(client); + }); + + tokio::time::timeout(Duration::from_secs(5), server.started.notified()) + .await + .expect("tool handler should start"); + + // Client disconnects: abort the in-flight request, closing the connection. + req_task.abort(); + + tokio::time::timeout(Duration::from_secs(10), server.cancelled.notified()) + .await + .expect("RequestContext::ct should fire after client disconnect (JSON)"); + + server.server_ct.cancel(); + Ok(()) +} From 7d811c49d1b9bc6b0819784e89f08c79830786bf Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 17 Jul 2026 21:20:30 -0400 Subject: [PATCH 243/333] fix(auth): add an SDK path for pre-registered OAuth clients (#994) ... and exercise it in conformance tests --- conformance/src/bin/client.rs | 54 +++--- crates/rmcp/src/transport/auth.rs | 270 ++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+), 34 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 63a116ff4..0183532fa 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -245,50 +245,36 @@ async fn perform_oauth_flow( Ok(AuthClient::new(reqwest::Client::default(), am)) } -/// Like `perform_oauth_flow` but uses pre-registered client credentials. +/// Like `perform_oauth_flow` but uses pre-registered client credentials, +/// exercising the SDK's high-level `OAuthState` path (no DCR). async fn perform_oauth_flow_preregistered( server_url: &str, client_id: &str, client_secret: &str, ) -> anyhow::Result> { - let mut manager = AuthorizationManager::new(server_url).await?; - let metadata = manager.discover_metadata().await?; - manager.set_metadata(metadata); + let mut oauth = OAuthState::new(server_url, None).await?; - // Configure with pre-registered credentials let config = rmcp::transport::auth::OAuthClientConfig::new(client_id, REDIRECT_URI) .with_client_secret(client_secret); - manager.configure_client(config)?; + oauth + .start_authorization_with_preregistered_client(config) + .await?; + + let auth_url = oauth.get_authorization_url().await?; + let callback = headless_authorize(&auth_url).await?; + oauth + .handle_callback_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await?; - let scopes = manager.select_scopes(None, &[]); - let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let auth_url = manager.get_authorization_url(&scope_refs).await?; + let am = oauth + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; - // Headless redirect - let http = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build()?; - let resp = http.get(&auth_url).send().await?; - let location = resp - .headers() - .get("location") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| anyhow::anyhow!("No Location header"))?; - let redirect_url = url::Url::parse(location)?; - let code = redirect_url - .query_pairs() - .find(|(k, _)| k == "code") - .map(|(_, v)| v.to_string()) - .ok_or_else(|| anyhow::anyhow!("No code"))?; - let state = redirect_url - .query_pairs() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.to_string()) - .ok_or_else(|| anyhow::anyhow!("No state"))?; - - manager.exchange_code_for_token(&code, &state).await?; - - Ok(AuthClient::new(reqwest::Client::default(), manager)) + Ok(AuthClient::new(reqwest::Client::default(), am)) } /// Run the standard auth flow, then connect and exercise the server. diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 53cd9bdac..fc52c7b89 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2944,6 +2944,34 @@ impl AuthorizationSession { }) } + /// create a session using pre-registered client credentials, skipping + /// dynamic client registration and URL-based client IDs. + /// + /// The manager must already have discovered authorization server metadata. + /// + /// On failure, the manager is returned alongside the error so callers can + /// retry without losing the original configuration and stores. + pub async fn with_preregistered_client( + mut auth_manager: AuthorizationManager, + config: OAuthClientConfig, + ) -> Result { + let redirect_uri = config.redirect_uri.clone(); + let scopes = config.scopes.clone(); + if let Err(e) = auth_manager.configure_client(config) { + return Err((auth_manager, e)); + } + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + let auth_url = match auth_manager.get_authorization_url(&scope_refs).await { + Ok(url) => url, + Err(e) => return Err((auth_manager, e)), + }; + Ok(Self { + auth_manager, + auth_url, + redirect_uri, + }) + } + /// create session for scope upgrade flow (existing manager + pre-computed auth url) pub fn for_scope_upgrade( auth_manager: AuthorizationManager, @@ -3214,6 +3242,50 @@ impl OAuthState { } } + /// start authorization using pre-registered client credentials, + /// skipping dynamic client registration. + /// + /// Use this when the client was registered with the authorization server + /// out of band and already holds a `client_id` (and optionally a + /// `client_secret`). If `config.scopes` is empty, scopes are selected + /// using the SDK's normal scope-selection policy. + pub async fn start_authorization_with_preregistered_client( + &mut self, + mut config: OAuthClientConfig, + ) -> Result<(), AuthError> { + let placeholder = self.placeholder().await?; + let old = std::mem::replace(self, placeholder); + let OAuthState::Unauthorized(mut manager) = old else { + *self = old; + return Err(AuthError::InternalError( + "Already in session state".to_string(), + )); + }; + let metadata = match manager.discover_metadata().await { + Ok(metadata) => metadata, + Err(e) => { + *self = OAuthState::Unauthorized(manager); + return Err(e); + } + }; + manager.metadata = Some(metadata); + if config.scopes.is_empty() { + config.scopes = manager.select_scopes(None, &[]); + } else { + manager.add_offline_access_if_supported(&mut config.scopes); + } + match AuthorizationSession::with_preregistered_client(manager, config).await { + Ok(session) => { + *self = OAuthState::Session(session); + Ok(()) + } + Err((manager, e)) => { + *self = OAuthState::Unauthorized(manager); + Err(e) + } + } + } + /// complete authorization pub async fn complete_authorization(&mut self) -> Result<(), AuthError> { let placeholder = self.placeholder().await?; @@ -3844,6 +3916,204 @@ mod tests { ); } + fn preregistered_as_metadata_response() -> HttpResponse { + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "registration_endpoint": "https://auth.example.com/register", + "scopes_supported": ["read", "write", "offline_access"] + }), + ) + } + + /// discovery responses for the preregistered-client tests: a 401 challenge + /// pointing at protected resource metadata, the PRM document, then the + /// authorization server metadata. + fn preregistered_discovery_responses() -> Vec { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + preregistered_as_metadata_response(), + ] + } + + fn auth_url_query(auth_url: &str) -> HashMap { + Url::parse(auth_url) + .unwrap() + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() + } + + #[tokio::test] + async fn preregistered_client_skips_registration_endpoint() { + let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let config = OAuthClientConfig { + client_id: "preregistered-client".to_string(), + client_secret: Some("secret".to_string()), + scopes: vec!["read".to_string()], + redirect_uri: "http://localhost:8080/callback".to_string(), + application_type: None, + }; + state + .start_authorization_with_preregistered_client(config) + .await + .unwrap(); + + // the registration endpoint was advertised but must not be called + let requests = client.requests(); + assert!( + requests + .iter() + .all(|request| !request.uri.contains("/register")), + "registration endpoint should not be called: {requests:?}" + ); + + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "preregistered-client"); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + #[tokio::test] + async fn preregistered_client_selects_default_scopes_when_none_provided() { + let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let config = OAuthClientConfig { + client_id: "preregistered-client".to_string(), + client_secret: None, + scopes: Vec::new(), + redirect_uri: "http://localhost:8080/callback".to_string(), + application_type: None, + }; + state + .start_authorization_with_preregistered_client(config) + .await + .unwrap(); + + // empty config scopes fall back to the discovered scopes_supported + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("scope").unwrap(), "read write offline_access"); + } + + #[tokio::test] + async fn preregistered_client_uses_explicit_scopes_and_adds_offline_access() { + let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let config = OAuthClientConfig { + client_id: "preregistered-client".to_string(), + client_secret: None, + scopes: vec!["read".to_string()], + redirect_uri: "http://localhost:8080/callback".to_string(), + application_type: None, + }; + state + .start_authorization_with_preregistered_client(config) + .await + .unwrap(); + + // explicit scopes are preserved; offline_access is appended per SEP-2207 + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("scope").unwrap(), "read offline_access"); + } + + #[tokio::test] + async fn preregistered_client_recovers_unauthorized_state_after_discovery_failure() { + // first discovery attempt fails: protected resource metadata reports a + // mismatched resource identifier, which discover_metadata rejects + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://other.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let config = OAuthClientConfig { + client_id: "preregistered-client".to_string(), + client_secret: None, + scopes: vec!["read".to_string()], + redirect_uri: "http://localhost:8080/callback".to_string(), + application_type: None, + }; + let err = state + .start_authorization_with_preregistered_client(config.clone()) + .await + .unwrap_err(); + assert!(!matches!(err, AuthError::InternalError(_)), "{err:?}"); + assert!( + matches!(state, super::OAuthState::Unauthorized(_)), + "state should return to Unauthorized after a transient failure" + ); + + // retrying with the same state succeeds once the server responds + client + .responses + .lock() + .unwrap() + .extend(preregistered_discovery_responses()); + state + .start_authorization_with_preregistered_client(config) + .await + .unwrap(); + assert!(matches!(state, super::OAuthState::Session(_))); + } + #[tokio::test] async fn discovery_get_follows_same_origin_redirects() { let client = RecordingOAuthHttpClient::with_responses(vec![ From 8efc142e515ff47afb6bdc39d58b0bb2b3c1d559 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Sat, 18 Jul 2026 06:44:38 -0400 Subject: [PATCH 244/333] feat(auth): bind DCR client credentials to issuing authorization server (SEP-2352) (#998) * feat(auth): bind DCR client credentials to issuing authorization server (SEP-2352) * fix(auth): address SEP-2352 review feedback on AS credential binding --- conformance/src/bin/client.rs | 67 ++++++++++++++++++++++++- crates/rmcp/src/transport/auth.rs | 83 +++++++++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 0183532fa..381cf4e48 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -4,7 +4,7 @@ use rmcp::{ service::RequestContext, transport::{ AuthClient, AuthorizationManager, StreamableHttpClientTransport, - auth::{AuthorizationCallback, OAuthState}, + auth::{AuthorizationCallback, InMemoryCredentialStore, OAuthState}, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -481,6 +481,66 @@ async fn run_auth_scope_retry_limit_client( Ok(()) } +async fn migration_token( + server_url: &str, + store: &InMemoryCredentialStore, +) -> anyhow::Result { + let mut manager = AuthorizationManager::new(server_url).await?; + manager.set_credential_store(store.clone()); + + if manager.initialize_from_store().await? { + return Ok(manager.get_access_token().await?); + } + + let metadata = manager.discover_metadata().await?; + manager.set_metadata(metadata); + manager + .register_client("conformance-client", REDIRECT_URI, &[]) + .await?; + + let scopes = manager.select_scopes(None, &[]); + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + let auth_url = manager.get_authorization_url(&scope_refs).await?; + let callback = headless_authorize(&auth_url).await?; + manager + .exchange_code_for_token_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await?; + + Ok(manager.get_access_token().await?) +} + +async fn run_auth_server_migration_client( + server_url: &str, + _ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let store = InMemoryCredentialStore::new(); + let http = reqwest::Client::new(); + let body = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}); + + let mut token = migration_token(server_url, &store).await?; + for _ in 0..3 { + let resp = http + .post(server_url) + .header( + "MCP-Protocol-Version", + conformance_protocol_version().as_str(), + ) + .bearer_auth(&token) + .json(&body) + .send() + .await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + token = migration_token(server_url, &store).await?; + } + } + + Ok(()) +} + /// Auth flow with pre-registered credentials (from context). async fn run_auth_preregistered_client( server_url: &str, @@ -942,6 +1002,11 @@ async fn main() -> anyhow::Result<()> { // Auth - scope retry limit "auth/scope-retry-limit" => run_auth_scope_retry_limit_client(&server_url, &ctx).await?, + // Auth - authorization server migration (SEP-2352) + "auth/authorization-server-migration" => { + run_auth_server_migration_client(&server_url, &ctx).await? + } + // Auth - pre-registration "auth/pre-registration" => run_auth_preregistered_client(&server_url, &ctx).await?, diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index fc52c7b89..f61f30364 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -201,6 +201,8 @@ pub struct StoredCredentials { pub granted_scopes: Vec, #[serde(default)] pub token_received_at: Option, + #[serde(default)] + pub issuer: Option, } impl std::fmt::Debug for StoredCredentials { @@ -213,6 +215,7 @@ impl std::fmt::Debug for StoredCredentials { ) .field("granted_scopes", &self.granted_scopes) .field("token_received_at", &self.token_received_at) + .field("issuer", &self.issuer) .finish() } } @@ -230,8 +233,14 @@ impl StoredCredentials { token_response, granted_scopes, token_received_at, + issuer: None, } } + + pub fn with_issuer(mut self, issuer: Option) -> Self { + self.issuer = issuer; + self + } } /// Trait for storing and retrieving OAuth2 credentials @@ -1088,6 +1097,49 @@ impl AuthorizationManager { self.metadata = Some(metadata); } + if let (Some(stored_issuer), Some(current_issuer)) = + (stored.issuer.as_deref(), self.metadata_issuer().as_deref()) + { + // A CIMD client ID is the client's metadata URL, so it is + // portable across authorization servers and exempt here. + if stored_issuer != current_issuer { + if is_https_url(&stored.client_id) { + // A CIMD client ID is the client's metadata URL, so it is + // portable across authorization servers — but the tokens + // were minted by the previous AS and must not be reused. + tracing::warn!( + stored_issuer, + current_issuer, + "authorization server issuer changed; discarding tokens but keeping portable CIMD client ID" + ); + self.credential_store + .save( + StoredCredentials::new( + stored.client_id.clone(), + None, + vec![], + None, + ) + .with_issuer(self.metadata_issuer()), + ) + .await?; + self.configure_client_id(&stored.client_id)?; + return Ok(false); + } + + tracing::warn!( + stored_issuer, + current_issuer, + "authorization server issuer changed; clearing stored credentials bound to the previous issuer" + ); + self.credential_store.clear().await?; + return Err(AuthError::AuthorizationServerMismatch { + expected_issuer: stored_issuer.to_string(), + received_issuer: current_issuer.to_string(), + }); + } + } + self.configure_client_id(&stored.client_id)?; return Ok(true); } @@ -1703,6 +1755,7 @@ impl AuthorizationManager { token_response: Some(token_result.clone()), granted_scopes, token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), }; self.credential_store.save(stored).await?; @@ -1716,6 +1769,10 @@ impl AuthorizationManager { .as_secs() } + fn metadata_issuer(&self) -> Option { + self.metadata.as_ref().and_then(|m| m.issuer.clone()) + } + /// Proactive refresh buffer: refresh tokens this many seconds before they expire /// to avoid races between token retrieval and the actual HTTP request. const REFRESH_BUFFER_SECS: u64 = 30; @@ -1838,6 +1895,7 @@ impl AuthorizationManager { token_response: Some(token_result.clone()), granted_scopes, token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), }; self.credential_store.save(stored).await?; @@ -2658,6 +2716,7 @@ impl AuthorizationManager { token_response: Some(token_result.clone()), granted_scopes, token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), }; self.credential_store.save(stored).await?; @@ -2780,6 +2839,7 @@ impl AuthorizationManager { token_response: Some(token_result.clone()), granted_scopes, token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), }; self.credential_store.save(stored).await?; @@ -3170,17 +3230,18 @@ impl OAuthState { *manager.current_scopes.write().await = granted_scopes.clone(); + let metadata = manager.discover_metadata().await?; + manager.metadata = Some(metadata); + let stored = StoredCredentials { client_id: client_id.to_string(), token_response: Some(credentials), granted_scopes, token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: manager.metadata_issuer(), }; manager.credential_store.save(stored).await?; - let metadata = manager.discover_metadata().await?; - manager.metadata = Some(metadata); - manager.configure_client_id(client_id)?; *self = OAuthState::Authorized(manager); @@ -4887,6 +4948,7 @@ mod tests { token_response: Some(token_response), granted_scopes: vec![], token_received_at: None, + issuer: None, }; let debug_output = format!("{:?}", creds); @@ -5864,6 +5926,7 @@ mod tests { token_response: Some(make_token_response("my-access-token", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5881,6 +5944,7 @@ mod tests { token_response: Some(make_token_response("stale-token", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5899,6 +5963,7 @@ mod tests { token_response: Some(make_token_response("no-expiry-token", None)), granted_scopes: vec![], token_received_at: None, + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5916,6 +5981,7 @@ mod tests { token_response: Some(make_token_response("almost-expired", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs() - 3590), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5934,6 +6000,7 @@ mod tests { token_response: Some(make_token_response("stale-token", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6235,6 +6302,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }) .await .unwrap(); @@ -6263,6 +6331,7 @@ mod tests { token_response: None, granted_scopes: vec![], token_received_at: None, + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6283,6 +6352,7 @@ mod tests { token_response: Some(make_token_response("old-token", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6373,6 +6443,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }) .await .unwrap(); @@ -6578,6 +6649,7 @@ mod tests { )), granted_scopes: vec!["read".to_string(), "write".to_string()], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6615,6 +6687,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6652,6 +6725,7 @@ mod tests { )), granted_scopes: vec!["read".to_string()], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6689,6 +6763,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6727,6 +6802,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6790,6 +6866,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); From 61b40afdcac2c17ae851c074dfba1350a3d2fb86 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Sat, 18 Jul 2026 09:24:25 -0400 Subject: [PATCH 245/333] fix: enumerate conformance client scenarios (#1008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #1001. Restores explicit dispatch arms for the ten client scenarios orphaned by #991: standard auth flow: - `auth/iss-*` - `auth/offline-access-*` - `auth/metadata-issuer-mismatch` - `auth/authorization-server-migration` plain connect/list client: `json-schema-ref-no-deref` `run_auth_client` now negotiates via `ClientLifecycleMode::Discover` — `auth/authorization-server-migration` tracked in #879 --- conformance/src/bin/client.rs | 58 +++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 381cf4e48..7f481f6bc 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -286,7 +286,17 @@ async fn run_auth_client(server_url: &str, ctx: &ConformanceContext) -> anyhow:: StreamableHttpClientTransportConfig::with_uri(server_url), ); - let client = BasicClientHandler.serve(transport).await?; + // The 2026-07-28 auth mocks require the modern per-request lifecycle + // (MCP-Protocol-Version header on every request), so negotiate via the + // discover lifecycle rather than the legacy initialize handshake. + let client = BasicClientHandler + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: preferred_protocol_versions(), + }, + ) + .await?; tracing::debug!("Connected (authenticated)"); let tools = client.list_tools(Default::default()).await?; @@ -899,15 +909,22 @@ fn conformance_protocol_version() -> ProtocolVersion { .unwrap_or(ProtocolVersion::V_2026_07_28) } -/// Runs draft stateless scenarios through the public discover lifecycle and -/// Streamable HTTP transport. -async fn run_discover_client(server_url: &str) -> anyhow::Result<()> { +/// Preferred protocol versions for discover-lifecycle negotiation: the +/// runner-provided version first, then all other known versions newest-first. +fn preferred_protocol_versions() -> Vec { let mut preferred_versions = vec![conformance_protocol_version()]; for version in ProtocolVersion::KNOWN_VERSIONS.iter().rev() { if !preferred_versions.contains(version) { preferred_versions.push(version.clone()); } } + preferred_versions +} + +/// Runs draft stateless scenarios through the public discover lifecycle and +/// Streamable HTTP transport. +async fn run_discover_client(server_url: &str) -> anyhow::Result<()> { + let preferred_versions = preferred_protocol_versions(); let transport = StreamableHttpClientTransport::from_uri(server_url); let client = FullClientHandler .serve_with_lifecycle( @@ -968,7 +985,12 @@ async fn main() -> anyhow::Result<()> { match scenario.as_str() { // Non-auth scenarios "initialize" => run_basic_client(&server_url).await?, - "json-schema-ref-no-deref" => run_discover_client(&server_url).await?, + // SEP-2106: the scenario serves a tool whose schema carries a network + // `$ref`; the check passes when the client lists tools without + // dereferencing (fetching) that URL. A plain connect → list_tools → + // close is sufficient; the scenario's mock server does not implement + // the discover lifecycle, so `run_discover_client` hangs against it. + "json-schema-ref-no-deref" => run_basic_client(&server_url).await?, "tools_call" => run_tools_call_client(&server_url, &ctx).await?, "elicitation-sep1034-client-defaults" => { run_elicitation_defaults_client(&server_url).await? @@ -994,7 +1016,31 @@ async fn main() -> anyhow::Result<()> { | "auth/token-endpoint-auth-post" | "auth/token-endpoint-auth-none" | "auth/2025-03-26-oauth-metadata-backcompat" - | "auth/2025-03-26-oauth-endpoint-fallback" => run_auth_client(&server_url, &ctx).await?, + | "auth/2025-03-26-oauth-endpoint-fallback" + // Offline access scope handling: positive/negative variants both run + // the well-behaved flow; the referee inspects the requested scopes. + | "auth/offline-access-scope" + | "auth/offline-access-not-supported" + // SEP-2468 (RFC 9207 iss / RFC 8414 §3.3 issuer-echo). The client + // captures `iss` from the authorization redirect and passes it to the + // callback handler; the SDK validates internally. Positive scenarios + // proceed to the token endpoint; negative scenarios error out (the + // referee sets `allowClientError`). + | "auth/iss-supported" + | "auth/iss-not-advertised" + | "auth/iss-supported-missing" + | "auth/iss-wrong-issuer" + | "auth/iss-unexpected" + | "auth/iss-normalized" + | "auth/metadata-issuer-mismatch" + | "auth/metadata-issuer-mismatch" + // SEP-2352: PRM `authorization_servers` switches between calls; a + // compliant client re-registers at the new AS. Known partial failure: + // the SDK lacks issuer-stamped credential storage (#879), so the + // `sep-2352-reregister-on-as-change` check fails. Left on the standard + // flow rather than fixture-orchestrated re-registration so the + // conformance result reflects real SDK behavior. + | "auth/authorization-server-migration" => run_auth_client(&server_url, &ctx).await?, // Auth - scope step-up "auth/scope-step-up" => run_auth_scope_step_up_client(&server_url, &ctx).await?, From 7abbf29e9364bad7462487f0a2d2ff2edb46eb87 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:18:53 -0400 Subject: [PATCH 246/333] fix: pass client header conformance (#1012) --- conformance/src/bin/client.rs | 57 +++++++- .../src/transport/streamable_http_client.rs | 129 +++++++++++++++--- 2 files changed, 167 insertions(+), 19 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 7f481f6bc..e82cc65af 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -22,7 +22,7 @@ struct ConformanceToolCall { #[derive(Debug, Default, serde::Deserialize)] struct ConformanceContext { - #[serde(default)] + #[serde(default, alias = "toolCalls")] tool_calls: Vec, #[serde(default)] client_id: Option, @@ -859,8 +859,32 @@ async fn run_basic_client(server_url: &str) -> anyhow::Result<()> { } async fn run_tools_call_client(server_url: &str, ctx: &ConformanceContext) -> anyhow::Result<()> { + run_tools_call_client_with_lifecycle(server_url, ctx, ClientLifecycleMode::Initialize).await +} + +async fn run_discover_tools_call_client( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + run_tools_call_client_with_lifecycle( + server_url, + ctx, + ClientLifecycleMode::Discover { + preferred_versions: preferred_protocol_versions(), + }, + ) + .await +} + +async fn run_tools_call_client_with_lifecycle( + server_url: &str, + ctx: &ConformanceContext, + lifecycle: ClientLifecycleMode, +) -> anyhow::Result<()> { let transport = StreamableHttpClientTransport::from_uri(server_url); - let client = FullClientHandler.serve(transport).await?; + let client = FullClientHandler + .serve_with_lifecycle(transport, lifecycle) + .await?; let tools = client.list_tools(Default::default()).await?; if ctx.tool_calls.is_empty() { @@ -1000,7 +1024,7 @@ async fn main() -> anyhow::Result<()> { run_discover_client(&server_url).await? } "http-standard-headers" | "http-custom-headers" | "http-invalid-tool-headers" => { - run_tools_call_client(&server_url, &ctx).await? + run_discover_tools_call_client(&server_url, &ctx).await? } // Auth scenarios - standard OAuth flow @@ -1083,3 +1107,30 @@ async fn main() -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conformance_context_accepts_camel_case_tool_calls() { + let context: ConformanceContext = serde_json::from_value(json!({ + "toolCalls": [{ + "name": "test_custom_headers", + "arguments": { "region": "us-west1" } + }] + })) + .expect("valid conformance context"); + + assert_eq!( + context.tool_calls.first().map(|tool_call| ( + tool_call.name.as_str(), + tool_call + .arguments + .as_ref() + .and_then(|arguments| arguments.get("region")), + )), + Some(("test_custom_headers", Some(&json!("us-west1")))) + ); + } +} diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 8ba3e423c..e54a17373 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -83,19 +83,24 @@ fn request_version_headers( fn cache_tools_from_response( cache: &mut HashMap>, - message: &ServerJsonRpcMessage, + message: &mut ServerJsonRpcMessage, + protocol_version: &ProtocolVersion, ) { + if protocol_version < &ProtocolVersion::STANDARD_HEADERS { + return; + } if let ServerJsonRpcMessage::Response(response) = message { - if let ServerResult::ListToolsResult(list) = &response.result { - for tool in &list.tools { - if let Err(reason) = + if let ServerResult::ListToolsResult(list) = &mut response.result { + list.tools.retain(|tool| { + let Err(reason) = mcp_headers::validate_param_header_annotations(&tool.input_schema) - { - tracing::warn!(tool = %tool.name, "ignoring x-mcp-header annotations: {reason}"); - continue; - } - cache.insert(tool.name.to_string(), tool.input_schema.clone()); - } + else { + cache.insert(tool.name.to_string(), tool.input_schema.clone()); + return true; + }; + tracing::warn!(tool = %tool.name, "rejecting invalid x-mcp-header annotations: {reason}"); + false + }); } } } @@ -1213,10 +1218,11 @@ impl Worker for StreamableHttpClientWorker { ); Ok(()) } - Ok(StreamableHttpPostResponse::Json(msg, ..)) => { + Ok(StreamableHttpPostResponse::Json(mut msg, ..)) => { cache_tools_from_response( &mut tool_header_cache, - &msg, + &mut msg, + &negotiated_version, ); context.send_to_handler(msg).await?; Ok(()) @@ -1262,8 +1268,12 @@ impl Worker for StreamableHttpClientWorker { tracing::trace!("client message accepted"); Ok(()) } - Ok(StreamableHttpPostResponse::Json(message, ..)) => { - cache_tools_from_response(&mut tool_header_cache, &message); + Ok(StreamableHttpPostResponse::Json(mut message, ..)) => { + cache_tools_from_response( + &mut tool_header_cache, + &mut message, + &negotiated_version, + ); context.send_to_handler(message).await?; Ok(()) } @@ -1311,12 +1321,16 @@ impl Worker for StreamableHttpClientWorker { } let _ = responder.send(send_result); } - Event::ServerMessage(json_rpc_message) => { + Event::ServerMessage(mut json_rpc_message) => { Self::clear_stream_response_pending( &mut pending_stream_response_ids, &json_rpc_message, ); - cache_tools_from_response(&mut tool_header_cache, &json_rpc_message); + cache_tools_from_response( + &mut tool_header_cache, + &mut json_rpc_message, + &negotiated_version, + ); // send the message to the handler if let Err(e) = context.send_to_handler(json_rpc_message).await { break 'main_loop Err(e); @@ -1669,3 +1683,86 @@ impl Default for StreamableHttpClientTransportConfig { } } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::model::{ListToolsResult, NumberOrString, ServerResult, Tool}; + + fn tool(name: &'static str, annotation: serde_json::Value) -> Tool { + let schema = json!({ + "type": "object", + "properties": { + "value": annotation, + }, + }); + Tool::new( + name, + name, + Arc::new(schema.as_object().expect("object schema").clone()), + ) + } + + #[test] + fn cache_tools_removes_invalid_header_annotations() { + let valid = tool( + "valid", + json!({ "type": "string", "x-mcp-header": "Value" }), + ); + let invalid = tool("invalid", json!({ "type": "string", "x-mcp-header": "" })); + let mut message = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::with_all_items(vec![valid, invalid])), + NumberOrString::Number(1), + ); + let mut cache = HashMap::new(); + + cache_tools_from_response(&mut cache, &mut message, &ProtocolVersion::V_2026_07_28); + + let ServerJsonRpcMessage::Response(response) = &mut message else { + panic!("expected tools/list response"); + }; + let ServerResult::ListToolsResult(result) = &mut response.result else { + panic!("expected tools/list result"); + }; + assert_eq!( + ( + result + .tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(), + cache.keys().map(String::as_str).collect::>(), + ), + (vec!["valid"], vec!["valid"]) + ); + } + + #[test] + fn cache_tools_preserves_pre_standard_header_results() { + let invalid = tool("legacy", json!({ "type": "string", "x-mcp-header": "" })); + let mut message = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::with_all_items(vec![invalid])), + NumberOrString::Number(1), + ); + let mut cache = HashMap::new(); + + cache_tools_from_response(&mut cache, &mut message, &ProtocolVersion::V_2025_11_25); + + let ServerJsonRpcMessage::Response(response) = message else { + panic!("expected tools/list response"); + }; + let ServerResult::ListToolsResult(result) = response.result else { + panic!("expected tools/list result"); + }; + assert_eq!( + result + .tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(), + vec!["legacy"] + ); + } +} From a5bb51c9d2c99a75adf30a8dfc7aa8231dce7e1a Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 20 Jul 2026 14:51:50 -0400 Subject: [PATCH 247/333] fix(server): serve draft-version requests statelessly per SEP-2567 (#999) * fix(server): serve draft-version requests statelessly per SEP-2567 * refactor: address PR feedback * refactor!: rename stateful_mode to legacy_session_mode * fix: address PR feedback --- conformance/src/bin/server.rs | 2 +- crates/rmcp/CHANGELOG.md | 4 + .../streamable_http_server/session.rs | 2 +- .../transport/streamable_http_server/tower.rs | 85 ++++++++++++++++--- .../test_discover_http_client_startup.rs | 2 +- .../rmcp/tests/test_server_discover_http.rs | 12 +-- .../tests/test_stateless_protocol_version.rs | 2 +- .../test_streamable_http_disconnect_cancel.rs | 2 +- .../test_streamable_http_json_response.rs | 10 +-- .../tests/test_streamable_http_priming.rs | 4 +- .../test_streamable_http_protocol_version.rs | 4 +- .../test_streamable_http_session_store.rs | 6 +- .../test_streamable_http_standard_headers.rs | 2 +- 13 files changed, 103 insertions(+), 34 deletions(-) diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 4df4812d6..b83b53843 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -1229,7 +1229,7 @@ async fn main() -> anyhow::Result<()> { let server = ConformanceServer::new(); let stateless = std::env::var_os("STATELESS").is_some(); let config = StreamableHttpServerConfig::default() - .with_stateful_mode(!stateless) + .with_legacy_session_mode(!stateless) .with_json_response(stateless); let service = StreamableHttpService::new( move || Ok(server.clone()), diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 1b6c212ec..dbfb14623 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING**: rename `StreamableHttpServerConfig::stateful_mode` to `legacy_session_mode` (and the builder `with_stateful_mode` to `with_legacy_session_mode`) to clarify that the option only affects legacy protocol versions (`< 2026-07-28`); per SEP-2567 the `2026-07-28` draft version is always served statelessly ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999)) + ## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08 ### Added diff --git a/crates/rmcp/src/transport/streamable_http_server/session.rs b/crates/rmcp/src/transport/streamable_http_server/session.rs index 4be265130..ab0ff3244 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session.rs @@ -13,7 +13,7 @@ //! //! * [`local::LocalSessionManager`] — in-memory session store (default). //! * [`never::NeverSessionManager`] — rejects all session operations, used -//! when stateful mode is disabled. +//! when legacy session mode is disabled. //! //! # Custom session managers //! diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 117912e4d..f7c2d72c6 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -59,8 +59,13 @@ pub struct StreamableHttpServerConfig { pub sse_retry: Option, /// If true, the server will create a session for each request and keep it alive. /// When enabled, SSE priming events are sent to enable client reconnection. - pub stateful_mode: bool, - /// When true and `stateful_mode` is false, the server prefers + /// + /// Only applies to legacy protocol versions (`< 2026-07-28`). Per SEP-2567, + /// sessions are removed from the `2026-07-28` draft version, so requests + /// negotiating that version are always served statelessly regardless of + /// this setting. + pub legacy_session_mode: bool, + /// When true and `legacy_session_mode` is false, the server prefers /// `Content-Type: application/json` for simple request-response tools. /// If the handler emits a notification or request before the final response, /// the server falls back to `text/event-stream` so no message is lost. @@ -130,7 +135,7 @@ impl Default for StreamableHttpServerConfig { Self { sse_keep_alive: Some(Duration::from_secs(15)), sse_retry: Some(Duration::from_secs(3)), - stateful_mode: true, + legacy_session_mode: true, json_response: false, cancellation_token: CancellationToken::new(), allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], @@ -176,8 +181,8 @@ impl StreamableHttpServerConfig { self } - pub fn with_stateful_mode(mut self, stateful: bool) -> Self { - self.stateful_mode = stateful; + pub fn with_legacy_session_mode(mut self, legacy_session_mode: bool) -> Self { + self.legacy_session_mode = legacy_session_mode; self } @@ -250,6 +255,57 @@ fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> b } } +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +// SEP-2567: sessions are removed from 2026-07-28; older versions are legacy. +// Validates protocol-version consistency and returns `Ok(true)` only for a valid legacy request. +fn is_legacy_request( + message: Option<&ClientJsonRpcMessage>, + headers: &HeaderMap, +) -> Result { + let has_per_request_version = message.is_some_and(message_has_per_request_protocol_version); + validate_protocol_version_header(headers, has_per_request_version)?; + if let Some(message) = message { + if let ClientJsonRpcMessage::Request(req) = message { + if let ClientRequest::InitializeRequest(init) = &req.request { + validate_header_matches_init_body( + headers, + init.params.protocol_version.as_str(), + Some(req.id.clone()), + )?; + } + } + validate_request_protocol_version_meta(headers, message)?; + } + + let from_body = match message { + Some(ClientJsonRpcMessage::Request(req)) => match &req.request { + ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()), + _ => req.request.get_meta().protocol_version(), + }, + _ => None, + }; + let version = from_body + .or_else(|| { + headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) + }) + .unwrap_or(ProtocolVersion::V_2025_03_26); + Ok(version < ProtocolVersion::V_2026_07_28) +} + +fn method_not_allowed_response() -> BoxResponse { + Response::builder() + .status(http::StatusCode::METHOD_NOT_ALLOWED) + .header(ALLOW, "POST") + .body(Full::new(Bytes::from("Method Not Allowed")).boxed()) + .expect("valid response") +} + fn invalid_request_jsonrpc_response( id: Option, message: impl Into>, @@ -662,7 +718,7 @@ fn validate_origin_header( /// /// ## Session management /// -/// When [`StreamableHttpServerConfig::stateful_mode`] is `true` (the default), +/// When [`StreamableHttpServerConfig::legacy_session_mode`] is `true` (the default), /// the server creates a session for each client that sends an `initialize` /// request. The session ID is returned in the `Mcp-Session-Id` response header /// and the client must include it on all subsequent requests. @@ -1158,13 +1214,13 @@ where return response; } let method = request.method().clone(); - let allowed_methods = match self.config.stateful_mode { + let allowed_methods = match self.config.legacy_session_mode { true => "GET, POST, DELETE", false => "POST", }; - let result = match (method, self.config.stateful_mode) { + let result = match (method, self.config.legacy_session_mode) { (Method::POST, _) => self.handle_post(request).await, - // if we're not in stateful mode, we don't support GET or DELETE because there is no session + // if legacy session mode is disabled, we don't support GET or DELETE because there is no session (Method::GET, true) => self.handle_get(request).await, (Method::DELETE, true) => self.handle_delete(request).await, _ => { @@ -1187,6 +1243,9 @@ where B: Body + Send + 'static, B::Error: Display, { + if !is_legacy_request(None, request.headers())? { + return Ok(method_not_allowed_response()); + } // check accept header if !request .headers() @@ -1340,7 +1399,10 @@ where Err(response) => return Ok(response), }; - if self.config.stateful_mode { + let use_session = + self.config.legacy_session_mode && is_legacy_request(Some(&message), &part.headers)?; + + if use_session { // do we have a session id? let session_id = part .headers @@ -1673,6 +1735,9 @@ where B: Body + Send + 'static, B::Error: Display, { + if !is_legacy_request(None, request.headers())? { + return Ok(method_not_allowed_response()); + } // check session id let session_id = request .headers() diff --git a/crates/rmcp/tests/test_discover_http_client_startup.rs b/crates/rmcp/tests/test_discover_http_client_startup.rs index 6ce1dbe40..c6051a047 100644 --- a/crates/rmcp/tests/test_discover_http_client_startup.rs +++ b/crates/rmcp/tests/test_discover_http_client_startup.rs @@ -54,7 +54,7 @@ async fn discover_http_client_bootstraps_headers_without_initialize() { || Ok(DiscoverHttpServer), Default::default(), StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_cancellation_token(ct.child_token()), ); diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs index ad9a16d0a..3dbafb1db 100644 --- a/crates/rmcp/tests/test_server_discover_http.rs +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -32,16 +32,16 @@ impl ServerHandler for DiscoveryServer { } async fn spawn_server(json_response: bool) -> (reqwest::Client, String, CancellationToken) { - spawn_server_with_stateful_mode(json_response, false).await + spawn_server_with_legacy_session_mode(json_response, false).await } -async fn spawn_server_with_stateful_mode( +async fn spawn_server_with_legacy_session_mode( json_response: bool, - stateful_mode: bool, + legacy_session_mode: bool, ) -> (reqwest::Client, String, CancellationToken) { let cancellation_token = CancellationToken::new(); let config = StreamableHttpServerConfig::default() - .with_stateful_mode(stateful_mode) + .with_legacy_session_mode(legacy_session_mode) .with_json_response(json_response) .with_sse_keep_alive(None) .with_cancellation_token(cancellation_token.clone()); @@ -136,8 +136,8 @@ async fn discover_returns_server_metadata_without_session() { } #[tokio::test] -async fn discover_does_not_require_initialization_in_stateful_mode() { - let (client, url, cancellation_token) = spawn_server_with_stateful_mode(true, true).await; +async fn discover_does_not_require_initialization_in_legacy_session_mode() { + let (client, url, cancellation_token) = spawn_server_with_legacy_session_mode(true, true).await; let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await; diff --git a/crates/rmcp/tests/test_stateless_protocol_version.rs b/crates/rmcp/tests/test_stateless_protocol_version.rs index 5103ddd8d..1804b791b 100644 --- a/crates/rmcp/tests/test_stateless_protocol_version.rs +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -16,7 +16,7 @@ use common::calculator::Calculator; fn stateless_json_config() -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new()) diff --git a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs index 73d056fa1..31e74137d 100644 --- a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs +++ b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs @@ -81,7 +81,7 @@ async fn spawn_stateless_server(json_response: bool) -> anyhow::Result anyhow::Result<() let ct = CancellationToken::new(); let (client, url, ct) = spawn_server( StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(ct.child_token()), @@ -145,7 +145,7 @@ async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Res let ct = CancellationToken::new(); let (client, url, ct) = spawn_progress_server( StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(ct.child_token()), @@ -197,7 +197,7 @@ async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { let ct = CancellationToken::new(); let (client, url, ct) = spawn_server( StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_sse_keep_alive(None) .with_cancellation_token(ct.child_token()), ) @@ -234,9 +234,9 @@ async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { } #[tokio::test] -async fn json_response_ignored_in_stateful_mode() -> anyhow::Result<()> { +async fn json_response_ignored_in_legacy_session_mode() -> anyhow::Result<()> { let ct = CancellationToken::new(); - // json_response: true has no effect when stateful_mode: true — server still uses SSE + // json_response: true has no effect when legacy_session_mode: true — server still uses SSE let (client, url, ct) = spawn_server( StreamableHttpServerConfig::default() .with_json_response(true) diff --git a/crates/rmcp/tests/test_streamable_http_priming.rs b/crates/rmcp/tests/test_streamable_http_priming.rs index 436d48227..1e0e5e5f9 100644 --- a/crates/rmcp/tests/test_streamable_http_priming.rs +++ b/crates/rmcp/tests/test_streamable_http_priming.rs @@ -14,7 +14,7 @@ use common::calculator::Calculator; async fn test_priming_on_stream_start() -> anyhow::Result<()> { let ct = CancellationToken::new(); - // stateful_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) + // legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) let service: StreamableHttpService = StreamableHttpService::new( || Ok(Calculator::new()), @@ -416,7 +416,7 @@ async fn test_priming_on_stream_close() -> anyhow::Result<()> { let ct = CancellationToken::new(); let session_manager = Arc::new(LocalSessionManager::default()); - // stateful_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) + // legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) let service = StreamableHttpService::new( || Ok(Calculator::new()), session_manager.clone(), diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index 0ed61c0e6..cf63624ce 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -50,7 +50,7 @@ async fn spawn_server_with_manager( fn stateless_json_config() -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new()) @@ -58,7 +58,7 @@ fn stateless_json_config() -> StreamableHttpServerConfig { fn stateful_config() -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() - .with_stateful_mode(true) + .with_legacy_session_mode(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new()) } diff --git a/crates/rmcp/tests/test_streamable_http_session_store.rs b/crates/rmcp/tests/test_streamable_http_session_store.rs index 91e77029e..0c8f6a835 100644 --- a/crates/rmcp/tests/test_streamable_http_session_store.rs +++ b/crates/rmcp/tests/test_streamable_http_session_store.rs @@ -73,7 +73,7 @@ fn make_service( ) -> StreamableHttpService { StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), { let mut cfg = StreamableHttpServerConfig::default(); - cfg.stateful_mode = true; + cfg.legacy_session_mode = true; cfg.sse_keep_alive = None; cfg.cancellation_token = ct.child_token(); cfg.session_store = Some(session_store); @@ -147,7 +147,7 @@ async fn test_session_state_deleted_from_store_on_delete() -> anyhow::Result<()> let service = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager.clone(), { let mut cfg = StreamableHttpServerConfig::default(); - cfg.stateful_mode = true; + cfg.legacy_session_mode = true; cfg.sse_keep_alive = None; cfg.cancellation_token = ct.child_token(); cfg.session_store = Some(store.clone()); @@ -219,7 +219,7 @@ fn spawn_server( ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { let svc = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, { let mut cfg = StreamableHttpServerConfig::default(); - cfg.stateful_mode = true; + cfg.legacy_session_mode = true; cfg.sse_keep_alive = None; cfg.cancellation_token = ct.child_token(); cfg.session_store = session_store; diff --git a/crates/rmcp/tests/test_streamable_http_standard_headers.rs b/crates/rmcp/tests/test_streamable_http_standard_headers.rs index c2c51a7c4..510433e70 100644 --- a/crates/rmcp/tests/test_streamable_http_standard_headers.rs +++ b/crates/rmcp/tests/test_streamable_http_standard_headers.rs @@ -37,7 +37,7 @@ impl ServerHandler for HeaderValidationServer { async fn spawn_server() -> (reqwest::Client, String, CancellationToken) { let config = StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new()); From 5b053416b85de8da380fbc284d32cb773b2104d3 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:20:12 -0400 Subject: [PATCH 248/333] fix: preserve negotiated progress responses (#1005) --- .../transport/streamable_http_server/tower.rs | 8 +- .../test_streamable_http_json_response.rs | 163 ++++++++++++++++-- 2 files changed, 156 insertions(+), 15 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index f7c2d72c6..bf95a5377 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -947,7 +947,13 @@ where )) })?; - if self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK { + let terminal = matches!( + &first, + ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) + ); + if terminal + && (self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK) + { // This message is the whole reply, so `receiver` is dropped here and // anything the handler emits afterwards is undeliverable. Cancel it so // a still-running handler stops instead of running on unobserved: its diff --git a/crates/rmcp/tests/test_streamable_http_json_response.rs b/crates/rmcp/tests/test_streamable_http_json_response.rs index d36045690..8705f7812 100644 --- a/crates/rmcp/tests/test_streamable_http_json_response.rs +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -17,6 +17,41 @@ use common::calculator::Calculator; const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#; const CALL_WITH_PROGRESS_BODY: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-test-1"}}}"#; +const NEGOTIATED_CALL_BODY: &str = r#"{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "terminal", + "arguments": {}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test", + "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +}"#; +const NEGOTIATED_CALL_WITH_PROGRESS_BODY: &str = r#"{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "progress", + "arguments": {}, + "_meta": { + "progressToken": "progress-test-1", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test", + "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +}"#; #[derive(Clone)] struct ProgressServer; @@ -28,22 +63,24 @@ impl ServerHandler for ProgressServer { async fn call_tool( &self, - _request: CallToolRequestParams, + request: CallToolRequestParams, context: RequestContext, ) -> Result { - let progress_token = context - .meta - .get_progress_token() - .expect("request includes progressToken"); - context - .peer - .notify_progress( - ProgressNotificationParam::new(progress_token, 50.0) - .with_total(100.0) - .with_message("working"), - ) - .await - .expect("progress notification is delivered"); + if request.name == "progress" { + let progress_token = context + .meta + .get_progress_token() + .expect("request includes progressToken"); + context + .peer + .notify_progress( + ProgressNotificationParam::new(progress_token, 50.0) + .with_total(100.0) + .with_message("working"), + ) + .await + .expect("progress notification is delivered"); + } Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) } } @@ -140,6 +177,49 @@ async fn stateless_json_response_returns_application_json() -> anyhow::Result<() Ok(()) } +#[tokio::test] +async fn stateless_negotiated_terminal_response_returns_application_json() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_progress_server( + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", "terminal") + .body(NEGOTIATED_CALL_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("application/json"), + "Expected application/json, got: {content_type}" + ); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["id"], 2); + assert!(body["result"].is_object(), "Expected result object"); + + ct.cancel(); + Ok(()) +} + #[tokio::test] async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> { let ct = CancellationToken::new(); @@ -192,6 +272,61 @@ async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Res Ok(()) } +#[tokio::test] +async fn stateless_negotiated_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> { + let ct = CancellationToken::new(); + let (client, url, ct) = spawn_progress_server( + StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()), + ) + .await; + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", "progress") + .body(NEGOTIATED_CALL_WITH_PROGRESS_BODY) + .send() + .await?; + + assert_eq!(response.status(), 200); + + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.contains("text/event-stream"), + "Expected SSE fallback, got: {content_type}" + ); + + let body = response.text().await?; + let messages: Vec = body + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .filter(|data| !data.is_empty()) + .map(serde_json::from_str) + .collect::>()?; + assert_eq!(messages.len(), 2, "Expected progress and result: {body}"); + assert_eq!(messages[0]["method"], "notifications/progress"); + assert_eq!(messages[1]["id"], 2); + assert!( + messages[1]["result"].is_object(), + "Expected result object: {body}" + ); + + ct.cancel(); + Ok(()) +} + #[tokio::test] async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { let ct = CancellationToken::new(); From c87b6422f730ba1b518a82ee31c8605bad469810 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 20 Jul 2026 17:23:22 -0400 Subject: [PATCH 249/333] fix: .with_stateful_mode -> .with_legacy_session_mode (#1015) --- crates/rmcp/tests/test_streamable_http_json_response.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/tests/test_streamable_http_json_response.rs b/crates/rmcp/tests/test_streamable_http_json_response.rs index 8705f7812..b1c09f512 100644 --- a/crates/rmcp/tests/test_streamable_http_json_response.rs +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -182,7 +182,7 @@ async fn stateless_negotiated_terminal_response_returns_application_json() -> an let ct = CancellationToken::new(); let (client, url, ct) = spawn_progress_server( StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(ct.child_token()), @@ -277,7 +277,7 @@ async fn stateless_negotiated_json_response_falls_back_to_sse_for_progress() -> let ct = CancellationToken::new(); let (client, url, ct) = spawn_progress_server( StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(ct.child_token()), From dd11e5be998275e10b2ad28cb3aa099e34d81f31 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 20 Jul 2026 17:24:30 -0400 Subject: [PATCH 250/333] chore: run full conformance CI suite (#1010) --- .github/workflows/conformance.yml | 66 +++++++------------ conformance/expected-failures-2026-07-28.yaml | 34 ++++++++++ conformance/src/bin/client.rs | 66 ++++++++++++------- 3 files changed, 99 insertions(+), 67 deletions(-) create mode 100644 conformance/expected-failures-2026-07-28.yaml diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 817c7d4ed..384c372cc 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,11 +14,15 @@ concurrency: env: # Pinned for reproducible runs; bump deliberately when the suite updates. CONFORMANCE_VERSION: "0.1.16" + # When updating DRAFT_CONFORMANCE_VERSION, diff + # `conformance list --spec-version 2026-07-28` + # and update #977 DRAFT_CONFORMANCE_VERSION: "0.2.0-alpha.9" jobs: server: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read steps: @@ -81,7 +85,9 @@ jobs: echo "draft conformance server did not become ready" >&2 exit 1 - # Run discovery separately until #985 enables the full draft suite. + # useful for enforcing partial conformance for SEP-2575. + # when `server-stateless` is removed from `expected-failures-2026-07-28.yaml`, + # this step can be removed. - name: Run SEP-2575 discovery contract run: | endpoint=http://127.0.0.1:8002/mcp @@ -91,6 +97,8 @@ jobs: -H "Mcp-Method: server/discover" ) + # The stateless server streams responses as SSE, so unwrap the + # `data:` payload before parsing as JSON. discover_response="$( curl --fail-with-body --silent --show-error \ "${common_headers[@]}" \ @@ -110,7 +118,8 @@ jobs: } } }' \ - "$endpoint" + "$endpoint" \ + | sed -n 's/^data: //p' )" jq -e ' .result.resultType == "complete" and @@ -152,35 +161,14 @@ jobs: (.error.data.supported | index("2026-07-28") != null) ' /tmp/unsupported-version.json - # Keep this explicit list until the full draft suite is enabled by #985. - - name: Run supported draft server scenarios + - name: Run 2026-07-28 server suite run: | - for scenario in \ - sep-2164-resource-not-found \ - caching \ - http-header-validation \ - http-custom-header-server-validation \ - input-required-result-basic-elicitation \ - input-required-result-basic-sampling \ - input-required-result-basic-list-roots \ - input-required-result-request-state \ - input-required-result-multiple-input-requests \ - input-required-result-multi-round \ - input-required-result-missing-input-response \ - input-required-result-non-tool-request \ - input-required-result-result-type \ - input-required-result-unsupported-methods \ - input-required-result-tampered-state \ - input-required-result-capability-check \ - input-required-result-ignore-extra-params \ - input-required-result-validate-input \ - ; do - npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ - --url http://127.0.0.1:8002/mcp \ - --scenario "$scenario" \ - --spec-version draft \ - -o conformance-results - done + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8002/mcp \ + --suite all \ + --spec-version 2026-07-28 \ + --expected-failures conformance/expected-failures-2026-07-28.yaml \ + -o conformance-results - name: Stop conformance servers if: always() @@ -197,6 +185,7 @@ jobs: client: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read steps: @@ -218,21 +207,14 @@ jobs: --spec-version 2025-11-25 \ -o conformance-client-results/full - # SEP-2322 MRTR client scenario (spec 2026-07-28). - - name: Run draft SEP-2322 client scenario + - name: Run 2026-07-28 client suite run: | npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ --command "$(pwd)/target/debug/conformance-client" \ - --scenario sep-2322-client-request-state \ - -o conformance-client-results/mrtr - - - name: Run draft SEP-2575 client scenario - run: | - npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ - --command "$(pwd)/target/debug/conformance-client" \ - --scenario request-metadata \ - --spec-version draft \ - -o conformance-client-results/sep-2575 + --suite all \ + --spec-version 2026-07-28 \ + --expected-failures conformance/expected-failures-2026-07-28.yaml \ + -o conformance-client-results/draft - name: Upload results if: always() diff --git a/conformance/expected-failures-2026-07-28.yaml b/conformance/expected-failures-2026-07-28.yaml new file mode 100644 index 000000000..c16286639 --- /dev/null +++ b/conformance/expected-failures-2026-07-28.yaml @@ -0,0 +1,34 @@ +# Known failures for the pinned 2026-07-28 draft conformance suite +# (@modelcontextprotocol/conformance DRAFT_CONFORMANCE_VERSION). +# +# The full suites run in CI with `--expected-failures` pointing at this file: +# - a scenario failing that is NOT listed here fails the build +# - a scenario listed here that starts passing also fails the build (stale entry), +# so remove it from this list when the underlying issue is fixed. +# +# When bumping DRAFT_CONFORMANCE_VERSION, diff +# `conformance list --spec-version 2026-07-28` and update #977. + +server: + # SEP-2575 stateless lifecycle gaps: discover capability declaration, + # missing-capability rejection, HTTP 404 for removed methods, and + # diagnostic tools (test_missing_capability, test_streaming_elicitation, + # test_logging_tool) not yet implemented. + # tracked in #1004 + - server-stateless + # SEP-2106: composition/conditional/$anchor keywords are stripped from + # published tool input schemas. + # tracked in #1003 + - json-schema-2020-12 + +client: + # Client does not yet send MCP-Protocol-Version header pre-initialize as + # required by the 2026-07-28 stateless lifecycle. + # tracked in #1002 + - tools_call + # Auth feature gaps in the 2026-07-28 auth scenarios. + # tracked in #1002 + - auth/scope-step-up + # SEP-2352: SDK lacks issuer-stamped credential storage (#879), so the + # sep-2352-reregister-on-as-change check fails. + - auth/authorization-server-migration diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index e82cc65af..54ce7c284 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -1006,25 +1006,49 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Running scenario '{}' against {}", scenario, server_url); - match scenario.as_str() { + // Safety net: some harness servers intentionally misbehave (e.g. reply + // with an id-less error instead of answering a request), which would + // leave the client waiting forever. Exit on our own before the harness's + // 30s client timeout so it never has to kill us (which has been observed + // to wedge the harness process in CI). + let timeout_secs: u64 = std::env::var("MCP_CONFORMANCE_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(25); + tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + run_scenario(&scenario, &server_url, &ctx), + ) + .await + .map_err(|_| anyhow::anyhow!("Scenario '{scenario}' timed out after {timeout_secs}s"))??; + + Ok(()) +} + +async fn run_scenario( + scenario: &str, + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + match scenario { // Non-auth scenarios - "initialize" => run_basic_client(&server_url).await?, + "initialize" => run_basic_client(server_url).await?, // SEP-2106: the scenario serves a tool whose schema carries a network // `$ref`; the check passes when the client lists tools without // dereferencing (fetching) that URL. A plain connect → list_tools → // close is sufficient; the scenario's mock server does not implement // the discover lifecycle, so `run_discover_client` hangs against it. - "json-schema-ref-no-deref" => run_basic_client(&server_url).await?, - "tools_call" => run_tools_call_client(&server_url, &ctx).await?, + "json-schema-ref-no-deref" => run_basic_client(server_url).await?, + "tools_call" => run_tools_call_client(server_url, ctx).await?, "elicitation-sep1034-client-defaults" => { - run_elicitation_defaults_client(&server_url).await? + run_elicitation_defaults_client(server_url).await? } - "sse-retry" => run_sse_retry_client(&server_url).await?, + "sse-retry" => run_sse_retry_client(server_url).await?, "request-metadata" | "sep-2322-client-request-state" => { - run_discover_client(&server_url).await? + run_discover_client(server_url).await? } "http-standard-headers" | "http-custom-headers" | "http-invalid-tool-headers" => { - run_discover_tools_call_client(&server_url, &ctx).await? + run_discover_tools_call_client(server_url, ctx).await? } // Auth scenarios - standard OAuth flow @@ -1056,34 +1080,26 @@ async fn main() -> anyhow::Result<()> { | "auth/iss-wrong-issuer" | "auth/iss-unexpected" | "auth/iss-normalized" - | "auth/metadata-issuer-mismatch" - | "auth/metadata-issuer-mismatch" - // SEP-2352: PRM `authorization_servers` switches between calls; a - // compliant client re-registers at the new AS. Known partial failure: - // the SDK lacks issuer-stamped credential storage (#879), so the - // `sep-2352-reregister-on-as-change` check fails. Left on the standard - // flow rather than fixture-orchestrated re-registration so the - // conformance result reflects real SDK behavior. - | "auth/authorization-server-migration" => run_auth_client(&server_url, &ctx).await?, + | "auth/metadata-issuer-mismatch" => run_auth_client(server_url, ctx).await?, // Auth - scope step-up - "auth/scope-step-up" => run_auth_scope_step_up_client(&server_url, &ctx).await?, + "auth/scope-step-up" => run_auth_scope_step_up_client(server_url, ctx).await?, // Auth - scope retry limit - "auth/scope-retry-limit" => run_auth_scope_retry_limit_client(&server_url, &ctx).await?, + "auth/scope-retry-limit" => run_auth_scope_retry_limit_client(server_url, ctx).await?, // Auth - authorization server migration (SEP-2352) "auth/authorization-server-migration" => { - run_auth_server_migration_client(&server_url, &ctx).await? + run_auth_server_migration_client(server_url, ctx).await? } // Auth - pre-registration - "auth/pre-registration" => run_auth_preregistered_client(&server_url, &ctx).await?, + "auth/pre-registration" => run_auth_preregistered_client(server_url, ctx).await?, // Auth - resource mismatch (should fail to auth → pass) "auth/resource-mismatch" => { // Try to auth; it should fail because PRM resource doesn't match - match run_auth_client(&server_url, &ctx).await { + match run_auth_client(server_url, ctx).await { Ok(_) => { tracing::warn!("Auth succeeded despite resource mismatch!"); } @@ -1094,12 +1110,12 @@ async fn main() -> anyhow::Result<()> { } // Auth - client credentials - "auth/client-credentials-basic" => run_client_credentials_basic(&server_url, &ctx).await?, - "auth/client-credentials-jwt" => run_client_credentials_jwt(&server_url, &ctx).await?, + "auth/client-credentials-basic" => run_client_credentials_basic(server_url, ctx).await?, + "auth/client-credentials-jwt" => run_client_credentials_jwt(server_url, ctx).await?, // Auth - cross-app access "auth/cross-app-access-complete-flow" => { - run_cross_app_access_client(&server_url, &ctx).await? + run_cross_app_access_client(server_url, ctx).await? } unknown => anyhow::bail!("Unsupported conformance scenario: {unknown}"), From 8ee9ee68f18f056ce5f9a8b001f12c5ff41c7a53 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:55:21 -0400 Subject: [PATCH 251/333] ci: fix discovery response parsing (#1016) --- .github/workflows/conformance.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 384c372cc..33fb56e81 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -97,8 +97,6 @@ jobs: -H "Mcp-Method: server/discover" ) - # The stateless server streams responses as SSE, so unwrap the - # `data:` payload before parsing as JSON. discover_response="$( curl --fail-with-body --silent --show-error \ "${common_headers[@]}" \ @@ -118,8 +116,7 @@ jobs: } } }' \ - "$endpoint" \ - | sed -n 's/^data: //p' + "$endpoint" )" jq -e ' .result.resultType == "complete" and From ff1a715b6ef480819cdc26e310c53dd775863217 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:53:04 -0400 Subject: [PATCH 252/333] feat: add subscription listen streams (SEP-2575) (#1000) * feat: add subscription listen streams * refactor: reuse legacy request helper --- .github/workflows/conformance.yml | 73 -- README.md | 101 +-- conformance/expected-failures-2026-07-28.yaml | 6 - conformance/src/bin/server.rs | 182 ++++- crates/rmcp/src/handler/client.rs | 19 + crates/rmcp/src/handler/server.rs | 119 ++- crates/rmcp/src/model.rs | 440 ++++++++++- crates/rmcp/src/model/meta.rs | 2 + crates/rmcp/src/service.rs | 192 ++++- crates/rmcp/src/service/client.rs | 409 +++++++++- crates/rmcp/src/service/server.rs | 290 ++++++- .../src/transport/common/server_side_http.rs | 29 +- .../src/transport/streamable_http_client.rs | 121 ++- .../transport/streamable_http_server/tower.rs | 23 +- .../client_json_rpc_message_schema.json | 112 ++- ...lient_json_rpc_message_schema_current.json | 112 ++- .../server_json_rpc_message_schema.json | 90 +++ ...erver_json_rpc_message_schema_current.json | 90 +++ crates/rmcp/tests/test_mrtr_behavior.rs | 9 +- crates/rmcp/tests/test_notification.rs | 1 + crates/rmcp/tests/test_subscriptions.rs | 719 ++++++++++++++++++ crates/rmcp/tests/test_subscriptions_model.rs | 237 ++++++ .../test_subscriptions_streamable_http.rs | 310 ++++++++ examples/clients/Cargo.toml | 4 + examples/clients/README.md | 7 + .../clients/src/subscriptions_streamhttp.rs | 49 ++ examples/servers/Cargo.toml | 4 + examples/servers/README.md | 7 + .../servers/src/subscriptions_streamhttp.rs | 83 ++ 29 files changed, 3617 insertions(+), 223 deletions(-) create mode 100644 crates/rmcp/tests/test_subscriptions.rs create mode 100644 crates/rmcp/tests/test_subscriptions_model.rs create mode 100644 crates/rmcp/tests/test_subscriptions_streamable_http.rs create mode 100644 examples/clients/src/subscriptions_streamhttp.rs create mode 100644 examples/servers/src/subscriptions_streamhttp.rs diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 33fb56e81..d88230d48 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -85,79 +85,6 @@ jobs: echo "draft conformance server did not become ready" >&2 exit 1 - # useful for enforcing partial conformance for SEP-2575. - # when `server-stateless` is removed from `expected-failures-2026-07-28.yaml`, - # this step can be removed. - - name: Run SEP-2575 discovery contract - run: | - endpoint=http://127.0.0.1:8002/mcp - common_headers=( - -H "Content-Type: application/json" - -H "Accept: application/json, text/event-stream" - -H "Mcp-Method: server/discover" - ) - - discover_response="$( - curl --fail-with-body --silent --show-error \ - "${common_headers[@]}" \ - -H "MCP-Protocol-Version: 2026-07-28" \ - --data '{ - "jsonrpc": "2.0", - "id": "discover", - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "conformance-workflow", - "version": "1.0.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } - }' \ - "$endpoint" - )" - jq -e ' - .result.resultType == "complete" and - (.result.supportedVersions | index("2026-07-28") != null) and - (.result.capabilities | type == "object") and - (.result.serverInfo.name | type == "string") and - .result.ttlMs == 0 and - .result.cacheScope == "private" - ' <<<"$discover_response" - - status="$( - curl --silent --show-error \ - --output /tmp/unsupported-version.json \ - --write-out "%{http_code}" \ - "${common_headers[@]}" \ - -H "MCP-Protocol-Version: 2099-01-01" \ - --data '{ - "jsonrpc": "2.0", - "id": "unsupported", - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2099-01-01", - "io.modelcontextprotocol/clientInfo": { - "name": "conformance-workflow", - "version": "1.0.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } - }' \ - "$endpoint" - )" - test "$status" = "400" - jq -e ' - .id == "unsupported" and - .error.code == -32022 and - .error.data.requested == "2099-01-01" and - (.error.data.supported | index("2026-07-28") != null) - ' /tmp/unsupported-version.json - - name: Run 2026-07-28 server suite run: | npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ diff --git a/README.md b/README.md index 0f8367377..7586d5c58 100644 --- a/README.md +++ b/README.md @@ -882,89 +882,90 @@ context.peer.notify_resource_list_changed().await?; ## Subscriptions -Clients can subscribe to specific resources. When a subscribed resource changes, the server sends a notification and the client can re-read it. +Protocol `2026-07-28` replaces `resources/subscribe`, `resources/unsubscribe`, and +the standalone HTTP GET stream with the transport-neutral, long-lived +`subscriptions/listen` request. Each requested notification category is opt-in. -**MCP Spec:** [Resources - Subscriptions](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions) +**MCP Spec:** [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions) ### Server-side -Enable subscriptions in the resources capability and implement the `subscribe()` / `unsubscribe()` handlers: +Declare the notification capabilities you serve, return the accepted subset, +and use the filter-enforcing subscription sink: ```rust -use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; -use std::sync::Arc; -use tokio::sync::Mutex; -use std::collections::HashSet; - -#[derive(Clone)] -struct MyServer { - subscriptions: Arc>>, -} +use rmcp::{ + ErrorData, ServerHandler, + model::*, + service::SubscriptionContext, +}; impl ServerHandler for MyServer { fn get_info(&self) -> ServerInfo { ServerInfo::new( ServerCapabilities::builder() - .enable_resources() - .enable_resources_subscribe() + .enable_tools() + .enable_tool_list_changed() .build(), ) } - async fn subscribe( + fn accepted_subscription_filter( &self, - request: SubscribeRequestParams, - _context: RequestContext, - ) -> Result<(), McpError> { - self.subscriptions.lock().await.insert(request.uri); - Ok(()) + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) } - async fn unsubscribe( - &self, - request: UnsubscribeRequestParams, - _context: RequestContext, - ) -> Result<(), McpError> { - self.subscriptions.lock().await.remove(&request.uri); + async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> { + if context.accepted().tools_list_changed == Some(true) { + context.sink().notify_tool_list_changed().await + .map_err(|error| ErrorData::internal_error(error.to_string(), None))?; + } + context.cancelled().await; Ok(()) } } ``` -When a subscribed resource changes, notify the client: - -```rust -// Check if the resource has subscribers, then notify -context.peer.notify_resource_updated( - ResourceUpdatedNotificationParam::new("file:///config.json"), -).await?; -``` +The SDK intersects the handler's filter with the requested categories and the +capabilities advertised by `get_info()`. It sends the acknowledgment before +`listen`, tags every sink notification with the listen request ID, and rejects +categories or resource URIs outside the accepted filter. ### Client-side ```rust use rmcp::model::*; -// Subscribe to updates for a resource -client.subscribe(SubscribeRequestParams::new("file:///config.json")).await?; +let mut subscription = client.listen( + SubscriptionFilter::builder() + .tools_list_changed() + .resource_subscription("file:///config.json") + .build(), +).await?; + +println!("accepted: {:?}", subscription.acknowledged()); +while let Some(notification) = subscription.next().await? { + println!("notification: {notification:?}"); +} -// Unsubscribe when no longer needed -client.unsubscribe(UnsubscribeRequestParams::new("file:///config.json")).await?; +subscription.cancel().await?; ``` -Handle update notifications in `ClientHandler`: +`listen()` buffers up to 64 notifications per subscription. Use +`listen_with_capacity()` to choose a different non-zero capacity; if a consumer +falls behind, `Subscription::end()` reports `SubscriptionEnd::Lagged`. -```rust -impl ClientHandler for MyClient { - async fn on_resource_updated( - &self, - params: ResourceUpdatedNotificationParam, - _context: NotificationContext, - ) { - // Re-read the resource at params.uri - } -} -``` +For older negotiated protocol versions, the deprecated `subscribe()` and +`unsubscribe()` APIs retain their legacy wire behavior. Modern Streamable HTTP +uses the listen POST response stream directly and does not use sessions, GET, +DELETE, or `Last-Event-ID`. After an abrupt transport close, call `listen` +again; subscription state is not resumed across HTTP or stdio reconnects. + +See the +[modern subscription server](examples/servers/src/subscriptions_streamhttp.rs) +and [client](examples/clients/src/subscriptions_streamhttp.rs) examples. --- diff --git a/conformance/expected-failures-2026-07-28.yaml b/conformance/expected-failures-2026-07-28.yaml index c16286639..85455fad9 100644 --- a/conformance/expected-failures-2026-07-28.yaml +++ b/conformance/expected-failures-2026-07-28.yaml @@ -10,12 +10,6 @@ # `conformance list --spec-version 2026-07-28` and update #977. server: - # SEP-2575 stateless lifecycle gaps: discover capability declaration, - # missing-capability rejection, HTTP 404 for removed methods, and - # diagnostic tools (test_missing_capability, test_streaming_elicitation, - # test_logging_tool) not yet implemented. - # tracked in #1004 - - server-stateless # SEP-2106: composition/conditional/$anchor keywords are stripped from # published tool input schemas. # tracked in #1003 diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index b83b53843..ceecca3b7 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -1,10 +1,16 @@ #![allow(deprecated)] -use std::{collections::HashSet, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; use rmcp::{ ErrorData, RoleServer, ServerHandler, model::*, - service::RequestContext, + service::{RequestContext, SubscriptionContext, SubscriptionSink}, transport::{ StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, @@ -48,7 +54,9 @@ const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!"; #[derive(Clone)] struct ConformanceServer { - subscriptions: Arc>>, + legacy_resource_subscriptions: Arc>>, + subscriptions: Arc>>, + next_subscription: Arc, log_level: Arc>, request_state_codec: RequestStateCodec, } @@ -56,7 +64,9 @@ struct ConformanceServer { impl ConformanceServer { fn new() -> Self { Self { - subscriptions: Arc::new(Mutex::new(HashSet::new())), + legacy_resource_subscriptions: Arc::new(Mutex::new(HashSet::new())), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + next_subscription: Arc::new(AtomicU64::new(0)), log_level: Arc::new(Mutex::new(LoggingLevel::Debug)), request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY), } @@ -379,22 +389,51 @@ impl ServerHandler for ConformanceServer { (name == "test_custom_header").then(custom_header_tool) } - async fn initialize( - &self, - request: InitializeRequestParams, - _cx: RequestContext, - ) -> Result { - Ok(InitializeResult::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_prompts() + .enable_prompts_list_changed() .enable_resources() + .enable_resources_subscribe() + .enable_resources_list_changed() .enable_tools() + .enable_tool_list_changed() .enable_logging() .build(), ) - .with_protocol_version(request.protocol_version) .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) - .with_instructions("Rust MCP conformance test server")) + .with_instructions("Rust MCP conformance test server") + } + + async fn initialize( + &self, + request: InitializeRequestParams, + _cx: RequestContext, + ) -> Result { + let info = self.get_info(); + Ok(InitializeResult::new(info.capabilities) + .with_protocol_version(request.protocol_version) + .with_server_info(info.server_info) + .with_instructions(info.instructions.unwrap_or_default())) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> { + let key = self.next_subscription.fetch_add(1, Ordering::Relaxed); + self.subscriptions + .lock() + .await + .insert(key, context.sink().clone()); + context.cancelled().await; + self.subscriptions.lock().await.remove(&key); + Ok(()) } async fn ping(&self, _cx: RequestContext) -> Result<(), ErrorData> { @@ -540,6 +579,46 @@ impl ServerHandler for ConformanceServer { })), ), custom_header_tool(), + Tool::new( + "test_trigger_tool_change", + "Triggers a tools/list_changed notification on matching subscriptions", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_trigger_prompt_change", + "Triggers a prompts/list_changed notification on matching subscriptions", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_missing_capability", + "Requires the sampling client capability", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_streaming_elicitation", + "Returns an input_required result containing an elicitation request", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_logging_tool", + "Emits notifications/message only when logLevel is requested", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), ]; // SEP-2322 MRTR test tools; all take no arguments. let mrtr_tools = [ @@ -922,6 +1001,81 @@ impl ServerHandler for ConformanceServer { )])) } + "test_trigger_tool_change" => { + let subscriptions = self + .subscriptions + .lock() + .await + .values() + .cloned() + .collect::>(); + for subscription in subscriptions { + let _ = subscription.notify_tool_list_changed().await; + } + Ok(CallToolResult::success(vec![ContentBlock::text( + "Tool list change triggered", + )])) + } + + "test_trigger_prompt_change" => { + let subscriptions = self + .subscriptions + .lock() + .await + .values() + .cloned() + .collect::>(); + for subscription in subscriptions { + let _ = subscription.notify_prompt_list_changed().await; + } + Ok(CallToolResult::success(vec![ContentBlock::text( + "Prompt list change triggered", + )])) + } + + "test_missing_capability" => { + let capabilities = cx.meta.client_capabilities().unwrap_or_default(); + if capabilities.sampling.is_none() { + return Err(ErrorData::missing_required_client_capability( + ClientCapabilities::builder().enable_sampling().build(), + )); + } + Ok(CallToolResult::success(vec![ContentBlock::text( + "Required capability declared", + )])) + } + + "test_streaming_elicitation" => { + let mut requests = InputRequests::new(); + requests.insert( + "streaming_elicitation".into(), + mrtr_elicitation_request( + "Provide a value", + json!({ "value": { "type": "string" } }), + json!(["value"]), + ), + ); + return Ok(InputRequiredResult::from_input_requests(requests).into()); + } + + "test_logging_tool" => { + if let Some(level) = cx.meta.log_level() { + let _ = cx + .peer + .notify_logging_message( + LoggingMessageNotificationParam::new( + level, + json!("logLevel was requested"), + ) + .with_logger("conformance-server"), + ) + .await; + } + Ok(CallToolResult::success(vec![ContentBlock::text( + "Logging tool completed", + )])) + } + _ => Err(ErrorData::invalid_params( format!("Unknown tool: {}", request.name), None, @@ -1029,7 +1183,7 @@ impl ServerHandler for ConformanceServer { request: SubscribeRequestParams, _cx: RequestContext, ) -> Result<(), ErrorData> { - let mut subs = self.subscriptions.lock().await; + let mut subs = self.legacy_resource_subscriptions.lock().await; subs.insert(request.uri.to_string()); Ok(()) } @@ -1039,7 +1193,7 @@ impl ServerHandler for ConformanceServer { request: UnsubscribeRequestParams, _cx: RequestContext, ) -> Result<(), ErrorData> { - let mut subs = self.subscriptions.lock().await; + let mut subs = self.legacy_resource_subscriptions.lock().await; subs.remove(request.uri.as_str()); Ok(()) } diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index c9097e241..99d099d65 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -66,6 +66,10 @@ impl Service for H { ServerNotification::PromptListChangedNotification(_notification_no_param) => { self.on_prompt_list_changed(context).await } + ServerNotification::SubscriptionsAcknowledgedNotification(notification) => { + self.on_subscriptions_acknowledged(notification.params, context) + .await + } ServerNotification::TaskStatusNotification(notification) => { self.on_task_status(notification.params, context).await } @@ -237,6 +241,13 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } + fn on_subscriptions_acknowledged( + &self, + params: SubscriptionsAcknowledgedNotificationParams, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } fn on_task_status( &self, @@ -365,6 +376,14 @@ macro_rules! impl_client_handler_for_wrapper { (**self).on_prompt_list_changed(context) } + fn on_subscriptions_acknowledged( + &self, + params: SubscriptionsAcknowledgedNotificationParams, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + (**self).on_subscriptions_acknowledged(params, context) + } + fn on_task_status( &self, params: TaskStatusNotificationParam, diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 689bcfc78..4c5f321c3 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -7,7 +7,7 @@ use crate::{ model::{TaskSupport, *}, service::{ MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole, - negotiate_protocol_version, + SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle, }, }; @@ -69,6 +69,8 @@ impl Service for H { )); } } + let legacy_request = + uses_legacy_lifecycle(protocol_version.as_ref(), requires_request_metadata); let result = match request { ClientRequest::InitializeRequest(request) => self .initialize(request.params, context) @@ -79,7 +81,11 @@ impl Service for H { .await .map(ServerResult::DiscoverResult), ClientRequest::PingRequest(_request) => { - self.ping(context).await.map(ServerResult::empty) + if !legacy_request { + Err(McpError::method_not_found::()) + } else { + self.ping(context).await.map(ServerResult::empty) + } } ClientRequest::CompleteRequest(request) => self .complete(request.params, context) @@ -109,14 +115,60 @@ impl Service for H { .read_resource(request.params, context) .await .map(ServerResult::from), - ClientRequest::SubscribeRequest(request) => self - .subscribe(request.params, context) - .await - .map(ServerResult::empty), - ClientRequest::UnsubscribeRequest(request) => self - .unsubscribe(request.params, context) - .await - .map(ServerResult::empty), + ClientRequest::SubscriptionsListenRequest(request) => { + if legacy_request { + Err(McpError::method_not_found::()) + } else { + let requested = request.params.notifications; + let Some(candidate) = self.accepted_subscription_filter(&requested) else { + return Err( + McpError::method_not_found::(), + ); + }; + let advertised = requested.supported_by(&self.get_info().capabilities); + let handler_accepted = requested.intersection(&candidate); + let accepted = handler_accepted.intersection(&advertised); + if accepted != handler_accepted { + tracing::debug!( + requested_resource_count = requested + .resource_subscriptions + .as_ref() + .map_or(0, Vec::len), + accepted_resource_count = + accepted.resource_subscriptions.as_ref().map_or(0, Vec::len), + "subscription filter reduced to advertised server capabilities" + ); + } + let subscription_id = context.id.clone(); + let subscription = + SubscriptionContext::establish(context, requested, accepted).await?; + // The integrated draft schema defines a final result for graceful + // server teardown; explicit stdio cancellation remains a notification. + self.listen(subscription).await.map(|()| { + ServerResult::SubscriptionsListenResult( + SubscriptionsListenResult::complete(subscription_id), + ) + }) + } + } + ClientRequest::SubscribeRequest(request) => { + if !legacy_request { + Err(McpError::method_not_found::()) + } else { + self.subscribe(request.params, context) + .await + .map(ServerResult::empty) + } + } + ClientRequest::UnsubscribeRequest(request) => { + if !legacy_request { + Err(McpError::method_not_found::()) + } else { + self.unsubscribe(request.params, context) + .await + .map(ServerResult::empty) + } + } ClientRequest::CallToolRequest(request) => { let is_task = request.params.task.is_some(); @@ -335,6 +387,36 @@ macro_rules! server_handler_methods { McpError::method_not_found::(), )) } + /// Return the subset of a requested notification filter this server accepts. + /// + /// Returning `None` leaves `subscriptions/listen` unimplemented. The SDK + /// intersects the returned filter with both `requested` and the notification + /// capabilities advertised by [`Self::get_info`] before acknowledging it. + /// Categories that were not requested or advertised are always removed. + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + None + } + /// Run one established subscription until it is cancelled or closed gracefully. + /// + /// The SDK sends the acknowledgment before invoking this method. Returning + /// `Ok(())` sends the final [`SubscriptionsListenResult`] defined by the + /// integrated draft schema, marking graceful server teardown. Explicit + /// stdio cancellation uses `notifications/cancelled` instead. + fn listen( + &self, + context: SubscriptionContext, + ) -> impl Future> + MaybeSendFuture + '_ { + async move { + context.cancelled().await; + Ok(()) + } + } + #[deprecated( + note = "resources/subscribe is legacy-only; implement accepted_subscription_filter and listen for protocol version 2026-07-28" + )] fn subscribe( &self, request: SubscribeRequestParams, @@ -342,6 +424,9 @@ macro_rules! server_handler_methods { ) -> impl Future> + MaybeSendFuture + '_ { std::future::ready(Err(McpError::method_not_found::())) } + #[deprecated( + note = "resources/unsubscribe is legacy-only; subscriptions/listen is cancelled through its request lifecycle" + )] fn unsubscribe( &self, request: UnsubscribeRequestParams, @@ -604,6 +689,20 @@ macro_rules! impl_server_handler_for_wrapper { (**self).read_resource(request, context) } + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + (**self).accepted_subscription_filter(requested) + } + + fn listen( + &self, + context: SubscriptionContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).listen(context) + } + fn subscribe( &self, request: SubscribeRequestParams, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 8da14fe53..97273d9ae 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3,8 +3,10 @@ #![expect(deprecated)] use std::{ borrow::Cow, + collections::hash_map::RandomState, + hash::{BuildHasher, Hasher}, ops::{Deref, DerefMut}, - sync::Arc, + sync::{Arc, OnceLock}, }; mod annotated; mod capabilities; @@ -558,6 +560,8 @@ pub struct ErrorData { } impl ErrorData { + const TRANSPORT_CLOSED_MARKER: &str = "io.modelcontextprotocol/transportClosed"; + pub fn new( code: ErrorCode, message: impl Into>, @@ -616,6 +620,33 @@ impl ErrorData { pub fn internal_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::INTERNAL_ERROR, message, data) } + + #[cfg(feature = "transport-streamable-http-client")] + pub(crate) fn transport_closed(message: impl Into>) -> Self { + let mut data = JsonObject::new(); + data.insert( + Self::TRANSPORT_CLOSED_MARKER.to_owned(), + Value::from(Self::transport_closed_token()), + ); + Self::internal_error(message, Some(Value::Object(data))) + } + + pub(crate) fn is_transport_closed(&self) -> bool { + self.data + .as_ref() + .and_then(|data| data.get(Self::TRANSPORT_CLOSED_MARKER)) + .and_then(Value::as_u64) + == Some(Self::transport_closed_token()) + } + + fn transport_closed_token() -> u64 { + static TOKEN: OnceLock = OnceLock::new(); + *TOKEN.get_or_init(|| { + let mut hasher = RandomState::new().build_hasher(); + hasher.write(b"rmcp transport-closed marker"); + hasher.finish() + }) + } } /// Represents any JSON-RPC message that can be sent or received. @@ -1661,6 +1692,9 @@ impl RequestParamsMeta for SubscribeRequestParams { pub type SubscribeRequestParam = SubscribeRequestParams; /// Request to subscribe to resource updates +#[deprecated( + note = "resources/subscribe is legacy-only; use subscriptions/listen for protocol version 2026-07-28" +)] pub type SubscribeRequest = Request; const_string!(UnsubscribeRequestMethod = "resources/unsubscribe"); @@ -1701,6 +1735,9 @@ impl RequestParamsMeta for UnsubscribeRequestParams { pub type UnsubscribeRequestParam = UnsubscribeRequestParams; /// Request to unsubscribe from resource updates +#[deprecated( + note = "resources/unsubscribe is legacy-only; cancel the subscriptions/listen request for protocol version 2026-07-28" +)] pub type UnsubscribeRequest = Request; const_string!(ResourceUpdatedNotificationMethod = "notifications/resources/updated"); @@ -1730,6 +1767,390 @@ impl ResourceUpdatedNotificationParam { pub type ResourceUpdatedNotification = Notification; +// ============================================================================= +// SUBSCRIPTIONS +// ============================================================================= + +/// Notification categories a client opts in to on a `subscriptions/listen` stream. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscriptionFilter { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "bool"))] + pub tools_list_changed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "bool"))] + pub prompts_list_changed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "bool"))] + pub resources_list_changed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "Vec"))] + pub resource_subscriptions: Option>, +} + +impl SubscriptionFilter { + /// Create an empty filter that opts in to no notifications. + pub fn new() -> Self { + Self::default() + } + + /// Create a builder for a subscription filter. + pub fn builder() -> SubscriptionFilterBuilder { + SubscriptionFilterBuilder::default() + } + + /// Return the subset present in both filters. + pub fn intersection(&self, other: &Self) -> Self { + let resource_subscriptions = self + .resource_subscriptions + .as_ref() + .and_then(|requested| { + other.resource_subscriptions.as_ref().map(|accepted| { + requested + .iter() + .filter(|uri| accepted.contains(uri)) + .cloned() + .collect() + }) + }) + .filter(|uris: &Vec| !uris.is_empty()); + Self { + tools_list_changed: (self.tools_list_changed == Some(true) + && other.tools_list_changed == Some(true)) + .then_some(true), + prompts_list_changed: (self.prompts_list_changed == Some(true) + && other.prompts_list_changed == Some(true)) + .then_some(true), + resources_list_changed: (self.resources_list_changed == Some(true) + && other.resources_list_changed == Some(true)) + .then_some(true), + resource_subscriptions, + } + } + + /// Return whether this filter accepts only notifications requested by `other`. + pub fn is_subset_of(&self, other: &Self) -> bool { + let booleans_are_subset = [ + (self.tools_list_changed, other.tools_list_changed), + (self.prompts_list_changed, other.prompts_list_changed), + (self.resources_list_changed, other.resources_list_changed), + ] + .into_iter() + .all(|(accepted, requested)| accepted != Some(true) || requested == Some(true)); + let resources_are_subset = self.resource_subscriptions.as_ref().is_none_or(|accepted| { + accepted.iter().all(|uri| { + other + .resource_subscriptions + .as_ref() + .is_some_and(|requested| requested.contains(uri)) + }) + }); + booleans_are_subset && resources_are_subset + } + + /// Return the requested notification types advertised by server capabilities. + pub fn supported_by(&self, capabilities: &ServerCapabilities) -> Self { + Self { + tools_list_changed: (self.tools_list_changed == Some(true) + && capabilities + .tools + .as_ref() + .is_some_and(|tools| tools.list_changed == Some(true))) + .then_some(true), + prompts_list_changed: (self.prompts_list_changed == Some(true) + && capabilities + .prompts + .as_ref() + .is_some_and(|prompts| prompts.list_changed == Some(true))) + .then_some(true), + resources_list_changed: (self.resources_list_changed == Some(true) + && capabilities + .resources + .as_ref() + .is_some_and(|resources| resources.list_changed == Some(true))) + .then_some(true), + resource_subscriptions: capabilities + .resources + .as_ref() + .is_some_and(|resources| resources.subscribe == Some(true)) + .then(|| self.resource_subscriptions.clone()) + .flatten(), + } + } +} + +/// Builder for [`SubscriptionFilter`]. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct SubscriptionFilterBuilder { + filter: SubscriptionFilter, +} + +impl SubscriptionFilterBuilder { + /// Opt in to `notifications/tools/list_changed`. + pub fn tools_list_changed(mut self) -> Self { + self.filter.tools_list_changed = Some(true); + self + } + + /// Opt in to `notifications/prompts/list_changed`. + pub fn prompts_list_changed(mut self) -> Self { + self.filter.prompts_list_changed = Some(true); + self + } + + /// Opt in to `notifications/resources/list_changed`. + pub fn resources_list_changed(mut self) -> Self { + self.filter.resources_list_changed = Some(true); + self + } + + /// Opt in to updates for all supplied resource URIs. + pub fn resource_subscriptions( + mut self, + uris: impl IntoIterator>, + ) -> Self { + self.filter.resource_subscriptions = Some(uris.into_iter().map(Into::into).collect()); + self + } + + /// Add one resource URI to the update subscription set. + pub fn resource_subscription(mut self, uri: impl Into) -> Self { + self.filter + .resource_subscriptions + .get_or_insert_default() + .push(uri.into()); + self + } + + /// Build the filter. + pub fn build(self) -> SubscriptionFilter { + self.filter + } +} + +const_string!(SubscriptionsListenRequestMethod = "subscriptions/listen"); + +#[cfg(feature = "schemars")] +fn subscriptions_listen_request_meta_schema( + generator: &mut schemars::SchemaGenerator, +) -> schemars::Schema { + let progress_token = generator.subschema_for::(); + let client_info = generator.subschema_for::(); + let client_capabilities = generator.subschema_for::(); + let log_level = generator.subschema_for::(); + schemars::json_schema!({ + "type": "object", + "properties": { + "progressToken": progress_token, + "io.modelcontextprotocol/protocolVersion": { + "type": "string", + }, + "io.modelcontextprotocol/clientInfo": client_info, + "io.modelcontextprotocol/clientCapabilities": client_capabilities, + "io.modelcontextprotocol/logLevel": log_level, + }, + "required": RequestMetaObject::DRAFT_REQUIRED_KEYS, + "additionalProperties": true, + }) +} + +/// Parameters for opening a long-lived notification subscription. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscriptionsListenRequestParams { + /// Protocol-level metadata. Required by the draft wire schema. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "schemars", + schemars(required, schema_with = "subscriptions_listen_request_meta_schema") + )] + pub meta: Option, + /// Notification categories requested for this stream. + pub notifications: SubscriptionFilter, +} + +impl SubscriptionsListenRequestParams { + /// Create listen parameters for a notification filter. + pub fn new(notifications: SubscriptionFilter) -> Self { + Self { + meta: None, + notifications, + } + } + + /// Set protocol-level request metadata. + pub fn with_meta(mut self, meta: RequestMetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +impl RequestParamsMeta for SubscriptionsListenRequestParams { + fn meta(&self) -> Option<&RequestMetaObject> { + self.meta.as_ref() + } + + fn meta_mut(&mut self) -> &mut Option { + &mut self.meta + } +} + +/// Request that opens a long-lived notification subscription. +pub type SubscriptionsListenRequest = + Request; + +const SUBSCRIPTION_ID_META_KEY: &str = "io.modelcontextprotocol/subscriptionId"; + +/// Metadata on the final result of a `subscriptions/listen` request. +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(transparent)] +#[non_exhaustive] +pub struct SubscriptionsListenResultMeta(MetaObject); + +impl SubscriptionsListenResultMeta { + /// Create result metadata for the originating listen request. + pub fn new(subscription_id: RequestId) -> Self { + let mut meta = MetaObject::new(); + meta.insert( + SUBSCRIPTION_ID_META_KEY.to_owned(), + subscription_id.into_json_value(), + ); + Self(meta) + } + + /// Return the originating listen request ID, if the metadata remains valid. + pub fn subscription_id(&self) -> Option { + self.0 + .get(SUBSCRIPTION_ID_META_KEY) + .and_then(|value| RequestId::deserialize(value).ok()) + } + + /// Replace the originating listen request ID. + pub fn set_subscription_id(&mut self, subscription_id: RequestId) { + self.0.insert( + SUBSCRIPTION_ID_META_KEY.to_owned(), + subscription_id.into_json_value(), + ); + } +} + +impl<'de> Deserialize<'de> for SubscriptionsListenResultMeta { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let meta = MetaObject::deserialize(deserializer)?; + let Some(value) = meta.get(SUBSCRIPTION_ID_META_KEY) else { + return Err(serde::de::Error::missing_field(SUBSCRIPTION_ID_META_KEY)); + }; + RequestId::deserialize(value).map_err(serde::de::Error::custom)?; + Ok(Self(meta)) + } +} + +impl std::ops::Deref for SubscriptionsListenResultMeta { + type Target = MetaObject; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for SubscriptionsListenResultMeta { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for SubscriptionsListenResultMeta { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("SubscriptionsListenResultMeta") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let subscription_id = generator.subschema_for::(); + schemars::json_schema!({ + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": subscription_id, + }, + "required": ["io.modelcontextprotocol/subscriptionId"], + "additionalProperties": true, + }) + } +} + +/// Final response indicating that a subscription ended gracefully. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscriptionsListenResult { + pub result_type: ResultType, + #[serde(rename = "_meta")] + pub meta: SubscriptionsListenResultMeta, +} + +impl SubscriptionsListenResult { + /// Create a completed subscription result. + pub fn new(meta: SubscriptionsListenResultMeta) -> Self { + Self { + result_type: ResultType::COMPLETE, + meta, + } + } + + /// Create a completed result for the originating listen request. + pub fn complete(subscription_id: RequestId) -> Self { + Self::new(SubscriptionsListenResultMeta::new(subscription_id)) + } +} + +const_string!( + SubscriptionsAcknowledgedNotificationMethod = "notifications/subscriptions/acknowledged" +); + +/// Parameters reporting the accepted subset of a subscription filter. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[non_exhaustive] +pub struct SubscriptionsAcknowledgedNotificationParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schemars", schemars(with = "NotificationMetaObject"))] + pub meta: Option, + pub notifications: SubscriptionFilter, +} + +impl SubscriptionsAcknowledgedNotificationParams { + /// Create acknowledgment parameters for the accepted filter. + pub fn new(notifications: SubscriptionFilter) -> Self { + Self { + meta: None, + notifications, + } + } + + /// Set notification metadata. + pub fn with_meta(mut self, meta: NotificationMetaObject) -> Self { + self.meta = Some(meta); + self + } +} + +/// First notification sent on an established subscription stream. +pub type SubscriptionsAcknowledgedNotification = Notification< + SubscriptionsAcknowledgedNotificationMethod, + SubscriptionsAcknowledgedNotificationParams, +>; + // ============================================================================= // PROMPT MANAGEMENT // ============================================================================= @@ -3937,6 +4358,7 @@ ts_union!( | ListResourcesRequest | ListResourceTemplatesRequest | ReadResourceRequest + | SubscriptionsListenRequest | SubscribeRequest | UnsubscribeRequest | CallToolRequest @@ -3961,6 +4383,7 @@ impl ClientRequest { ClientRequest::ListResourcesRequest(r) => r.method.as_str(), ClientRequest::ListResourceTemplatesRequest(r) => r.method.as_str(), ClientRequest::ReadResourceRequest(r) => r.method.as_str(), + ClientRequest::SubscriptionsListenRequest(r) => r.method.as_str(), ClientRequest::SubscribeRequest(r) => r.method.as_str(), ClientRequest::UnsubscribeRequest(r) => r.method.as_str(), ClientRequest::CallToolRequest(r) => r.method.as_str(), @@ -4019,6 +4442,7 @@ ts_union!( | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification + | SubscriptionsAcknowledgedNotification | TaskStatusNotification | CustomNotification; ); @@ -4033,6 +4457,7 @@ ts_union!( | ListResourcesResult | ListResourceTemplatesResult | ReadResourceResult + | SubscriptionsListenResult | ListToolsResult | ElicitResult | CreateTaskResult @@ -4096,6 +4521,19 @@ mod tests { let _: ResourceReference = ResourceTemplateReference::new("res://x"); } + #[cfg(feature = "transport-streamable-http-client")] + #[test] + fn transport_closed_marker_accepts_only_the_process_local_token() { + let local = ErrorData::transport_closed("closed"); + let spoofed = ErrorData::internal_error( + "spoofed", + Some(json!({ "io.modelcontextprotocol/transportClosed": true })), + ); + + assert!(local.is_transport_closed()); + assert!(!spoofed.is_transport_closed()); + } + #[test] fn cancelled_notification_request_id_is_optional_on_wire() { // None → requestId 생략 diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index c51fc6d7c..d86156aa9 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -197,6 +197,7 @@ variant_extension! { ListResourcesRequest ListResourceTemplatesRequest ReadResourceRequest + SubscriptionsListenRequest SubscribeRequest UnsubscribeRequest CallToolRequest @@ -239,6 +240,7 @@ variant_extension! { ResourceListChangedNotification ToolListChangedNotification PromptListChangedNotification + SubscriptionsAcknowledgedNotification TaskStatusNotification CustomNotification } diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 69c3f2105..7ef938e6d 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -85,6 +85,8 @@ pub enum ServiceError { TransportClosed, #[error("Unexpected response type")] UnexpectedResponse, + #[error("subscription consumer lagged behind its {capacity}-message buffer")] + SubscriptionLagged { capacity: usize }, #[error("task cancelled for reason {}", reason.as_deref().unwrap_or(""))] Cancelled { reason: Option }, #[error("request timeout after {}", chrono::Duration::from_std(*timeout).unwrap_or_default())] @@ -128,6 +130,20 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { const IS_CLIENT: bool; type Info: TransferObject; type PeerInfo: TransferObject; + #[doc(hidden)] + fn configure_direct_peer(_peer: &Peer, _info: &Self::Info) {} + #[doc(hidden)] + fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { + None + } +} + +pub(crate) fn uses_legacy_lifecycle( + protocol_version: Option<&ProtocolVersion>, + uses_discover_lifecycle: bool, +) -> bool { + !uses_discover_lifecycle + && protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28) } pub type TxJsonRpcMessage = @@ -340,6 +356,8 @@ impl ProgressNotificationToken for ServerNotification { type Responder = tokio::sync::oneshot::Sender; type ProgressTimeoutWatchers = Arc>>>; +type SubscriptionChannel = (mpsc::Sender, usize); +type SubscriptionChannelMap = HashMap>; /// A handle to a remote request /// @@ -400,10 +418,12 @@ impl RequestHandle { has_progress_reset_rx, ) .await; + self.peer.unregister_subscription(&self.id); result } async fn send_timeout_cancel_notification(&self, reason: &str) { + self.peer.unregister_subscription(&self.id); let notification = CancelledNotification { params: CancelledNotificationParam { request_id: Some(self.id.clone()), @@ -478,6 +498,7 @@ impl RequestHandle { self.progress_reset_rx.is_some(), ) .await; + self.peer.unregister_subscription(&self.id); let notification = CancelledNotification { params: CancelledNotificationParam { request_id: Some(self.id), @@ -530,7 +551,6 @@ pub(crate) struct ClientRequestMetadata { /// For general purpose, call [`Peer::send_request`] or [`Peer::send_notification`] to send message to remote peer. /// /// To create a cancellable request, call [`Peer::send_request_with_option`]. -#[derive(Clone)] pub struct Peer { tx: mpsc::Sender>, request_id_provider: Arc, @@ -539,6 +559,25 @@ pub struct Peer { info: Arc>>>, client_request_metadata: Arc>, request_metadata_required: Arc, + subscription_channels: Arc>>, +} + +impl Clone for Peer +where + R::PeerInfo: Clone, +{ + fn clone(&self) -> Peer { + Self { + tx: self.tx.clone(), + request_id_provider: self.request_id_provider.clone(), + progress_token_provider: self.progress_token_provider.clone(), + progress_timeout_watchers: self.progress_timeout_watchers.clone(), + info: self.info.clone(), + client_request_metadata: self.client_request_metadata.clone(), + request_metadata_required: self.request_metadata_required.clone(), + subscription_channels: self.subscription_channels.clone(), + } + } } impl std::fmt::Debug for Peer { @@ -610,6 +649,7 @@ impl Peer { info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), client_request_metadata: Default::default(), request_metadata_required: Default::default(), + subscription_channels: Default::default(), }, rx, ) @@ -641,9 +681,19 @@ impl Peer { } pub async fn send_request_with_option( + &self, + request: R::Req, + options: PeerRequestOptions, + ) -> Result, ServiceError> { + self.send_request_with_option_and_subscription(request, options, None) + .await + } + + async fn send_request_with_option_and_subscription( &self, mut request: R::Req, options: PeerRequestOptions, + subscription_sender: Option>, ) -> Result, ServiceError> { let id = self.request_id_provider.next_request_id(); let progress_token = self.progress_token_provider.next_progress_token(); @@ -670,6 +720,10 @@ impl Peer { } else { None }; + if let Some(channel) = subscription_sender { + self.subscription_channels_write() + .insert(id.clone(), channel); + } if self .tx .send(PeerSinkMessage::Request { @@ -686,6 +740,7 @@ impl Peer { .await .remove(&progress_token); } + self.unregister_subscription(&id); return Err(ServiceError::TransportClosed); } Ok(RequestHandle { @@ -698,6 +753,67 @@ impl Peer { }) } + #[cfg(feature = "client")] + pub(crate) async fn send_subscription_request( + &self, + request: R::Req, + options: PeerRequestOptions, + channel_capacity: usize, + ) -> Result<(RequestHandle, mpsc::Receiver), ServiceError> { + let (sender, receiver) = mpsc::channel(channel_capacity); + let handle = self + .send_request_with_option_and_subscription( + request, + options, + Some((sender, channel_capacity)), + ) + .await?; + Ok((handle, receiver)) + } + + fn subscription_sender(&self, id: &RequestId) -> Option> { + self.subscription_channels_read().get(id).cloned() + } + + pub(crate) fn unregister_subscription(&self, id: &RequestId) { + self.subscription_channels_write().remove(id); + } + + fn subscription_channels_read( + &self, + ) -> std::sync::RwLockReadGuard<'_, SubscriptionChannelMap> { + match self.subscription_channels.read() { + Ok(channels) => channels, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn subscription_channels_write( + &self, + ) -> std::sync::RwLockWriteGuard<'_, SubscriptionChannelMap> { + match self.subscription_channels.write() { + Ok(channels) => channels, + Err(poisoned) => poisoned.into_inner(), + } + } + + pub(crate) fn try_cancel_request(&self, id: RequestId, reason: Option) { + let notification = CancelledNotification { + params: CancelledNotificationParam { + request_id: Some(id), + reason, + meta: None, + }, + method: crate::model::CancelledNotificationMethod, + extensions: Default::default(), + }; + let (responder, _receiver) = tokio::sync::oneshot::channel(); + let _ = self.tx.try_send(PeerSinkMessage::Notification { + notification: notification.into(), + responder, + }); + } + async fn notify_progress_timeout_watcher(&self, progress_token: &ProgressToken) { let sender = self .progress_timeout_watchers @@ -999,6 +1115,7 @@ where E: std::error::Error + Send + Sync + 'static, { let (peer, peer_rx) = Peer::new(Arc::new(AtomicU32RequestIdProvider::default()), peer_info); + R::configure_direct_peer(&peer, &service.get_info()); serve_inner(service, transport.into_transport(), peer, peer_rx, ct) } @@ -1274,19 +1391,67 @@ where .. })) => { tracing::info!(?notification, "received notification"); - // catch cancelled notification - let mut notification = match notification.try_into() { - Ok::(cancelled) => { - if let Some(request_id) = &cancelled.params.request_id { - if let Some(ct) = local_ct_pool.remove(request_id) { - tracing::info!(id = %request_id, reason = cancelled.params.reason, "cancelled"); + let cancellation_request_id = + if let Some(cancelled) = R::peer_cancelled_params(¬ification) { + let request_id = cancelled.request_id.clone(); + if let Some(request_id) = request_id.as_ref() { + if R::IS_CLIENT { + if let Some(responder) = + local_responder_pool.remove(request_id) + { + let _ = responder.send(Err(ServiceError::Cancelled { + reason: cancelled.reason.clone(), + })); + } + } else if let Some(ct) = local_ct_pool.remove(request_id) { + tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled"); ct.cancel(); } } - cancelled.into() + request_id + } else { + None + }; + let subscription_id = notification + .get_meta() + .subscription_id() + .or(cancellation_request_id); + if let Some(subscription_id) = subscription_id + && let Some((sender, capacity)) = + peer.subscription_sender(&subscription_id) + { + match sender.try_send(notification) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + tracing::warn!( + id = %subscription_id, + capacity, + "subscription notification buffer full" + ); + if R::IS_CLIENT + && let Some(responder) = + local_responder_pool.remove(&subscription_id) + { + let _ = responder + .send(Err(ServiceError::SubscriptionLagged { capacity })); + } + peer.unregister_subscription(&subscription_id); + peer.try_cancel_request( + subscription_id, + Some("subscription notification buffer full".to_owned()), + ); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + peer.unregister_subscription(&subscription_id); + peer.try_cancel_request( + subscription_id, + Some("subscription notification receiver closed".to_owned()), + ); + } } - Err(notification) => notification, - }; + continue; + } + let mut notification = notification; if let Some(progress_token) = notification.progress_token() { peer.notify_progress_timeout_watcher(progress_token).await; } @@ -1331,7 +1496,12 @@ where continue; }; if let Some(responder) = local_responder_pool.remove(&id) { - let _response_result = responder.send(Err(ServiceError::McpError(error))); + let service_error = if error.is_transport_closed() { + ServiceError::TransportClosed + } else { + ServiceError::McpError(error) + }; + let _response_result = responder.send(Err(service_error)); if let Err(_error) = _response_result { tracing::warn!(%id, "Error sending response"); } diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 9ba3024d0..35deb38c8 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1,6 +1,6 @@ // Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] -use std::{borrow::Cow, sync::Arc, time::Duration}; +use std::{borrow::Cow, num::NonZeroUsize, sync::Arc, time::Duration}; use thiserror::Error; @@ -21,8 +21,9 @@ use crate::{ ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Reference, RequestId, RequestMetaObject, RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, SetLevelRequest, - SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, UnsubscribeRequest, - UnsubscribeRequestParams, + SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, SubscriptionFilter, + SubscriptionsListenRequest, SubscriptionsListenRequestParams, SubscriptionsListenResult, + UnsubscribeRequest, UnsubscribeRequestParams, }, transport::DynamicTransportError, }; @@ -184,10 +185,298 @@ impl ServiceRole for RoleClient { type PeerInfo = ServerInfo; type InitializeError = ClientInitializeError; const IS_CLIENT: bool = true; + + fn configure_direct_peer(peer: &Peer, info: &Self::Info) { + let Some(server_info) = peer.peer_info() else { + return; + }; + if server_info.protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() { + return; + } + peer.set_client_request_metadata(ClientRequestMetadata { + protocol_version: server_info.protocol_version.clone(), + client_info: info.client_info.clone(), + client_capabilities: info.capabilities.clone(), + }); + } + + fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { + match notification { + ServerNotification::CancelledNotification(notification) => Some(¬ification.params), + _ => None, + } + } } pub type ServerSink = Peer; +/// Default number of notifications buffered for one subscription. +pub const DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY: usize = 64; + +/// How a client-side subscription stream ended. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum SubscriptionEnd { + /// The server returned a final `SubscriptionsListenResult`. + Graceful(SubscriptionsListenResult), + /// The transport closed without a final result. Call `Peer::listen` again + /// after reconnecting; subscription streams are not resumable. + Abrupt, + /// The subscription was explicitly cancelled by either peer. + Cancelled, + /// The consumer did not drain notifications before the channel filled. + Lagged { capacity: usize }, +} + +/// Handle for one active `subscriptions/listen` request. +#[derive(Debug)] +pub struct Subscription { + id: RequestId, + acknowledged: SubscriptionFilter, + notifications: tokio::sync::mpsc::Receiver, + request: Option>, + end: Option, +} + +type SubscriptionResponse = + Result, tokio::sync::oneshot::error::RecvError>; + +struct PendingSubscriptionRequest { + handle: Option>, +} + +impl PendingSubscriptionRequest { + fn new(handle: RequestHandle) -> Self { + Self { + handle: Some(handle), + } + } + + async fn recv(&mut self) -> Option { + let handle = self.handle.as_mut()?; + Some((&mut handle.rx).await) + } + + fn take(&mut self) -> Option> { + self.handle.take() + } + + fn unregister(&self, id: &RequestId) { + if let Some(handle) = self.handle.as_ref() { + handle.peer.unregister_subscription(id); + } + } + + fn disarm(&mut self) { + self.handle.take(); + } + + async fn cancel(&mut self, reason: &'static str) { + if let Some(handle) = self.handle.take() { + let _ = handle.cancel(Some(reason.to_owned())).await; + } + } +} + +impl Drop for PendingSubscriptionRequest { + fn drop(&mut self) { + let Some(handle) = self.handle.take() else { + return; + }; + handle.peer.unregister_subscription(&handle.id); + handle.peer.try_cancel_request( + handle.id, + Some("subscription establishment cancelled".to_owned()), + ); + } +} + +impl Subscription { + /// Return the originating listen request ID. + pub fn id(&self) -> &RequestId { + &self.id + } + + /// Return the notification filter accepted by the server. + pub fn acknowledged(&self) -> &SubscriptionFilter { + &self.acknowledged + } + + /// Return the terminal state after this subscription has ended. + pub fn end(&self) -> Option<&SubscriptionEnd> { + self.end.as_ref() + } + + /// Receive the next notification, or `None` after the subscription ends. + /// + /// A graceful final result and an abrupt transport close are distinguished + /// through [`Self::end`]. + /// + /// # Errors + /// + /// Returns a service or protocol error when the stream carries an invalid + /// message, an unexpected final result, or another request failure. + pub async fn next(&mut self) -> Result, ServiceError> { + if self.end.is_some() { + return Ok(None); + } + let Some(request) = self.request.as_mut() else { + self.end = Some(SubscriptionEnd::Abrupt); + return Ok(None); + }; + + tokio::select! { + biased; + notification = self.notifications.recv() => { + let Some(notification) = notification else { + let response = (&mut request.rx).await; + return self.handle_response(response); + }; + if let ServerNotification::CancelledNotification(cancelled) = ¬ification { + if cancelled.params.request_id.as_ref() != Some(&self.id) { + self.cancel_as_abrupt("subscription cancellation ID mismatch") + .await; + return Err(ServiceError::UnexpectedResponse); + } + self.finish(SubscriptionEnd::Cancelled); + return Ok(None); + } + if notification.get_meta().subscription_id().as_ref() != Some(&self.id) { + self.cancel_as_abrupt("subscription notification ID mismatch") + .await; + return Err(ServiceError::UnexpectedResponse); + } + if !self.accepts(¬ification) { + self.cancel_as_abrupt( + "subscription notification was outside the acknowledged filter", + ) + .await; + return Err(ServiceError::UnexpectedResponse); + } + Ok(Some(notification)) + } + response = &mut request.rx => { + self.handle_response(response) + } + } + } + + /// Cancel this subscription. + /// + /// # Errors + /// + /// Returns a transport error when the cancellation signal cannot be sent. + pub async fn cancel(&mut self) -> Result<(), ServiceError> { + self.cancel_with_reason(None).await + } + + /// Cancel this subscription with a diagnostic reason. + /// + /// # Errors + /// + /// Returns a transport error when the cancellation signal cannot be sent. + pub async fn cancel_with_reason(&mut self, reason: Option) -> Result<(), ServiceError> { + let Some(request) = self.request.take() else { + return Ok(()); + }; + request.cancel(reason).await?; + self.end = Some(SubscriptionEnd::Cancelled); + Ok(()) + } + + fn finish(&mut self, end: SubscriptionEnd) { + if let Some(request) = self.request.take() { + request.peer.unregister_subscription(&self.id); + } + self.end = Some(end); + } + + async fn cancel_as_abrupt(&mut self, reason: &'static str) { + if let Some(request) = self.request.take() { + let _ = request.cancel(Some(reason.to_owned())).await; + } + self.end = Some(SubscriptionEnd::Abrupt); + } + + fn accepts(&self, notification: &ServerNotification) -> bool { + match notification { + ServerNotification::ToolListChangedNotification(_) => { + self.acknowledged.tools_list_changed == Some(true) + } + ServerNotification::PromptListChangedNotification(_) => { + self.acknowledged.prompts_list_changed == Some(true) + } + ServerNotification::ResourceListChangedNotification(_) => { + self.acknowledged.resources_list_changed == Some(true) + } + ServerNotification::ResourceUpdatedNotification(update) => self + .acknowledged + .resource_subscriptions + .as_ref() + .is_some_and(|uris| uris.contains(&update.params.uri)), + ServerNotification::SubscriptionsAcknowledgedNotification(_) + | ServerNotification::CancelledNotification(_) + | ServerNotification::ProgressNotification(_) + | ServerNotification::LoggingMessageNotification(_) + | ServerNotification::TaskStatusNotification(_) + | ServerNotification::CustomNotification(_) => false, + } + } + + fn handle_response( + &mut self, + response: SubscriptionResponse, + ) -> Result, ServiceError> { + let response = match response { + Ok(response) => response, + Err(_) => { + self.finish(SubscriptionEnd::Abrupt); + return Ok(None); + } + }; + let response = match response { + Ok(response) => response, + Err(ServiceError::TransportClosed) => { + self.finish(SubscriptionEnd::Abrupt); + return Ok(None); + } + Err(ServiceError::SubscriptionLagged { capacity }) => { + self.finish(SubscriptionEnd::Lagged { capacity }); + return Ok(None); + } + Err(error) => { + self.finish(SubscriptionEnd::Abrupt); + return Err(error); + } + }; + let ServerResult::SubscriptionsListenResult(result) = response else { + self.finish(SubscriptionEnd::Abrupt); + return Err(ServiceError::UnexpectedResponse); + }; + if !result.result_type.is_complete() + || result.meta.subscription_id().as_ref() != Some(&self.id) + { + self.finish(SubscriptionEnd::Abrupt); + return Err(ServiceError::UnexpectedResponse); + } + self.finish(SubscriptionEnd::Graceful(result)); + Ok(None) + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + let Some(request) = self.request.take() else { + return; + }; + request.peer.unregister_subscription(&self.id); + request.peer.try_cancel_request( + self.id.clone(), + Some("subscription handle dropped".to_owned()), + ); + } +} + /// Selects how a client establishes its MCP lifecycle. /// /// Existing [`ServiceExt::serve`] behavior remains legacy initialization. @@ -621,6 +910,106 @@ macro_rules! method { } impl Peer { + /// Open a long-lived notification subscription and wait for its acknowledgment. + /// + /// Notifications routed to the returned [`Subscription`] are not also + /// delivered through [`ClientHandler`](crate::ClientHandler) callbacks. + /// + /// # Errors + /// + /// Returns a service, transport, or protocol error when the request cannot + /// be established or the acknowledgment is invalid. + pub async fn listen( + &self, + notifications: SubscriptionFilter, + ) -> Result { + self.listen_with_channel_capacity_inner( + notifications, + DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY, + ) + .await + } + + /// Open a subscription with an explicit notification buffer capacity. + /// + /// Notifications routed to the returned [`Subscription`] are not also + /// delivered through [`ClientHandler`](crate::ClientHandler) callbacks. + /// + /// # Errors + /// + /// Returns a service, transport, or protocol error when the request cannot + /// be established or the acknowledgment is invalid. + pub async fn listen_with_capacity( + &self, + notifications: SubscriptionFilter, + channel_capacity: NonZeroUsize, + ) -> Result { + self.listen_with_channel_capacity_inner(notifications, channel_capacity.get()) + .await + } + + async fn listen_with_channel_capacity_inner( + &self, + notifications: SubscriptionFilter, + channel_capacity: usize, + ) -> Result { + let request = ClientRequest::SubscriptionsListenRequest(SubscriptionsListenRequest::new( + SubscriptionsListenRequestParams::new(notifications.clone()), + )); + let (handle, mut subscription_notifications) = self + .send_subscription_request(request, PeerRequestOptions::no_options(), channel_capacity) + .await?; + let id = handle.id.clone(); + let mut pending = PendingSubscriptionRequest::new(handle); + + tokio::select! { + biased; + notification = subscription_notifications.recv() => { + let Some(notification) = notification else { + pending.cancel("subscription stream closed before acknowledgment").await; + return Err(ServiceError::TransportClosed); + }; + if notification.get_meta().subscription_id().as_ref() != Some(&id) { + pending.cancel("subscription acknowledgment ID mismatch").await; + return Err(ServiceError::UnexpectedResponse); + } + let ServerNotification::SubscriptionsAcknowledgedNotification( + acknowledgment, + ) = notification else { + pending.cancel("notification received before subscription acknowledgment").await; + return Err(ServiceError::UnexpectedResponse); + }; + let accepted = acknowledgment.params.notifications; + if !accepted.is_subset_of(¬ifications) { + pending.cancel("subscription acknowledged an unrequested filter").await; + return Err(ServiceError::UnexpectedResponse); + } + let Some(handle) = pending.take() else { + return Err(ServiceError::TransportClosed); + }; + Ok(Subscription { + id, + acknowledged: accepted, + notifications: subscription_notifications, + request: Some(handle), + end: None, + }) + } + response = pending.recv() => { + pending.unregister(&id); + pending.disarm(); + let Some(response) = response else { + return Err(ServiceError::TransportClosed); + }; + match response { + Ok(Err(error)) => Err(error), + Ok(Ok(_)) => Err(ServiceError::UnexpectedResponse), + Err(_) => Err(ServiceError::TransportClosed), + } + } + } + } + /// Discover the server's supported protocol versions and capabilities. /// /// The high-level client currently exposes this peer only after initialization; @@ -716,8 +1105,18 @@ impl Peer { method!(peer_req list_resources ListResourcesRequest(PaginatedRequestParams)? => ListResourcesResult); method!(peer_req list_resource_templates ListResourceTemplatesRequest(PaginatedRequestParams)? => ListResourceTemplatesResult); method!(peer_req read_resource ReadResourceRequest(ReadResourceRequestParams) => ReadResourceResult); - method!(peer_req subscribe SubscribeRequest(SubscribeRequestParams) ); - method!(peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams)); + method!( + #[deprecated( + note = "resources/subscribe is legacy-only; use Peer::listen for protocol version 2026-07-28" + )] + peer_req subscribe SubscribeRequest(SubscribeRequestParams) + ); + method!( + #[deprecated( + note = "resources/unsubscribe is legacy-only; cancel the Subscription handle instead" + )] + peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams) + ); method!(peer_req call_tool CallToolRequest(CallToolRequestParams) => CallToolResult); method!(peer_req list_tools ListToolsRequest(PaginatedRequestParams)? => ListToolsResult); diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index e24ae085f..b7aa6b968 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -1,8 +1,8 @@ // Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] -use std::borrow::Cow; #[cfg(feature = "elicitation")] use std::collections::HashSet; +use std::{borrow::Cow, sync::Arc}; use thiserror::Error; #[cfg(feature = "elicitation")] @@ -20,7 +20,8 @@ use crate::{ ProgressNotification, ProgressNotificationParam, PromptListChangedNotification, ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification, ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest, - ServerResult, ToolListChangedNotification, + ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification, }, transport::DynamicTransportError, }; @@ -41,6 +42,13 @@ impl ServiceRole for RoleServer { type InitializeError = ServerInitializeError; const IS_CLIENT: bool = false; + + fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { + match notification { + ClientNotification::CancelledNotification(notification) => Some(¬ification.params), + _ => None, + } + } } /// It represents the error that may occur when serving the server. @@ -98,6 +106,284 @@ impl ServerInitializeError { } pub type ClientSink = Peer; +/// Failure to send a notification through a [`SubscriptionSink`]. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SubscriptionSendError { + #[error("subscription is no longer active")] + SubscriptionClosed, + #[error("notification is not allowed on a subscription stream: {0}")] + UnsupportedNotification(&'static str), + #[error("notification was not accepted for this subscription: {0}")] + NotificationNotAccepted(&'static str), + #[error(transparent)] + Service(#[from] ServiceError), +} + +/// A server-side notification sink scoped to one `subscriptions/listen` request. +/// +/// The sink applies the accepted filter and adds the subscription request ID to +/// every notification it sends. +#[derive(Debug, Clone)] +pub struct SubscriptionSink { + peer: Peer, + id: RequestId, + accepted: Arc, + active: CancellationToken, +} + +impl SubscriptionSink { + fn new( + peer: Peer, + id: RequestId, + accepted: Arc, + active: CancellationToken, + ) -> Self { + Self { + peer, + id, + accepted, + active, + } + } + + /// Return the JSON-RPC ID of the originating listen request. + pub fn id(&self) -> &RequestId { + &self.id + } + + /// Return the filter accepted for this subscription. + pub fn accepted(&self) -> &SubscriptionFilter { + self.accepted.as_ref() + } + + /// Send an allowed change notification with subscription metadata attached. + /// + /// # Errors + /// + /// Returns [`SubscriptionSendError::SubscriptionClosed`] after the request + /// ends, a filter error for disallowed notifications, or a transport error. + pub async fn send( + &self, + mut notification: ServerNotification, + ) -> Result<(), SubscriptionSendError> { + if self.active.is_cancelled() { + return Err(SubscriptionSendError::SubscriptionClosed); + } + match ¬ification { + ServerNotification::ToolListChangedNotification(_) => { + if self.accepted.tools_list_changed != Some(true) { + return Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/tools/list_changed", + )); + } + } + ServerNotification::PromptListChangedNotification(_) => { + if self.accepted.prompts_list_changed != Some(true) { + return Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/prompts/list_changed", + )); + } + } + ServerNotification::ResourceListChangedNotification(_) => { + if self.accepted.resources_list_changed != Some(true) { + return Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/resources/list_changed", + )); + } + } + ServerNotification::ResourceUpdatedNotification(update) => { + let accepted = self + .accepted + .resource_subscriptions + .as_ref() + .is_some_and(|uris| uris.contains(&update.params.uri)); + if !accepted { + return Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/resources/updated", + )); + } + } + ServerNotification::SubscriptionsAcknowledgedNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/subscriptions/acknowledged", + )); + } + ServerNotification::CancelledNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/cancelled", + )); + } + ServerNotification::ProgressNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/progress", + )); + } + ServerNotification::LoggingMessageNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/message", + )); + } + ServerNotification::TaskStatusNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "notifications/tasks/status", + )); + } + ServerNotification::CustomNotification(_) => { + return Err(SubscriptionSendError::UnsupportedNotification( + "custom notification", + )); + } + } + + notification + .get_meta_mut() + .set_subscription_id(self.id.clone()); + self.peer.send_notification(notification).await?; + Ok(()) + } + + /// Send `notifications/tools/list_changed`. + /// + /// # Errors + /// + /// See [`Self::send`]. + pub async fn notify_tool_list_changed(&self) -> Result<(), SubscriptionSendError> { + self.send(ServerNotification::ToolListChangedNotification( + ToolListChangedNotification { + method: Default::default(), + extensions: Default::default(), + }, + )) + .await + } + + /// Send `notifications/prompts/list_changed`. + /// + /// # Errors + /// + /// See [`Self::send`]. + pub async fn notify_prompt_list_changed(&self) -> Result<(), SubscriptionSendError> { + self.send(ServerNotification::PromptListChangedNotification( + PromptListChangedNotification { + method: Default::default(), + extensions: Default::default(), + }, + )) + .await + } + + /// Send `notifications/resources/list_changed`. + /// + /// # Errors + /// + /// See [`Self::send`]. + pub async fn notify_resource_list_changed(&self) -> Result<(), SubscriptionSendError> { + self.send(ServerNotification::ResourceListChangedNotification( + ResourceListChangedNotification { + method: Default::default(), + extensions: Default::default(), + }, + )) + .await + } + + /// Send `notifications/resources/updated` for an accepted URI. + /// + /// # Errors + /// + /// See [`Self::send`]. + pub async fn notify_resource_updated( + &self, + uri: impl Into, + ) -> Result<(), SubscriptionSendError> { + self.send(ServerNotification::ResourceUpdatedNotification( + ResourceUpdatedNotification::new(ResourceUpdatedNotificationParam::new(uri)), + )) + .await + } +} + +/// Context for one established server-side notification subscription. +/// +/// The acknowledgment has already been sent before this context is handed to +/// [`ServerHandler::listen`](crate::ServerHandler::listen). +#[derive(Debug)] +pub struct SubscriptionContext { + request: RequestContext, + requested: SubscriptionFilter, + accepted: Arc, + sink: SubscriptionSink, + _active_guard: DropGuard, +} + +impl SubscriptionContext { + pub(crate) async fn establish( + request: RequestContext, + requested: SubscriptionFilter, + accepted: SubscriptionFilter, + ) -> Result { + let active = request.ct.child_token(); + let accepted = Arc::new(accepted); + let sink = SubscriptionSink::new( + request.peer.clone(), + request.id.clone(), + accepted.clone(), + active.clone(), + ); + let mut acknowledgment = SubscriptionsAcknowledgedNotification::new( + SubscriptionsAcknowledgedNotificationParams::new(accepted.as_ref().clone()), + ); + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(request.id.clone()); + acknowledgment.extensions.insert(meta); + request + .peer + .send_notification(ServerNotification::SubscriptionsAcknowledgedNotification( + acknowledgment, + )) + .await + .map_err(|error| { + ErrorData::internal_error( + format!("failed to acknowledge subscription: {error}"), + None, + ) + })?; + Ok(Self { + request, + requested, + accepted, + sink, + _active_guard: active.drop_guard(), + }) + } + + /// Return the filter requested by the client. + pub fn requested(&self) -> &SubscriptionFilter { + &self.requested + } + + /// Return the subset accepted by the server. + pub fn accepted(&self) -> &SubscriptionFilter { + self.accepted.as_ref() + } + + /// Return a cloneable, filter-enforcing notification sink. + pub fn sink(&self) -> &SubscriptionSink { + &self.sink + } + + /// Wait until the subscription request is cancelled. + pub async fn cancelled(&self) { + self.request.ct.cancelled().await; + } + + /// Access the underlying request context. + pub fn request_context(&self) -> &RequestContext { + &self.request + } +} + impl> ServiceExt for S { fn serve_with_ct( self, diff --git a/crates/rmcp/src/transport/common/server_side_http.rs b/crates/rmcp/src/transport/common/server_side_http.rs index 32609bb75..4969ff793 100644 --- a/crates/rmcp/src/transport/common/server_side_http.rs +++ b/crates/rmcp/src/transport/common/server_side_http.rs @@ -6,7 +6,7 @@ use http::Response; use http_body::Body; use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; use sse_stream::{KeepAlive, Sse, SseBody}; -use tokio_util::sync::CancellationToken; +use tokio_util::sync::{CancellationToken, DropGuard}; use super::http_header::EVENT_STREAM_MIME_TYPE; use crate::model::{ClientJsonRpcMessage, ServerJsonRpcMessage}; @@ -57,6 +57,25 @@ impl sse_stream::Timer for TokioTimer { } } +pin_project_lite::pin_project! { + struct CancelOnDropStream { + #[pin] + inner: S, + _drop_guard: DropGuard, + } +} + +impl futures::Stream for CancelOnDropStream { + type Item = S::Item; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.project().inner.poll_next(cx) + } +} + #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct ServerSseMessage { @@ -108,6 +127,7 @@ pub(crate) fn sse_stream_response( ct: CancellationToken, ) -> Response> { use futures::StreamExt; + let cancelled = ct.clone(); let stream = stream .map(|message| { let mut sse = if let Some(ref msg) = message.message { @@ -126,7 +146,11 @@ pub(crate) fn sse_stream_response( Result::::Ok(sse) }) - .take_until(async move { ct.cancelled().await }); + .take_until(async move { cancelled.cancelled().await }); + let stream = CancelOnDropStream { + inner: stream, + _drop_guard: ct.drop_guard(), + }; let stream = SseBody::new(stream); let stream = match keep_alive { @@ -140,6 +164,7 @@ pub(crate) fn sse_stream_response( .status(http::StatusCode::OK) .header(http::header::CONTENT_TYPE, EVENT_STREAM_MIME_TYPE) .header(http::header::CACHE_CONTROL, "no-cache") + .header("X-Accel-Buffering", "no") .body(stream) .expect("valid response") } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index e54a17373..8c3a1f83d 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -30,6 +30,7 @@ use crate::{ }; type BoxedSseStream = BoxStream<'static, Result>; +type SseTaskResult = (Option, Result<(), StreamableHttpError>); const SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); fn build_request_headers( @@ -683,7 +684,7 @@ impl StreamableHttpClientWorker { } fn spawn_common_stream( - streams: &mut tokio::task::JoinSet>>, + streams: &mut tokio::task::JoinSet>, client: C, session_id: Arc, config: &StreamableHttpClientTransportConfig, @@ -699,7 +700,7 @@ impl StreamableHttpClientWorker { let max_sse_event_size = config.max_sse_event_size; streams.spawn(async move { - match client + let result = match client .get_stream_with_max_sse_event_size( uri, session_id.clone(), @@ -739,7 +740,8 @@ impl StreamableHttpClientWorker { tracing::error!("fail to get common stream: {error}"); Err(error) } - } + }; + (None, result) }); } @@ -887,7 +889,10 @@ impl Worker for StreamableHttpClientWorker { )); } }; - let mut session_id: Option> = if let Some(session_id) = session_id { + let mut uses_modern_http = !is_legacy_startup; + let mut session_id: Option> = if uses_modern_http { + None + } else if let Some(session_id) = session_id { Some(session_id.into()) } else { if !self.config.allow_stateless { @@ -951,10 +956,14 @@ impl Worker for StreamableHttpClientWorker { enum Event { ClientMessage(WorkerSendRequest), ServerMessage(ServerJsonRpcMessage), - StreamResult(Result<(), StreamableHttpError>), + StreamResult { + request_id: Option, + result: Result<(), StreamableHttpError>, + }, } let mut streams = tokio::task::JoinSet::new(); let mut pending_stream_response_ids = HashSet::new(); + let mut request_stream_cancellations = HashMap::::new(); let mut awaiting_fallback_initialized = false; if let Some(session_id) = &session_id { Self::spawn_common_stream( @@ -990,7 +999,15 @@ impl Worker for StreamableHttpClientWorker { terminated_stream = streams.join_next(), if !streams.is_empty() => { match terminated_stream { Some(result) => { - Event::StreamResult(result.map_err(StreamableHttpError::TokioJoinError).and_then(std::convert::identity)) + match result { + Ok((request_id, result)) => { + Event::StreamResult { request_id, result } + } + Err(error) => Event::StreamResult { + request_id: None, + result: Err(StreamableHttpError::TokioJoinError(error)), + }, + } } None => { continue @@ -1001,6 +1018,25 @@ impl Worker for StreamableHttpClientWorker { match event { Event::ClientMessage(send_request) => { let WorkerSendRequest { message, responder } = send_request; + let cancellation_request_id = match &message { + ClientJsonRpcMessage::Notification(notification) => { + match ¬ification.notification { + ClientNotification::CancelledNotification(cancelled) => { + cancelled.params.request_id.clone() + } + _ => None, + } + } + _ => None, + }; + if uses_modern_http && let Some(request_id) = cancellation_request_id { + if let Some(stream_ct) = request_stream_cancellations.remove(&request_id) { + stream_ct.cancel(); + } + pending_stream_response_ids.remove(&request_id); + let _ = responder.send(Ok(())); + continue; + } let is_fallback_initialize = saved_init_request.is_none() && matches!( &message, @@ -1021,6 +1057,7 @@ impl Worker for StreamableHttpClientWorker { && streams.is_empty(), "discover bootstrap must not create session state" ); + uses_modern_http = false; let response = self .client @@ -1228,6 +1265,7 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { + let stream_request_id = request_id.clone(); Self::mark_stream_response_pending( &mut pending_stream_response_ids, request_id, @@ -1242,12 +1280,24 @@ impl Worker for StreamableHttpClientWorker { config.max_sse_event_size, self.config.retry_config.clone(), ); - streams.spawn(Self::execute_sse_stream( - sse_stream, - sse_worker_tx.clone(), - true, - transport_task_ct.child_token(), - )); + let stream_ct = transport_task_ct.child_token(); + if uses_modern_http + && let Some(request_id) = + stream_request_id.as_ref() + { + request_stream_cancellations.insert( + request_id.clone(), + stream_ct.clone(), + ); + } + let stream_tx = sse_worker_tx.clone(); + streams.spawn(async move { + let result = Self::execute_sse_stream( + sse_stream, stream_tx, true, stream_ct, + ) + .await; + (stream_request_id, result) + }); tracing::trace!("got new sse stream after re-init"); Ok(()) } @@ -1278,6 +1328,7 @@ impl Worker for StreamableHttpClientWorker { Ok(()) } Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { + let stream_request_id = request_id.clone(); Self::mark_stream_response_pending( &mut pending_stream_response_ids, request_id, @@ -1292,12 +1343,20 @@ impl Worker for StreamableHttpClientWorker { config.max_sse_event_size, self.config.retry_config.clone(), ); - streams.spawn(Self::execute_sse_stream( - sse_stream, - sse_worker_tx.clone(), - true, - transport_task_ct.child_token(), - )); + let stream_ct = transport_task_ct.child_token(); + if uses_modern_http && let Some(request_id) = stream_request_id.as_ref() + { + request_stream_cancellations + .insert(request_id.clone(), stream_ct.clone()); + } + let stream_tx = sse_worker_tx.clone(); + streams.spawn(async move { + let result = Self::execute_sse_stream( + sse_stream, stream_tx, true, stream_ct, + ) + .await; + (stream_request_id, result) + }); tracing::trace!("got new sse stream"); Ok(()) } @@ -1322,6 +1381,11 @@ impl Worker for StreamableHttpClientWorker { let _ = responder.send(send_result); } Event::ServerMessage(mut json_rpc_message) => { + if let Some(response_id) = Self::server_response_id(&json_rpc_message) + && let Some(stream_ct) = request_stream_cancellations.remove(response_id) + { + stream_ct.cancel(); + } Self::clear_stream_response_pending( &mut pending_stream_response_ids, &json_rpc_message, @@ -1336,7 +1400,26 @@ impl Worker for StreamableHttpClientWorker { break 'main_loop Err(e); } } - Event::StreamResult(result) => { + Event::StreamResult { request_id, result } => { + if let Some(request_id) = request_id { + Self::drain_queued_stream_messages( + &mut sse_worker_rx, + &mut context, + &mut pending_stream_response_ids, + ) + .await?; + request_stream_cancellations.remove(&request_id); + if pending_stream_response_ids.remove(&request_id) { + context + .send_to_handler(ServerJsonRpcMessage::error( + ErrorData::transport_closed( + "streamable HTTP response stream closed before its final response", + ), + Some(request_id), + )) + .await?; + } + } if result.is_err() { tracing::warn!( "sse client event stream terminated with error: {:?}", diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index bf95a5377..16fa1eccb 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -30,7 +30,7 @@ use crate::{ ProtocolVersion, RequestId, ServerJsonRpcMessage, }, serve_server, - service::serve_directly_with_ct, + service::{serve_directly_with_ct, uses_legacy_lifecycle}, transport::{ OneshotTransport, TransportAdapterIdentity, common::{ @@ -259,8 +259,9 @@ fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> b clippy::result_large_err, reason = "BoxResponse is intentionally large; matches other handlers in this file" )] -// SEP-2567: sessions are removed from 2026-07-28; older versions are legacy. -// Validates protocol-version consistency and returns `Ok(true)` only for a valid legacy request. +// SEP-2567: sessions are removed from the discover lifecycle. Validate +// protocol-version consistency, then classify the request with the shared +// lifecycle helper. fn is_legacy_request( message: Option<&ClientJsonRpcMessage>, headers: &HeaderMap, @@ -280,6 +281,17 @@ fn is_legacy_request( validate_request_protocol_version_meta(headers, message)?; } + let uses_discover_lifecycle = matches!( + message, + Some(ClientJsonRpcMessage::Request(req)) + if !matches!(&req.request, ClientRequest::InitializeRequest(_)) + && req + .request + .get_meta() + .missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty() + ); + let from_body = match message { Some(ClientJsonRpcMessage::Request(req)) => match &req.request { ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()), @@ -295,7 +307,10 @@ fn is_legacy_request( .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) }) .unwrap_or(ProtocolVersion::V_2025_03_26); - Ok(version < ProtocolVersion::V_2026_07_28) + Ok(uses_legacy_lifecycle( + Some(&version), + uses_discover_lifecycle, + )) } fn method_not_allowed_response() -> BoxResponse { diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 15e6e2945..922469c23 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -1160,20 +1160,23 @@ { "$ref": "#/definitions/Request9" }, + { + "$ref": "#/definitions/Request10" + }, { "$ref": "#/definitions/RequestOptionalParam4" }, { - "$ref": "#/definitions/Request10" + "$ref": "#/definitions/Request11" }, { "$ref": "#/definitions/RequestOptionalParam5" }, { - "$ref": "#/definitions/Request11" + "$ref": "#/definitions/Request12" }, { - "$ref": "#/definitions/Request12" + "$ref": "#/definitions/Request13" }, { "$ref": "#/definitions/CustomRequest" @@ -1567,6 +1570,22 @@ ] }, "Request10": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CallToolRequestMethod" + }, + "params": { + "$ref": "#/definitions/CallToolRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request11": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1582,7 +1601,7 @@ "params" ] }, - "Request11": { + "Request12": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1598,7 +1617,7 @@ "params" ] }, - "Request12": { + "Request13": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1699,10 +1718,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SubscribeRequestMethod" + "$ref": "#/definitions/SubscriptionsListenRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParams" + "$ref": "#/definitions/SubscriptionsListenRequestParams" } }, "required": [ @@ -1715,10 +1734,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" + "$ref": "#/definitions/SubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" + "$ref": "#/definitions/SubscribeRequestParams" } }, "required": [ @@ -1731,10 +1750,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CallToolRequestMethod" + "$ref": "#/definitions/UnsubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/CallToolRequestParams" + "$ref": "#/definitions/UnsubscribeRequestParams" } }, "required": [ @@ -2303,6 +2322,77 @@ "uri" ] }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsListenRequestMethod": { + "type": "string", + "format": "const", + "const": "subscriptions/listen" + }, + "SubscriptionsListenRequestParams": { + "description": "Parameters for opening a long-lived notification subscription.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata. Required by the draft wire schema.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientInfo", + "io.modelcontextprotocol/clientCapabilities" + ] + }, + "notifications": { + "description": "Notification categories requested for this stream.", + "allOf": [ + { + "$ref": "#/definitions/SubscriptionFilter" + } + ] + } + }, + "required": [ + "_meta", + "notifications" + ] + }, "TaskMetadata": { "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", "type": "object", diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 15e6e2945..922469c23 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -1160,20 +1160,23 @@ { "$ref": "#/definitions/Request9" }, + { + "$ref": "#/definitions/Request10" + }, { "$ref": "#/definitions/RequestOptionalParam4" }, { - "$ref": "#/definitions/Request10" + "$ref": "#/definitions/Request11" }, { "$ref": "#/definitions/RequestOptionalParam5" }, { - "$ref": "#/definitions/Request11" + "$ref": "#/definitions/Request12" }, { - "$ref": "#/definitions/Request12" + "$ref": "#/definitions/Request13" }, { "$ref": "#/definitions/CustomRequest" @@ -1567,6 +1570,22 @@ ] }, "Request10": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CallToolRequestMethod" + }, + "params": { + "$ref": "#/definitions/CallToolRequestParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request11": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1582,7 +1601,7 @@ "params" ] }, - "Request11": { + "Request12": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1598,7 +1617,7 @@ "params" ] }, - "Request12": { + "Request13": { "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", "type": "object", "properties": { @@ -1699,10 +1718,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/SubscribeRequestMethod" + "$ref": "#/definitions/SubscriptionsListenRequestMethod" }, "params": { - "$ref": "#/definitions/SubscribeRequestParams" + "$ref": "#/definitions/SubscriptionsListenRequestParams" } }, "required": [ @@ -1715,10 +1734,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/UnsubscribeRequestMethod" + "$ref": "#/definitions/SubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/UnsubscribeRequestParams" + "$ref": "#/definitions/SubscribeRequestParams" } }, "required": [ @@ -1731,10 +1750,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/CallToolRequestMethod" + "$ref": "#/definitions/UnsubscribeRequestMethod" }, "params": { - "$ref": "#/definitions/CallToolRequestParams" + "$ref": "#/definitions/UnsubscribeRequestParams" } }, "required": [ @@ -2303,6 +2322,77 @@ "uri" ] }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsListenRequestMethod": { + "type": "string", + "format": "const", + "const": "subscriptions/listen" + }, + "SubscriptionsListenRequestParams": { + "description": "Parameters for opening a long-lived notification subscription.", + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata. Required by the draft wire schema.", + "type": "object", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/definitions/LoggingLevel" + }, + "io.modelcontextprotocol/protocolVersion": { + "type": "string" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientInfo", + "io.modelcontextprotocol/clientCapabilities" + ] + }, + "notifications": { + "description": "Notification categories requested for this stream.", + "allOf": [ + { + "$ref": "#/definitions/SubscriptionFilter" + } + ] + } + }, + "required": [ + "_meta", + "notifications" + ] + }, "TaskMetadata": { "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", "type": "object", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 4cf0a0baf..520702420 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -1650,6 +1650,9 @@ { "$ref": "#/definitions/Notification5" }, + { + "$ref": "#/definitions/Notification6" + }, { "$ref": "#/definitions/CustomNotification" } @@ -2222,6 +2225,21 @@ ] }, "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod" + }, + "params": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification6": { "type": "object", "properties": { "method": { @@ -3295,6 +3313,9 @@ { "$ref": "#/definitions/ReadResourceResult" }, + { + "$ref": "#/definitions/SubscriptionsListenResult" + }, { "$ref": "#/definitions/ListToolsResult" }, @@ -3438,6 +3459,75 @@ "format": "const", "const": "string" }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsAcknowledgedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/subscriptions/acknowledged" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters reporting the accepted subset of a subscription filter.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/NotificationMetaObject" + }, + "notifications": { + "$ref": "#/definitions/SubscriptionFilter" + } + }, + "required": [ + "notifications" + ] + }, + "SubscriptionsListenResult": { + "description": "Final response indicating that a subscription ended gracefully.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/SubscriptionsListenResultMeta" + }, + "resultType": { + "$ref": "#/definitions/ResultType" + } + }, + "required": [ + "resultType", + "_meta" + ] + }, + "SubscriptionsListenResultMeta": { + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ] + }, "Task": { "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", "type": "object", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 4cf0a0baf..520702420 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -1650,6 +1650,9 @@ { "$ref": "#/definitions/Notification5" }, + { + "$ref": "#/definitions/Notification6" + }, { "$ref": "#/definitions/CustomNotification" } @@ -2222,6 +2225,21 @@ ] }, "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod" + }, + "params": { + "$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Notification6": { "type": "object", "properties": { "method": { @@ -3295,6 +3313,9 @@ { "$ref": "#/definitions/ReadResourceResult" }, + { + "$ref": "#/definitions/SubscriptionsListenResult" + }, { "$ref": "#/definitions/ListToolsResult" }, @@ -3438,6 +3459,75 @@ "format": "const", "const": "string" }, + "SubscriptionFilter": { + "description": "Notification categories a client opts in to on a `subscriptions/listen` stream.", + "type": "object", + "properties": { + "promptsListChanged": { + "type": "boolean" + }, + "resourceSubscriptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "resourcesListChanged": { + "type": "boolean" + }, + "toolsListChanged": { + "type": "boolean" + } + } + }, + "SubscriptionsAcknowledgedNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/subscriptions/acknowledged" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters reporting the accepted subset of a subscription filter.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/NotificationMetaObject" + }, + "notifications": { + "$ref": "#/definitions/SubscriptionFilter" + } + }, + "required": [ + "notifications" + ] + }, + "SubscriptionsListenResult": { + "description": "Final response indicating that a subscription ended gracefully.", + "type": "object", + "properties": { + "_meta": { + "$ref": "#/definitions/SubscriptionsListenResultMeta" + }, + "resultType": { + "$ref": "#/definitions/ResultType" + } + }, + "required": [ + "resultType", + "_meta" + ] + }, + "SubscriptionsListenResultMeta": { + "type": "object", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/definitions/NumberOrString" + } + }, + "additionalProperties": true, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ] + }, "Task": { "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", "type": "object", diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs index b9087f706..3cb5da31b 100644 --- a/crates/rmcp/tests/test_mrtr_behavior.rs +++ b/crates/rmcp/tests/test_mrtr_behavior.rs @@ -281,9 +281,9 @@ fn client_info(protocol_version: ProtocolVersion) -> ClientInfo { .with_protocol_version(protocol_version) } -fn server_info_2026() -> ServerInfo { +fn server_info(protocol_version: ProtocolVersion) -> ServerInfo { let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); - info.protocol_version = ProtocolVersion::V_2026_07_28; + info.protocol_version = protocol_version; info } @@ -302,6 +302,7 @@ where .run_until(async move { let (server_transport, client_transport) = tokio::io::duplex(8192); let server_peer_info = client_info(client_protocol); + let client_peer_info = server_info(server_peer_info.protocol_version.clone()); let server_task = tokio::task::spawn_local(async move { let running = serve_directly::( server, @@ -315,7 +316,7 @@ where let client = serve_directly::( MrtrClient, client_transport, - Some(server_info_2026()), + Some(client_peer_info), ); let result = body(client).await; @@ -579,7 +580,7 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re let client = serve_directly::( MrtrClient, client_transport, - Some(server_info_2026()), + Some(server_info(ProtocolVersion::V_2026_07_28)), ); let result = client diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index 073396ee7..9aafd3ccd 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -1,4 +1,5 @@ #![cfg(not(feature = "local"))] +#![allow(deprecated)] use std::sync::Arc; use rmcp::{ diff --git a/crates/rmcp/tests/test_subscriptions.rs b/crates/rmcp/tests/test_subscriptions.rs new file mode 100644 index 000000000..3f33765c5 --- /dev/null +++ b/crates/rmcp/tests/test_subscriptions.rs @@ -0,0 +1,719 @@ +#![cfg(all( + not(feature = "local"), + feature = "client", + feature = "server", + feature = "transport-io" +))] + +use std::{ + num::NonZeroUsize, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use rmcp::{ + ClientHandler, ClientServiceExt, ServerHandler, ServiceExt, + model::{ + ClientNotification, ClientRequest, DiscoverResult, GetMeta, Implementation, + NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, ServerCapabilities, + ServerInfo, ServerNotification, ServerResult, SubscriptionFilter, + SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, + SubscriptionsListenResult, + }, + service::{ + NotificationContext, RequestContext, RoleClient, RoleServer, SubscriptionContext, + SubscriptionEnd, SubscriptionSendError, SubscriptionSink, + }, +}; +use tokio::sync::{Mutex, Notify}; + +struct ToolsOnlyServer; + +#[derive(Clone)] +struct CountingClient { + tool_changes: Arc, +} + +impl ClientHandler for CountingClient { + async fn on_tool_list_changed(&self, _context: NotificationContext) { + self.tool_changes.fetch_add(1, Ordering::Relaxed); + } +} + +impl ServerHandler for ToolsOnlyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context + .sink() + .notify_tool_list_changed() + .await + .expect("accepted tool notification"); + assert!(matches!( + context.sink().notify_prompt_list_changed().await, + Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/prompts/list_changed" + )) + )); + Ok(()) + } +} + +struct ToolsAndPromptsServer; + +impl ServerHandler for ToolsAndPromptsServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .enable_prompts() + .enable_prompts_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + if context.accepted().tools_list_changed == Some(true) { + context + .sink() + .notify_tool_list_changed() + .await + .expect("send tool notification"); + } + if context.accepted().prompts_list_changed == Some(true) { + context + .sink() + .notify_prompt_list_changed() + .await + .expect("send prompt notification"); + } + Ok(()) + } +} + +struct ResourceSubscriptionServer; + +impl ServerHandler for ResourceSubscriptionServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_resources() + .enable_resources_subscribe() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context + .sink() + .notify_resource_updated("file:///accepted") + .await + .expect("accepted URI"); + assert!(matches!( + context + .sink() + .notify_resource_updated("file:///not-requested") + .await, + Err(SubscriptionSendError::NotificationNotAccepted( + "notifications/resources/updated" + )) + )); + Ok(()) + } +} + +struct RemoteCancellationServer; + +impl ServerHandler for RemoteCancellationServer { + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context + .request_context() + .peer + .notify_cancelled(rmcp::model::CancelledNotificationParam::new( + Some(context.sink().id().clone()), + Some("server shutdown".to_owned()), + )) + .await + .expect("send server cancellation"); + std::future::pending().await + } +} + +struct FloodServer; + +impl ServerHandler for FloodServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + for _ in 0..10 { + if context.sink().notify_tool_list_changed().await.is_err() { + break; + } + } + context.cancelled().await; + Ok(()) + } +} + +#[derive(Clone)] +struct ClosedSinkServer { + sink: Arc>>, +} + +struct LeakyServer; + +impl ServerHandler for LeakyServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + let mut notification = + ServerNotification::PromptListChangedNotification(PromptListChangedNotification { + method: Default::default(), + extensions: Default::default(), + }); + notification + .get_meta_mut() + .set_subscription_id(context.sink().id().clone()); + context + .request_context() + .peer + .send_notification(notification) + .await + .expect("send deliberately invalid notification"); + std::future::pending().await + } +} + +struct MalformedAcknowledgmentServer { + cancelled: Arc, +} + +impl rmcp::service::Service for MalformedAcknowledgmentServer { + async fn handle_request( + &self, + request: ClientRequest, + context: RequestContext, + ) -> Result { + match request { + ClientRequest::DiscoverRequest(_) => { + Ok(ServerResult::DiscoverResult(DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .enable_prompts() + .enable_prompts_list_changed() + .build(), + Implementation::new("malformed-ack-server", "1.0.0"), + ))) + } + ClientRequest::SubscriptionsListenRequest(_) => { + let mut acknowledgment = SubscriptionsAcknowledgedNotification::new( + SubscriptionsAcknowledgedNotificationParams::new( + SubscriptionFilter::builder().prompts_list_changed().build(), + ), + ); + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(context.id.clone()); + acknowledgment.extensions.insert(meta); + context + .peer + .send_notification(ServerNotification::SubscriptionsAcknowledgedNotification( + acknowledgment, + )) + .await + .map_err(|error| rmcp::ErrorData::internal_error(error.to_string(), None))?; + context.ct.cancelled().await; + Ok(ServerResult::SubscriptionsListenResult( + SubscriptionsListenResult::complete(context.id), + )) + } + _ => Err(rmcp::ErrorData::invalid_request( + "unexpected test request", + None, + )), + } + } + + async fn handle_notification( + &self, + notification: ClientNotification, + _context: NotificationContext, + ) -> Result<(), rmcp::ErrorData> { + if matches!(notification, ClientNotification::CancelledNotification(_)) { + self.cancelled.notify_one(); + } + Ok(()) + } + + fn get_info(&self) -> rmcp::model::ServerInfo { + ServerInfo::default() + } +} + +impl ServerHandler for ClosedSinkServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + self.sink.lock().await.replace(context.sink().clone()); + Ok(()) + } +} + +#[derive(Clone)] +struct CancellationServer { + cancelled: Arc, +} + +impl ServerHandler for CancellationServer { + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context.cancelled().await; + self.cancelled.notify_one(); + Ok(()) + } +} + +#[derive(Clone)] +struct AbruptServer { + started: Arc, +} + +impl ServerHandler for AbruptServer { + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.clone()) + } + + async fn listen(&self, _context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + self.started.notify_one(); + std::future::pending().await + } +} + +async fn modern_client( + server: S, +) -> anyhow::Result> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + let server = server.serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + ().serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .map_err(Into::into) +} + +#[tokio::test] +async fn listen_exposes_acknowledged_filter_and_graceful_result() -> anyhow::Result<()> { + let client = modern_client(ToolsOnlyServer).await?; + let mut subscription = client + .listen( + SubscriptionFilter::builder() + .tools_list_changed() + .prompts_list_changed() + .build(), + ) + .await?; + + assert_eq!( + subscription.acknowledged(), + &SubscriptionFilter::builder().tools_list_changed().build() + ); + + let notification = tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await?? + .expect("tool notification"); + assert!(matches!( + notification, + ServerNotification::ToolListChangedNotification(_) + )); + assert_eq!( + notification.get_meta().subscription_id(), + Some(subscription.id().clone()) + ); + + assert!( + tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await?? + .is_none() + ); + let Some(SubscriptionEnd::Graceful(result)) = subscription.end() else { + panic!("expected graceful final result"); + }; + assert_eq!( + result.meta.subscription_id().as_ref(), + Some(subscription.id()) + ); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn typed_subscription_notifications_do_not_reach_handler_callbacks() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + let server = ToolsOnlyServer.serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + let tool_changes = Arc::new(AtomicUsize::new(0)); + let client = CountingClient { + tool_changes: tool_changes.clone(), + } + .serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(subscription.next().await?.is_some()); + assert!(subscription.next().await?.is_none()); + tokio::task::yield_now().await; + assert_eq!(tool_changes.load(Ordering::Relaxed), 0); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn discover_lifecycle_allows_subscriptions_with_older_application_version() +-> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + let server = ToolsOnlyServer.serve(server_transport).await?; + server.waiting().await?; + anyhow::Ok(()) + }); + let client = () + .serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2025_11_25], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(subscription.next().await?.is_some()); + assert!(subscription.next().await?.is_none()); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn concurrent_subscriptions_are_demultiplexed_by_request_id() -> anyhow::Result<()> { + let client = modern_client(ToolsAndPromptsServer).await?; + let (tools, prompts) = tokio::join!( + client.listen(SubscriptionFilter::builder().tools_list_changed().build()), + client.listen(SubscriptionFilter::builder().prompts_list_changed().build()) + ); + let mut tools = tools?; + let mut prompts = prompts?; + + let tool_notification = tools.next().await?.expect("tool notification"); + let prompt_notification = prompts.next().await?.expect("prompt notification"); + assert!(matches!( + tool_notification, + ServerNotification::ToolListChangedNotification(_) + )); + assert!(matches!( + prompt_notification, + ServerNotification::PromptListChangedNotification(_) + )); + assert_ne!(tools.id(), prompts.id()); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn stdio_cancellation_sends_cancelled_for_the_listen_request() -> anyhow::Result<()> { + let cancelled = Arc::new(Notify::new()); + let client = modern_client(CancellationServer { + cancelled: cancelled.clone(), + }) + .await?; + let mut subscription = client.listen(SubscriptionFilter::new()).await?; + + subscription.cancel().await?; + assert!(matches!( + subscription.end(), + Some(SubscriptionEnd::Cancelled) + )); + tokio::time::timeout(Duration::from_secs(5), cancelled.notified()).await?; + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn resource_updates_are_filtered_by_exact_uri_membership() -> anyhow::Result<()> { + let client = modern_client(ResourceSubscriptionServer).await?; + let mut subscription = client + .listen( + SubscriptionFilter::builder() + .resource_subscription("file:///accepted") + .build(), + ) + .await?; + + let notification = subscription.next().await?.expect("resource update"); + let ServerNotification::ResourceUpdatedNotification(update) = notification else { + panic!("expected resource update"); + }; + assert_eq!(update.params.uri, "file:///accepted"); + assert!(subscription.next().await?.is_none()); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn stdio_server_cancellation_ends_the_matching_subscription() -> anyhow::Result<()> { + let client = modern_client(RemoteCancellationServer).await?; + let mut subscription = client.listen(SubscriptionFilter::new()).await?; + + assert!(subscription.next().await?.is_none()); + assert!(matches!( + subscription.end(), + Some(SubscriptionEnd::Cancelled) + )); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn slow_consumer_reports_subscription_lag() -> anyhow::Result<()> { + let client = modern_client(FloodServer).await?; + let mut subscription = client + .listen_with_capacity( + SubscriptionFilter::builder().tools_list_changed().build(), + NonZeroUsize::MIN, + ) + .await?; + + tokio::time::sleep(Duration::from_millis(50)).await; + while subscription.next().await?.is_some() {} + assert!( + matches!( + subscription.end(), + Some(SubscriptionEnd::Lagged { capacity: 1 }) + ), + "unexpected end: {:?}", + subscription.end() + ); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn sink_rejects_notifications_after_graceful_completion() -> anyhow::Result<()> { + let sink = Arc::new(Mutex::new(None)); + let client = modern_client(ClosedSinkServer { sink: sink.clone() }).await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + assert!(subscription.next().await?.is_none()); + + let sink = sink.lock().await.clone().expect("captured sink"); + assert!(matches!( + sink.notify_tool_list_changed().await, + Err(SubscriptionSendError::SubscriptionClosed) + )); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn client_rejects_notifications_outside_the_acknowledged_filter() -> anyhow::Result<()> { + let client = modern_client(LeakyServer).await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(matches!( + subscription.next().await, + Err(rmcp::ServiceError::UnexpectedResponse) + )); + assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn malformed_acknowledgment_cancels_pending_listen_request() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + let cancelled = Arc::new(Notify::new()); + let server_cancelled = cancelled.clone(); + tokio::spawn(async move { + let server = MalformedAcknowledgmentServer { + cancelled: server_cancelled, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + }); + let client = () + .serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + + assert!(matches!( + client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await, + Err(rmcp::ServiceError::UnexpectedResponse) + )); + tokio::time::timeout(Duration::from_secs(5), cancelled.notified()).await?; + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn transport_close_without_final_result_is_abrupt() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(16 * 1024); + let started = Arc::new(Notify::new()); + let server_started = started.clone(); + tokio::spawn(async move { + let server = AbruptServer { + started: server_started.clone(), + } + .serve(server_transport) + .await?; + server_started.notified().await; + server.cancel().await?; + anyhow::Ok(()) + }); + let client = () + .serve_with_lifecycle( + client_transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client.listen(SubscriptionFilter::new()).await?; + + assert!( + tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await?? + .is_none() + ); + assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); + Ok(()) +} diff --git a/crates/rmcp/tests/test_subscriptions_model.rs b/crates/rmcp/tests/test_subscriptions_model.rs new file mode 100644 index 000000000..b0c8e51c3 --- /dev/null +++ b/crates/rmcp/tests/test_subscriptions_model.rs @@ -0,0 +1,237 @@ +use rmcp::model::{ + ClientJsonRpcMessage, ClientRequest, GetMeta, JsonRpcNotification, JsonRpcRequest, + NotificationMetaObject, RequestId, RequestMetaObject, ServerJsonRpcMessage, ServerNotification, + SubscriptionFilter, SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequest, + SubscriptionsListenRequestParams, SubscriptionsListenResult, SubscriptionsListenResultMeta, +}; +use serde_json::json; + +#[test] +fn subscription_filter_serializes_only_opted_in_notifications() { + let filter = SubscriptionFilter::builder() + .tools_list_changed() + .resource_subscription("file:///one") + .resource_subscription("file:///two") + .build(); + + assert_eq!( + serde_json::to_value(filter).expect("serialize filter"), + json!({ + "toolsListChanged": true, + "resourceSubscriptions": ["file:///one", "file:///two"], + }) + ); +} + +#[test] +fn subscription_filter_subset_is_order_independent_and_ignores_false_flags() { + let requested = SubscriptionFilter::builder() + .tools_list_changed() + .resource_subscriptions(["file:///one", "file:///two"]) + .build(); + let mut accepted = SubscriptionFilter::builder() + .resource_subscriptions(["file:///two", "file:///one"]) + .build(); + accepted.tools_list_changed = Some(false); + + assert!(accepted.is_subset_of(&requested)); +} + +#[test] +fn subscription_filter_omits_empty_resource_intersection() { + let requested = SubscriptionFilter::builder() + .resource_subscription("file:///requested") + .build(); + let accepted = SubscriptionFilter::builder() + .resource_subscription("file:///different") + .build(); + + assert_eq!( + serde_json::to_value(requested.intersection(&accepted)).expect("serialize intersection"), + json!({}) + ); +} + +#[test] +fn listen_request_round_trips_required_fields_and_arbitrary_metadata() { + let mut request = SubscriptionsListenRequest::new(SubscriptionsListenRequestParams::new( + SubscriptionFilter::builder().prompts_list_changed().build(), + )); + let mut meta = RequestMetaObject::new(); + meta.insert("com.example/request".into(), json!("value")); + request.extensions.insert(meta); + let message = ClientJsonRpcMessage::request( + ClientRequest::SubscriptionsListenRequest(request), + RequestId::String("subscription-1".into()), + ); + + let value = serde_json::to_value(&message).expect("serialize listen request"); + assert_eq!( + value, + json!({ + "jsonrpc": "2.0", + "id": "subscription-1", + "method": "subscriptions/listen", + "params": { + "_meta": { + "com.example/request": "value", + }, + "notifications": { + "promptsListChanged": true, + }, + }, + }) + ); + + let round_trip: ClientJsonRpcMessage = + serde_json::from_value(value).expect("deserialize listen request"); + let ClientJsonRpcMessage::Request(JsonRpcRequest { request, .. }) = round_trip else { + panic!("expected request"); + }; + let ClientRequest::SubscriptionsListenRequest(request) = request else { + panic!("expected subscriptions/listen request"); + }; + assert_eq!( + request + .extensions + .get::() + .and_then(|meta| meta.get("com.example/request")), + Some(&json!("value")) + ); +} + +#[test] +fn acknowledged_notification_round_trips_numeric_subscription_id_and_metadata() { + let mut notification = SubscriptionsAcknowledgedNotification::new( + SubscriptionsAcknowledgedNotificationParams::new( + SubscriptionFilter::builder() + .resources_list_changed() + .build(), + ), + ); + let mut meta = NotificationMetaObject::new(); + meta.set_subscription_id(RequestId::Number(7)); + meta.insert("com.example/notification".into(), json!(true)); + notification.extensions.insert(meta); + let message = ServerJsonRpcMessage::notification( + ServerNotification::SubscriptionsAcknowledgedNotification(notification), + ); + + let value = serde_json::to_value(&message).expect("serialize acknowledgment"); + assert_eq!( + value, + json!({ + "jsonrpc": "2.0", + "method": "notifications/subscriptions/acknowledged", + "params": { + "_meta": { + "io.modelcontextprotocol/subscriptionId": 7, + "com.example/notification": true, + }, + "notifications": { + "resourcesListChanged": true, + }, + }, + }) + ); + + let round_trip: ServerJsonRpcMessage = + serde_json::from_value(value).expect("deserialize acknowledgment"); + let ServerJsonRpcMessage::Notification(JsonRpcNotification { notification, .. }) = round_trip + else { + panic!("expected notification"); + }; + assert_eq!( + notification.get_meta().subscription_id(), + Some(RequestId::Number(7)) + ); + assert_eq!( + notification.get_meta().get("com.example/notification"), + Some(&json!(true)) + ); +} + +#[test] +fn listen_result_requires_matching_string_subscription_id_and_preserves_metadata() { + let mut meta = SubscriptionsListenResultMeta::new(RequestId::String("subscription-2".into())); + meta.insert("com.example/result".into(), json!({ "reason": "shutdown" })); + let result = SubscriptionsListenResult::new(meta); + + let value = serde_json::to_value(&result).expect("serialize listen result"); + assert_eq!( + value, + json!({ + "resultType": "complete", + "_meta": { + "io.modelcontextprotocol/subscriptionId": "subscription-2", + "com.example/result": { + "reason": "shutdown", + }, + }, + }) + ); + + let round_trip: SubscriptionsListenResult = + serde_json::from_value(value).expect("deserialize listen result"); + assert_eq!( + round_trip.meta.subscription_id(), + Some(RequestId::String("subscription-2".into())) + ); + assert_eq!( + round_trip.meta.get("com.example/result"), + Some(&json!({ "reason": "shutdown" })) + ); +} + +#[test] +fn listen_result_meta_returns_none_after_required_id_is_removed() { + let mut meta = SubscriptionsListenResultMeta::new(RequestId::Number(1)); + meta.remove("io.modelcontextprotocol/subscriptionId"); + + assert_eq!(meta.subscription_id(), None); +} + +#[cfg(feature = "schemars")] +#[test] +fn subscription_schemas_mark_only_draft_required_fields_as_required() { + let request_schema = + serde_json::to_value(schemars::schema_for!(SubscriptionsListenRequestParams)) + .expect("request schema"); + let filter_schema = + serde_json::to_value(schemars::schema_for!(SubscriptionFilter)).expect("filter schema"); + let acknowledgment_schema = serde_json::to_value(schemars::schema_for!( + SubscriptionsAcknowledgedNotificationParams + )) + .expect("acknowledgment schema"); + let result_schema = serde_json::to_value(schemars::schema_for!(SubscriptionsListenResult)) + .expect("result schema"); + + assert_eq!( + request_schema["required"], + json!(["_meta", "notifications"]) + ); + assert_eq!( + request_schema["properties"]["_meta"]["required"], + json!([ + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientInfo", + "io.modelcontextprotocol/clientCapabilities" + ]) + ); + assert!(filter_schema.get("required").is_none()); + assert_eq!( + filter_schema["properties"]["toolsListChanged"]["type"], + "boolean" + ); + assert_eq!( + filter_schema["properties"]["resourceSubscriptions"]["type"], + "array" + ); + assert_eq!(acknowledgment_schema["required"], json!(["notifications"])); + assert_eq!( + acknowledgment_schema["properties"]["_meta"]["$ref"], + "#/$defs/NotificationMetaObject" + ); + assert_eq!(result_schema["required"], json!(["resultType", "_meta"])); +} diff --git a/crates/rmcp/tests/test_subscriptions_streamable_http.rs b/crates/rmcp/tests/test_subscriptions_streamable_http.rs new file mode 100644 index 000000000..8acfe2873 --- /dev/null +++ b/crates/rmcp/tests/test_subscriptions_streamable_http.rs @@ -0,0 +1,310 @@ +#![cfg(all( + not(feature = "local"), + feature = "client", + feature = "server", + feature = "transport-streamable-http-client-reqwest", + feature = "transport-streamable-http-server" +))] + +use std::{ + borrow::Cow, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use rmcp::{ + ClientLifecycleMode, ClientServiceExt, ServerHandler, + model::{ + ClientInfo, ClientRequest, ListToolsRequest, ProtocolVersion, RequestMetaObject, + ServerCapabilities, ServerInfo, ServerNotification, SubscriptionFilter, + }, + service::{PeerRequestOptions, SubscriptionContext, SubscriptionEnd}, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }, +}; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +struct HttpSubscriptionServer { + cancelled: Arc, + started: Arc, + ending: ServerEnding, +} + +#[derive(Clone, Copy)] +enum ServerEnding { + ClientCancellation, + Graceful, + Abrupt, +} + +impl ServerHandler for HttpSubscriptionServer { + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(&[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25]) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> { + context + .sink() + .notify_tool_list_changed() + .await + .expect("send tool notification"); + self.started.notify_one(); + match self.ending { + ServerEnding::Graceful => Ok(()), + ServerEnding::ClientCancellation => { + context.cancelled().await; + self.cancelled.notify_one(); + Ok(()) + } + ServerEnding::Abrupt => std::future::pending().await, + } + } +} + +async fn spawn_server( + ending: ServerEnding, +) -> ( + String, + CancellationToken, + Arc, + Arc, + Arc, +) { + let cancellation_token = CancellationToken::new(); + let subscription_cancelled = Arc::new(Notify::new()); + let subscription_started = Arc::new(Notify::new()); + let server = HttpSubscriptionServer { + cancelled: subscription_cancelled.clone(), + started: subscription_started.clone(), + ending, + }; + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(server.clone()), + Default::default(), + StreamableHttpServerConfig::default() + .with_legacy_session_mode(true) + .with_json_response(true) + .with_sse_keep_alive(Some(Duration::from_millis(50))) + .with_cancellation_token(cancellation_token.child_token()), + ); + let get_requests = Arc::new(AtomicUsize::new(0)); + let observed_get_requests = get_requests.clone(); + let router = + axum::Router::new() + .nest_service("/mcp", service) + .layer(axum::middleware::from_fn( + move |request: axum::extract::Request, next: axum::middleware::Next| { + let observed_get_requests = observed_get_requests.clone(); + async move { + if request.method() == axum::http::Method::GET { + observed_get_requests.fetch_add(1, Ordering::Relaxed); + } + next.run(request).await + } + }, + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("listener address"); + tokio::spawn({ + let cancellation_token = cancellation_token.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { cancellation_token.cancelled_owned().await }) + .await; + } + }); + ( + format!("http://{address}/mcp"), + cancellation_token, + subscription_cancelled, + subscription_started, + get_requests, + ) +} + +#[tokio::test] +async fn modern_http_listen_uses_post_stream_and_cancels_by_closing_it() -> anyhow::Result<()> { + let (url, server_ct, subscription_cancelled, _, get_requests) = + spawn_server(ServerEnding::ClientCancellation).await; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(url.clone()), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + assert_eq!(get_requests.load(Ordering::Relaxed), 0); + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(matches!( + subscription.next().await?.expect("tool notification"), + ServerNotification::ToolListChangedNotification(_) + )); + let mut older_version = RequestMetaObject::new(); + older_version.set_protocol_version(ProtocolVersion::V_2025_11_25); + client + .send_request_with_option( + ClientRequest::ListToolsRequest(ListToolsRequest { + method: Default::default(), + params: None, + extensions: Default::default(), + }), + PeerRequestOptions::no_options().with_meta(older_version), + ) + .await? + .await_response() + .await?; + subscription.cancel().await?; + tokio::time::timeout(Duration::from_secs(5), subscription_cancelled.notified()).await?; + + client.cancel().await?; + server_ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn modern_http_graceful_close_returns_final_listen_result() -> anyhow::Result<()> { + let (url, server_ct, _, _, _) = spawn_server(ServerEnding::Graceful).await; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(url), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + assert!(subscription.next().await?.is_some()); + assert!(subscription.next().await?.is_none()); + assert!(matches!( + subscription.end(), + Some(SubscriptionEnd::Graceful(_)) + )); + + client.cancel().await?; + server_ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn modern_http_stream_close_without_result_is_abrupt() -> anyhow::Result<()> { + let (url, server_ct, _, subscription_started, _) = spawn_server(ServerEnding::Abrupt).await; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(url), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + assert!(subscription.next().await?.is_some()); + subscription_started.notified().await; + + server_ct.cancel(); + assert!( + tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await?? + .is_none() + ); + assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt))); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn modern_http_lifecycle_stays_sessionless_for_older_application_version() +-> anyhow::Result<()> { + let (url, server_ct, _, _, get_requests) = spawn_server(ServerEnding::ClientCancellation).await; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(url), + ); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2025_11_25], + }, + ) + .await?; + + client.list_tools(None).await?; + assert_eq!(get_requests.load(Ordering::Relaxed), 0); + + client.cancel().await?; + server_ct.cancel(); + Ok(()) +} + +#[tokio::test] +async fn modern_http_get_and_delete_are_method_not_allowed_in_legacy_session_mode() { + let (url, server_ct, _, _, _) = spawn_server(ServerEnding::ClientCancellation).await; + let client = reqwest::Client::new(); + + for method in [reqwest::Method::GET, reqwest::Method::DELETE] { + let response = client + .request(method, &url) + .header("Accept", "text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .send() + .await + .expect("request"); + assert_eq!(response.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); + assert_eq!( + response + .headers() + .get(reqwest::header::ALLOW) + .and_then(|value| value.to_str().ok()), + Some("POST") + ); + } + + server_ct.cancel(); +} diff --git a/examples/clients/Cargo.toml b/examples/clients/Cargo.toml index 416057ac8..419a176f9 100644 --- a/examples/clients/Cargo.toml +++ b/examples/clients/Cargo.toml @@ -65,3 +65,7 @@ path = "src/auth/client_credentials.rs" [[example]] name = "clients_task_stdio" path = "src/task_stdio.rs" + +[[example]] +name = "clients_subscriptions_streamhttp" +path = "src/subscriptions_streamhttp.rs" diff --git a/examples/clients/README.md b/examples/clients/README.md index 419cdac22..f082a4926 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -16,6 +16,13 @@ A client that communicates with a Git-related MCP server using standard input/ou A client that communicates with an MCP server using HTTP streaming transport. - Connects to an MCP server running at `http://localhost:8000` + +### Modern Subscription Client (`subscriptions_streamhttp.rs`) + +Uses modern discovery and `subscriptions/listen`, prints the accepted filter, +and consumes tagged notifications until graceful closure or cancellation. + +- Run with `cargo run -p mcp-client-examples --example clients_subscriptions_streamhttp` - Retrieves server information and list of available tools - Calls a tool named "increment" diff --git a/examples/clients/src/subscriptions_streamhttp.rs b/examples/clients/src/subscriptions_streamhttp.rs new file mode 100644 index 000000000..7ab612bb9 --- /dev/null +++ b/examples/clients/src/subscriptions_streamhttp.rs @@ -0,0 +1,49 @@ +use rmcp::{ + ClientLifecycleMode, ClientServiceExt, + model::{ClientInfo, ProtocolVersion, SubscriptionFilter}, + transport::StreamableHttpClientTransport, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let transport = StreamableHttpClientTransport::from_uri("http://127.0.0.1:8000/mcp"); + let client = ClientInfo::default() + .serve_with_lifecycle( + transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await?; + let mut subscription = client + .listen(SubscriptionFilter::builder().tools_list_changed().build()) + .await?; + + println!("accepted filter: {:?}", subscription.acknowledged()); + loop { + tokio::select! { + result = subscription.next() => { + match result? { + Some(notification) => println!("notification: {notification:?}"), + None => { + println!("subscription ended: {:?}", subscription.end()); + break; + } + } + } + _ = tokio::signal::ctrl_c() => { + subscription.cancel().await?; + break; + } + } + } + + client.cancel().await?; + Ok(()) +} diff --git a/examples/servers/Cargo.toml b/examples/servers/Cargo.toml index f189c9e7f..bc983a154 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -118,3 +118,7 @@ path = "src/task_stdio.rs" [[example]] name = "servers_mrtr" path = "src/mrtr.rs" + +[[example]] +name = "servers_subscriptions_streamhttp" +path = "src/subscriptions_streamhttp.rs" diff --git a/examples/servers/README.md b/examples/servers/README.md index a6f0dcf76..5fa5011ec 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -27,6 +27,13 @@ A server using streamable HTTP transport for MCP communication, with axum. - Provides counter tools via HTTP streaming - Demonstrates streamable HTTP transport configuration +### Modern Subscription Server (`subscriptions_streamhttp.rs`) + +A stateless `2026-07-28` server that opens `subscriptions/listen` response +streams, acknowledges the accepted filter, and emits tagged tool-list changes. + +- Run with `cargo run -p mcp-server-examples --example servers_subscriptions_streamhttp` + ### Counter Streamable HTTP Server with Hyper (`counter_hyper_streamable_http.rs`) A server using streamable HTTP transport for MCP communication, with hyper. diff --git a/examples/servers/src/subscriptions_streamhttp.rs b/examples/servers/src/subscriptions_streamhttp.rs new file mode 100644 index 000000000..aead85ff1 --- /dev/null +++ b/examples/servers/src/subscriptions_streamhttp.rs @@ -0,0 +1,83 @@ +use std::{borrow::Cow, time::Duration}; + +use rmcp::{ + ErrorData, ServerHandler, + model::{ProtocolVersion, ServerCapabilities, ServerInfo, SubscriptionFilter}, + service::SubscriptionContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +struct SubscriptionServer; + +impl ServerHandler for SubscriptionServer { + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(&[ProtocolVersion::V_2026_07_28]) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + } + + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + Some(requested.supported_by(&self.get_info().capabilities)) + } + + async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> { + loop { + tokio::select! { + () = context.cancelled() => return Ok(()), + () = tokio::time::sleep(Duration::from_secs(2)) => { + context + .sink() + .notify_tool_list_changed() + .await + .map_err(|error| { + ErrorData::internal_error(error.to_string(), None) + })?; + } + } + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let cancellation_token = CancellationToken::new(); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(SubscriptionServer), + Default::default(), + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_sse_keep_alive(Some(Duration::from_secs(10))) + .with_cancellation_token(cancellation_token.child_token()), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await?; + + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = tokio::signal::ctrl_c().await; + cancellation_token.cancel(); + }) + .await?; + Ok(()) +} From 7044ccfe7b245982b23976a552c6b37a01fdf076 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:53:21 -0400 Subject: [PATCH 253/333] chore: add workflow to clean up stale PRs and issues (#1007) --- .github/workflows/stale.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..3c7d1d7fb --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,35 @@ +name: Clean Up Stale PRs and Issues +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + stale: + name: Clean Up + runs-on: ubuntu-24.04 + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v10 + with: + days-before-stale: 60 + days-before-close: 14 + exempt-issue-labels: keep,security,pinned + exempt-pr-labels: keep,security,pinned + exempt-draft-pr: true + stale-issue-message: > + This issue has had no activity for 60 days and is now marked as stale. + It will be closed in 14 days if there is no further activity. Add the + `keep` label to keep it open. + close-issue-message: > + Closing this issue after 14 days of inactivity since it was marked stale. + Feel free to reopen if it is still relevant. + stale-pr-message: > + This pull request has had no activity for 60 days and is now marked as stale. + It will be closed in 14 days if there is no further activity. Add the + `keep` label to keep it open. + close-pr-message: > + Closing this pull request after 14 days of inactivity since it was marked stale. + Feel free to reopen if you plan to continue the work. From 9bf459962ee660c64648862415c6ef1b461d9d7d Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:23:35 -0400 Subject: [PATCH 254/333] fix: preserve JSON Schema 2020-12 keywords (#1018) --- conformance/expected-failures-2026-07-28.yaml | 6 +----- conformance/src/bin/server.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/conformance/expected-failures-2026-07-28.yaml b/conformance/expected-failures-2026-07-28.yaml index 85455fad9..41754013f 100644 --- a/conformance/expected-failures-2026-07-28.yaml +++ b/conformance/expected-failures-2026-07-28.yaml @@ -9,11 +9,7 @@ # When bumping DRAFT_CONFORMANCE_VERSION, diff # `conformance list --spec-version 2026-07-28` and update #977. -server: - # SEP-2106: composition/conditional/$anchor keywords are stripped from - # published tool input schemas. - # tracked in #1003 - - json-schema-2020-12 +server: [] client: # Client does not yet send MCP-Protocol-Version header pre-initialize as diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index ceecca3b7..441c5512b 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -556,6 +556,7 @@ impl ServerHandler for ConformanceServer { "type": "object", "$defs": { "address": { + "$anchor": "address", "type": "object", "properties": { "street": { "type": "string" }, @@ -567,6 +568,19 @@ impl ServerHandler for ConformanceServer { "name": { "type": "string" }, "address": { "$ref": "#/$defs/address" } }, + "allOf": [{ + "anyOf": [ + { "required": ["name"] }, + { "required": ["address"] } + ] + }], + "if": { "required": ["address"] }, + "then": { + "properties": { + "address": { "required": ["street"] } + } + }, + "else": { "required": ["name"] }, "additionalProperties": false })), ), From a8308dc9431a3a82d821bc6c012e2b23a96739b7 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:23:55 -0400 Subject: [PATCH 255/333] ci: expose extension conformance gaps (#1019) --- .github/workflows/conformance.yml | 42 +++++++++++++++++-- ROADMAP.md | 26 ++++++++---- conformance/expected-failures-2026-07-28.yaml | 6 ++- conformance/expected-failures-extensions.yaml | 32 ++++++++++++++ 4 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 conformance/expected-failures-extensions.yaml diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index d88230d48..f00ea9a6d 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -85,7 +85,7 @@ jobs: echo "draft conformance server did not become ready" >&2 exit 1 - - name: Run 2026-07-28 server suite + - name: Run 2026-07-28 versioned-spec server suite run: | npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ --url http://127.0.0.1:8002/mcp \ @@ -94,6 +94,32 @@ jobs: --expected-failures conformance/expected-failures-2026-07-28.yaml \ -o conformance-results + # Extension scenarios are excluded by the --spec-version filter and + # are informational for tiering. Run them explicitly so their gaps remain + # visible and newly passing scenarios make the strict baseline fail stale. + - name: Run Tasks extension server suite (informational) + run: | + scenarios=( + tasks-lifecycle + tasks-capability-negotiation + tasks-wire-fields + tasks-request-state-removal + tasks-mrtr-input + tasks-request-headers + tasks-dispatch-and-envelope + tasks-status-notifications + tasks-required-task-error + tasks-mrtr-composition + ) + + for scenario in "${scenarios[@]}"; do + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8002/mcp \ + --scenario "$scenario" \ + --expected-failures conformance/expected-failures-extensions.yaml \ + -o conformance-extension-results + done + - name: Stop conformance servers if: always() run: | @@ -105,7 +131,9 @@ jobs: uses: actions/upload-artifact@v7 with: name: conformance-server-results - path: conformance-results + path: | + conformance-results + conformance-extension-results client: runs-on: ubuntu-latest @@ -131,7 +159,7 @@ jobs: --spec-version 2025-11-25 \ -o conformance-client-results/full - - name: Run 2026-07-28 client suite + - name: Run 2026-07-28 versioned-spec client suite run: | npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ --command "$(pwd)/target/debug/conformance-client" \ @@ -140,6 +168,14 @@ jobs: --expected-failures conformance/expected-failures-2026-07-28.yaml \ -o conformance-client-results/draft + - name: Run extension client suite (informational) + run: | + npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ + --command "$(pwd)/target/debug/conformance-client" \ + --suite extensions \ + --expected-failures conformance/expected-failures-extensions.yaml \ + -o conformance-client-results/extensions + - name: Upload results if: always() uses: actions/upload-artifact@v7 diff --git a/ROADMAP.md b/ROADMAP.md index 2f0013cf5..04b0c859a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,10 @@ This roadmap tracks the path to SEP-1730 Tier 1 for the Rust MCP SDK. Spec 2025-11-25 (suite 0.1.16): Server 100% (30/30) · Client 100% (18/18) -Spec 2026-07-28 (suite 0.2.0-alpha.9): Server 92.5% (37/40) · Client 75.0% (24/32) +Spec 2026-07-28 (suite 0.2.0-alpha.9): Server 97.5% (39/40) · Client 90.6% (29/32) + +Extension scenarios are reported separately below because they are +informational and do not count toward SDK tiering. --- @@ -14,11 +17,20 @@ All 2026-07-28 work carries the `2026-07-28` label and the Per-scenario conformance status is tracked in the epic issue: [#977 — Tracking: 2026-07-28 spec conformance](https://github.com/modelcontextprotocol/rust-sdk/issues/977). -### Conformance (baseline 2026-07-13, suite `0.2.0-alpha.9`) +### Versioned-spec conformance (baseline 2026-07-21, suite `0.2.0-alpha.9`) + +- Server: 1 expected failure: `json-schema-2020-12` +- Client: 3 expected failures: `tools_call`, `auth/scope-step-up`, and `auth/authorization-server-migration` +- CI: runs the complete `2026-07-28` versioned-spec suites with a strict baseline; an unlisted failure or a listed scenario that starts passing fails the build + +### Extension conformance (informational) + +Extension-tagged scenarios are excluded by `--spec-version` filters, so CI +runs them in separate server and client steps with +`conformance/expected-failures-extensions.yaml`. -- Server: 3 scenarios (`tools-call-with-progress` stateless behavior, SEP-2243 server-side custom headers, and `server-stateless` — the SEP-2575 discovery/negotiation suite at 2/28 checks) -- Client: 8 scenarios (SEP-2243 headers ×3, `request-metadata`, and 4 single-check auth failures: SEP-2350 step-up, pre-registration, SEP-2352 AS migration, SEP-2468 issuer validation); fixes for SEP-2350 (#888) and SEP-2352 (#965) are already in review -- CI: run the full `--spec-version 2026-07-28` suites (stateless server) instead of hand-picked scenario lists; re-baseline on each draft-suite bump +- SEP-2663 Tasks server: 9 expected failures; `tasks-status-notifications` is currently skipped by the upstream harness; tracked in #868 +- Client extensions: `auth/client-credentials-basic` passes; `auth/client-credentials-jwt` and `auth/enterprise-managed-authorization` are expected failures ### Spec features without conformance scenarios @@ -100,5 +112,5 @@ These extension scenarios are tracked but do not count toward tier advancement: |---|---|---| | `auth/client-credentials-jwt` | extension | ❌ Failed — JWT `aud` claim verification error | | `auth/client-credentials-basic` | extension | ✅ Passed | -| `auth/cross-app-access-complete-flow` | extension | ❌ Failed — sends `authorization_code` grant instead of `jwt-bearer` | -| `tasks-*` | extension | Not yet attempted | +| `auth/enterprise-managed-authorization` | extension | ❌ Failed — scenario is not implemented by the conformance client | +| `tasks-*` | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped scenario | diff --git a/conformance/expected-failures-2026-07-28.yaml b/conformance/expected-failures-2026-07-28.yaml index 41754013f..2c86aeb9f 100644 --- a/conformance/expected-failures-2026-07-28.yaml +++ b/conformance/expected-failures-2026-07-28.yaml @@ -1,7 +1,11 @@ # Known failures for the pinned 2026-07-28 draft conformance suite # (@modelcontextprotocol/conformance DRAFT_CONFORMANCE_VERSION). # -# The full suites run in CI with `--expected-failures` pointing at this file: +# The full versioned-spec suites run in CI with `--expected-failures` pointing +# at this file. Extension scenarios are filtered out by `--spec-version` and +# tracked separately in `expected-failures-extensions.yaml`. +# +# Within the versioned-spec suites: # - a scenario failing that is NOT listed here fails the build # - a scenario listed here that starts passing also fails the build (stale entry), # so remove it from this list when the underlying issue is fixed. diff --git a/conformance/expected-failures-extensions.yaml b/conformance/expected-failures-extensions.yaml new file mode 100644 index 000000000..5230f523f --- /dev/null +++ b/conformance/expected-failures-extensions.yaml @@ -0,0 +1,32 @@ +# Known failures for informational extension scenarios in +# @modelcontextprotocol/conformance DRAFT_CONFORMANCE_VERSION. +# +# Extensions are not selected by a `--spec-version` run and do not count toward +# SDK tiering. CI runs them separately so a green versioned-spec suite does not +# hide extension gaps. +# +# This is a strict baseline: +# - an unlisted failure fails the build +# - a listed scenario that starts passing fails the build as a stale entry +# +# When bumping DRAFT_CONFORMANCE_VERSION, review the available extension and +# pending scenarios and update this file deliberately. + +server: + # SEP-2663 Tasks Extension, tracked in #868. + # `tasks-status-notifications` is intentionally absent: the upstream check is + # currently skipped, and CI should fail if it becomes active but does not pass. + - tasks-lifecycle + - tasks-capability-negotiation + - tasks-wire-fields + - tasks-request-state-removal + - tasks-mrtr-input + - tasks-request-headers + - tasks-dispatch-and-envelope + - tasks-required-task-error + - tasks-mrtr-composition + +client: + # Informational OAuth extension scenarios. + - auth/client-credentials-jwt + - auth/enterprise-managed-authorization From 4e36fd9531f20ab33cabd562ff19355db7426496 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:14:30 -0400 Subject: [PATCH 256/333] fix: re-register after auth server change (#1011) --- conformance/expected-failures-2026-07-28.yaml | 3 -- crates/rmcp/src/transport/auth.rs | 42 +++++++++++++++---- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/conformance/expected-failures-2026-07-28.yaml b/conformance/expected-failures-2026-07-28.yaml index 2c86aeb9f..29a166b76 100644 --- a/conformance/expected-failures-2026-07-28.yaml +++ b/conformance/expected-failures-2026-07-28.yaml @@ -23,6 +23,3 @@ client: # Auth feature gaps in the 2026-07-28 auth scenarios. # tracked in #1002 - auth/scope-step-up - # SEP-2352: SDK lacks issuer-stamped credential storage (#879), so the - # sep-2352-reregister-on-as-change check fails. - - auth/authorization-server-migration diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index f61f30364..c86dfb8bb 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1088,7 +1088,8 @@ impl AuthorizationManager { /// Initialize from stored credentials if available /// /// This will load credentials from the credential store and configure - /// the client if credentials are found. + /// the client if credentials are found. Returns `false` when credentials + /// are absent or discarded after an authorization-server change. pub async fn initialize_from_store(&mut self) -> Result { if let Some(stored) = self.credential_store.load().await? { if stored.token_response.is_some() { @@ -1133,10 +1134,7 @@ impl AuthorizationManager { "authorization server issuer changed; clearing stored credentials bound to the previous issuer" ); self.credential_store.clear().await?; - return Err(AuthError::AuthorizationServerMismatch { - expected_issuer: stored_issuer.to_string(), - received_issuer: current_issuer.to_string(), - }); + return Ok(false); } } @@ -3549,9 +3547,9 @@ mod tests { use super::{ AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, - InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError, - OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, - StateStore, StoredAuthorizationState, is_https_url, + CredentialStore, InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig, + OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, + OAuthHttpRequest, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, }; use crate::transport::auth::VendorExtraTokenFields; @@ -5134,6 +5132,34 @@ mod tests { mgr } + #[tokio::test] + async fn initialize_from_store_clears_dcr_credentials_when_issuer_changes() { + let store = InMemoryCredentialStore::new(); + store + .save(StoredCredentials { + client_id: "dcr-client".to_string(), + token_response: Some(make_token_response("old-token", Some(3600))), + granted_scopes: vec![], + token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: Some("https://old.example.com".to_string()), + }) + .await + .unwrap(); + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: "https://new.example.com/authorize".to_string(), + token_endpoint: "https://new.example.com/token".to_string(), + issuer: Some("https://new.example.com".to_string()), + ..Default::default() + })) + .await; + manager.set_credential_store(store.clone()); + + let initialized = manager.initialize_from_store().await.unwrap(); + let credentials_cleared = store.load().await.unwrap().is_none(); + + assert_eq!((initialized, credentials_cleared), (false, true)); + } + fn test_client_config() -> OAuthClientConfig { OAuthClientConfig { client_id: "my-client".to_string(), From 99ee02449998c838e2aa78fbe0f21ab0c013ccaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:22:29 -0400 Subject: [PATCH 257/333] chore(deps): bump actions/labeler from 6 to 7 (#1023) Bumps [actions/labeler](https://github.com/actions/labeler) from 6 to 7. - [Release notes](https://github.com/actions/labeler/releases) - [Commits](https://github.com/actions/labeler/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/labeler dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index f95dde7cc..9850dafa5 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -20,7 +20,7 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} steps: - - uses: actions/labeler@v6 + - uses: actions/labeler@v7 with: # Auto-include paths starting with dot (e.g. .github) dot: true From 8803d39034b6a7590151ab8fbf9b9cbe7c337bb4 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:01:22 -0400 Subject: [PATCH 258/333] fix: accept stringified numeric response IDs (#1021) --- crates/rmcp/src/model.rs | 16 ++++ crates/rmcp/src/service.rs | 19 +++- crates/rmcp/src/service/client.rs | 4 +- .../src/transport/streamable_http_client.rs | 50 +++++++++- .../rmcp/tests/test_client_initialization.rs | 93 ++++++++++++++++++- .../rmcp/tests/test_client_lifecycle_modes.rs | 41 +++++++- 6 files changed, 214 insertions(+), 9 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 97273d9ae..09d296e9a 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -240,6 +240,22 @@ impl NumberOrString { NumberOrString::String(s) => Value::String(s.to_string()), } } + + pub(crate) fn numeric_string_value(&self) -> Option { + match self { + Self::String(id) => id.parse().ok(), + Self::Number(_) => None, + } + } + + pub(crate) fn matches_response_id(&self, response_id: &Self) -> bool { + self == response_id + || matches!( + self, + Self::Number(request_id) + if response_id.numeric_string_value() == Some(*request_id) + ) + } } impl std::fmt::Display for NumberOrString { diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 7ef938e6d..4eeaeadf4 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -305,6 +305,17 @@ pub trait ProgressTokenProvider: Send + Sync + 'static { pub type AtomicU32RequestIdProvider = AtomicU32Provider; pub type AtomicU32ProgressTokenProvider = AtomicU32Provider; +pub(crate) fn remove_pending_request( + pending_requests: &mut HashMap, + response_id: &RequestId, +) -> Option { + pending_requests.remove(response_id).or_else(|| { + response_id + .numeric_string_value() + .and_then(|id| pending_requests.remove(&RequestId::Number(id))) + }) +} + #[derive(Debug, Default)] pub struct AtomicU32Provider { id: AtomicU64, @@ -1481,7 +1492,9 @@ where id, .. })) => { - if let Some(responder) = local_responder_pool.remove(&id) { + if let Some(responder) = + remove_pending_request(&mut local_responder_pool, &id) + { let response_result = responder.send(Ok(result)); if let Err(_error) = response_result { tracing::warn!(%id, "Error sending response"); @@ -1495,7 +1508,9 @@ where tracing::debug!(?error, "received id-less peer error"); continue; }; - if let Some(responder) = local_responder_pool.remove(&id) { + if let Some(responder) = + remove_pending_request(&mut local_responder_pool, &id) + { let service_error = if error.is_transport_closed() { ServiceError::TransportClosed } else { diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 35deb38c8..093a537b1 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -687,7 +687,7 @@ where let (response, response_id) = expect_response(transport, "initialize response", service, peer.clone()).await?; - if id != response_id { + if !id.matches_response_id(&response_id) { return Err(ClientInitializeError::ConflictInitResponseId( id, response_id, @@ -753,7 +753,7 @@ where match expect_response(transport, "discover response", service, peer.clone()).await { Ok((ServerResult::DiscoverResult(result), response_id)) => { - if response_id != id { + if !id.matches_response_id(&response_id) { return Err(ClientInitializeError::ConflictInitResponseId( id, response_id, diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 8c3a1f83d..432ff705c 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -499,8 +499,14 @@ impl StreamableHttpClientWorker { pending_stream_response_ids: &mut HashSet, message: &ServerJsonRpcMessage, ) { - if let Some(id) = Self::server_response_id(message) { - pending_stream_response_ids.remove(id); + let Some(response_id) = Self::server_response_id(message) else { + return; + }; + if pending_stream_response_ids.remove(response_id) { + return; + } + if let Some(id) = response_id.numeric_string_value() { + pending_stream_response_ids.remove(&RequestId::Number(id)); } } @@ -1382,7 +1388,10 @@ impl Worker for StreamableHttpClientWorker { } Event::ServerMessage(mut json_rpc_message) => { if let Some(response_id) = Self::server_response_id(&json_rpc_message) - && let Some(stream_ct) = request_stream_cancellations.remove(response_id) + && let Some(stream_ct) = crate::service::remove_pending_request( + &mut request_stream_cancellations, + response_id, + ) { stream_ct.cancel(); } @@ -1848,4 +1857,39 @@ mod tests { vec!["legacy"] ); } + + #[cfg(feature = "transport-streamable-http-client-reqwest")] + #[test] + fn clear_stream_response_pending_accepts_stringified_numeric_id() { + let mut pending = HashSet::from([NumberOrString::Number(1)]); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + NumberOrString::String("1".into()), + ); + + StreamableHttpClientWorker::::clear_stream_response_pending( + &mut pending, + &response, + ); + + assert!(pending.is_empty()); + } + + #[cfg(feature = "transport-streamable-http-client-reqwest")] + #[test] + fn clear_stream_response_pending_prefers_exact_string_id() { + let string_id = NumberOrString::String("1".into()); + let mut pending = HashSet::from([NumberOrString::Number(1), string_id.clone()]); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + string_id, + ); + + StreamableHttpClientWorker::::clear_stream_response_pending( + &mut pending, + &response, + ); + + assert_eq!(pending, HashSet::from([NumberOrString::Number(1)])); + } } diff --git a/crates/rmcp/tests/test_client_initialization.rs b/crates/rmcp/tests/test_client_initialization.rs index f51b33ef7..6c7984c5d 100644 --- a/crates/rmcp/tests/test_client_initialization.rs +++ b/crates/rmcp/tests/test_client_initialization.rs @@ -9,11 +9,102 @@ use common::handlers::TestClientHandler; use rmcp::{ ServiceExt, model::{ - ErrorCode, ErrorData, JsonRpcError, JsonRpcVersion2_0, RequestId, ServerJsonRpcMessage, + ClientJsonRpcMessage, ErrorCode, ErrorData, InitializeResult, JsonRpcError, + JsonRpcVersion2_0, RequestId, ServerCapabilities, ServerJsonRpcMessage, ServerResult, }, transport::{IntoTransport, Transport}, }; +fn stringify_numeric_id(id: RequestId) -> RequestId { + let RequestId::Number(id) = id else { + panic!("expected a numeric request ID"); + }; + RequestId::String(id.to_string().into()) +} + +#[tokio::test] +async fn client_initialization_accepts_stringified_numeric_response_id() { + let (server_transport, client_transport) = tokio::io::duplex(1024); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected initialize request"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult( + InitializeResult::new(ServerCapabilities::default()), + ), + stringify_numeric_id(request.id), + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + }); + + let client = TestClientHandler::new(true, true) + .serve(client_transport) + .await + .expect("client should accept stringified initialize response ID"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn client_correlates_stringified_numeric_response_id() { + let (server_transport, client_transport) = tokio::io::duplex(1024); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(initialize) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected initialize request"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult( + InitializeResult::new(ServerCapabilities::default()), + ), + initialize.id, + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected tools/list request") + else { + panic!("expected tools/list request"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(Default::default()), + stringify_numeric_id(request.id), + )) + .await + .expect("send tools/list response"); + }); + + let client = TestClientHandler::new(true, true) + .serve(client_transport) + .await + .expect("initialize client"); + client + .list_tools(None) + .await + .expect("client should correlate stringified response ID"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + #[tokio::test] async fn test_client_init_handles_jsonrpc_error() { let (server_transport, client_transport) = tokio::io::duplex(1024); diff --git a/crates/rmcp/tests/test_client_lifecycle_modes.rs b/crates/rmcp/tests/test_client_lifecycle_modes.rs index 66638c1f2..375364e88 100644 --- a/crates/rmcp/tests/test_client_lifecycle_modes.rs +++ b/crates/rmcp/tests/test_client_lifecycle_modes.rs @@ -4,7 +4,7 @@ use rmcp::{ ClientHandler, ClientLifecycleMode, ClientServiceExt, ServerHandler, ServiceExt, model::{ ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, GetMeta, - Implementation, InitializeResult, ProtocolVersion, ServerCapabilities, + Implementation, InitializeResult, ProtocolVersion, RequestId, ServerCapabilities, ServerJsonRpcMessage, ServerResult, }, service::PeerRequestOptions, @@ -21,6 +21,45 @@ struct StatelessServer; impl ServerHandler for StatelessServer {} +#[tokio::test] +async fn discover_startup_accepts_stringified_numeric_response_id() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected discover request") + else { + panic!("expected discover request"); + }; + let RequestId::Number(response_id) = request.id else { + panic!("expected a numeric request ID"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + Implementation::new("discover-server", "1.0.0"), + )), + RequestId::String(response_id.to_string().into()), + )) + .await + .expect("send discover response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("client should accept stringified discover response ID"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + #[tokio::test] async fn high_level_server_accepts_discover_startup_without_initialize() { let (server_transport, client_transport) = tokio::io::duplex(4096); From ac9c637d043e4138bc2ef740a13d6e7a77ffbd29 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 22 Jul 2026 14:15:36 -0400 Subject: [PATCH 259/333] fix: conformance tests for tools_call and auth/scope-step-up (#1022) --- .github/workflows/conformance.yml | 2 -- conformance/expected-failures-2026-07-28.yaml | 25 ---------------- conformance/src/bin/client.rs | 30 ++++++++++++++----- 3 files changed, 22 insertions(+), 35 deletions(-) delete mode 100644 conformance/expected-failures-2026-07-28.yaml diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index f00ea9a6d..1c69e1ce7 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -91,7 +91,6 @@ jobs: --url http://127.0.0.1:8002/mcp \ --suite all \ --spec-version 2026-07-28 \ - --expected-failures conformance/expected-failures-2026-07-28.yaml \ -o conformance-results # Extension scenarios are excluded by the --spec-version filter and @@ -165,7 +164,6 @@ jobs: --command "$(pwd)/target/debug/conformance-client" \ --suite all \ --spec-version 2026-07-28 \ - --expected-failures conformance/expected-failures-2026-07-28.yaml \ -o conformance-client-results/draft - name: Run extension client suite (informational) diff --git a/conformance/expected-failures-2026-07-28.yaml b/conformance/expected-failures-2026-07-28.yaml deleted file mode 100644 index 29a166b76..000000000 --- a/conformance/expected-failures-2026-07-28.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# Known failures for the pinned 2026-07-28 draft conformance suite -# (@modelcontextprotocol/conformance DRAFT_CONFORMANCE_VERSION). -# -# The full versioned-spec suites run in CI with `--expected-failures` pointing -# at this file. Extension scenarios are filtered out by `--spec-version` and -# tracked separately in `expected-failures-extensions.yaml`. -# -# Within the versioned-spec suites: -# - a scenario failing that is NOT listed here fails the build -# - a scenario listed here that starts passing also fails the build (stale entry), -# so remove it from this list when the underlying issue is fixed. -# -# When bumping DRAFT_CONFORMANCE_VERSION, diff -# `conformance list --spec-version 2026-07-28` and update #977. - -server: [] - -client: - # Client does not yet send MCP-Protocol-Version header pre-initialize as - # required by the 2026-07-28 stateless lifecycle. - # tracked in #1002 - - tools_call - # Auth feature gaps in the 2026-07-28 auth scenarios. - # tracked in #1002 - - auth/scope-step-up diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 54ce7c284..0785e0675 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -189,6 +189,7 @@ impl ClientHandler for FullClientHandler { const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json"; const REDIRECT_URI: &str = "http://localhost:3000/callback"; +const SCOPE_STEP_UP_INITIAL_SCOPES: &[&str] = &["mcp:basic"]; const SCOPE_STEP_UP_ESCALATED_SCOPES: &[&str] = &["mcp:basic", "mcp:write"]; /// Perform the headless OAuth authorization-code flow. @@ -320,11 +321,10 @@ async fn run_auth_scope_step_up_client( server_url: &str, _ctx: &ConformanceContext, ) -> anyhow::Result<()> { - // First auth let mut oauth = OAuthState::new(server_url, None).await?; oauth .start_authorization_with_metadata_url( - &[], + SCOPE_STEP_UP_INITIAL_SCOPES, REDIRECT_URI, Some("conformance-client"), Some(CIMD_CLIENT_METADATA_URL), @@ -351,7 +351,9 @@ async fn run_auth_scope_step_up_client( StreamableHttpClientTransportConfig::with_uri(server_url), ); - let client = BasicClientHandler.serve(transport).await?; + let client = BasicClientHandler + .serve_with_lifecycle(transport, conformance_lifecycle()) + .await?; let tools = client.list_tools(Default::default()).await?; tracing::debug!("Listed {} tools", tools.tools.len()); @@ -398,10 +400,12 @@ async fn run_auth_scope_step_up_client( auth_client2, StreamableHttpClientTransportConfig::with_uri(server_url), ); - let client2 = BasicClientHandler.serve(transport2).await?; - let _ = client2 + let client2 = BasicClientHandler + .serve_with_lifecycle(transport2, conformance_lifecycle()) + .await?; + client2 .call_tool(call_tool_params(tool.name.clone(), args)) - .await; + .await?; client2.cancel().await.ok(); return Ok(()); } @@ -859,7 +863,7 @@ async fn run_basic_client(server_url: &str) -> anyhow::Result<()> { } async fn run_tools_call_client(server_url: &str, ctx: &ConformanceContext) -> anyhow::Result<()> { - run_tools_call_client_with_lifecycle(server_url, ctx, ClientLifecycleMode::Initialize).await + run_tools_call_client_with_lifecycle(server_url, ctx, conformance_lifecycle()).await } async fn run_discover_tools_call_client( @@ -930,7 +934,17 @@ fn conformance_protocol_version() -> ProtocolVersion { std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION") .ok() .and_then(|version| serde_json::from_value(Value::String(version)).ok()) - .unwrap_or(ProtocolVersion::V_2026_07_28) + .unwrap_or(ProtocolVersion::V_2025_11_25) +} + +fn conformance_lifecycle() -> ClientLifecycleMode { + if conformance_protocol_version().as_str() >= ProtocolVersion::V_2026_07_28.as_str() { + ClientLifecycleMode::Discover { + preferred_versions: preferred_protocol_versions(), + } + } else { + ClientLifecycleMode::Initialize + } } /// Preferred protocol versions for discover-lifecycle negotiation: the From 07abfdfd99d655b92f41a47f06ce58825d6f3d73 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 22 Jul 2026 14:16:52 -0400 Subject: [PATCH 260/333] feat: Implement SEP-2663 Tasks Extension (#1020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat!: implement SEP-2663 Tasks extension, removing the experimental 2025-11-25 tasks design Reshape tasks from the experimental SEP-1319/1686 core-protocol feature into the official io.modelcontextprotocol/tasks extension (SEP-2663): Model: - Re-model Task (statusMessage, ttlMs nullable, pollIntervalMs) and add DetailedTask with status-discriminated payloads (inputRequests/result/error inlined per spec) - CreateTaskResult now flattens Task with resultType: "task"; add ResultType::TASK and CallToolResponse::Task - Add tasks/update (UpdateTaskParams with MRTR InputResponses); tasks/get and tasks/cancel reworked; remove tasks/list, tasks/result - notifications/tasks now carries a full DetailedTask; removed from client notifications (SEP-2260) Capabilities: - Remove core TasksCapability et al; tasks are declared via the extensions map (enable_tasks() builders, supports_tasks() accessors) - Remove tool-level execution.taskSupport and the _meta.task / TaskMetadata / with_task() opt-in: task creation is server-directed and gated on the per-request client capability Runtime: - Replace OperationProcessor with TaskManager: durable-before-response task creation, input_required round-trips via tasks/update, cooperative cancellation, TTL expiry - Client peer helpers get_task/update_task/cancel_task - Emit Mcp-Name routing header from params.taskId for tasks/* methods (SEP-2243/2663) Macros: - Remove #[task_handler] and the tool execution() attribute Tests/examples/docs updated; schema snapshots regenerated. BREAKING CHANGE: the 2025-11-25 experimental tasks API is removed without a compatibility shim. Clients that do not declare the tasks extension always receive synchronous results. * feat: gate tasks/* methods on the client tasks-extension capability (SEP-2663) When the server advertises io.modelcontextprotocol/tasks but the client did not declare it (per-request _meta clientCapabilities, or initialize-time capabilities in session mode), tasks/get, tasks/update, and tasks/cancel now return -32021 Missing Required Client Capability with the required extension in the error data, instead of falling through to the handler's -32601 default. Servers that do not advertise the extension keep returning -32601. Also add regression tests confirming that unknown taskIds yield -32602 and that a legacy 2025-11-25 'task' param on tools/call is silently ignored. * feat: pass SEP-2663 Tasks extension conformance suite Conformance fixtures (conformance/src/bin/server.rs): - Add the required fixture tools: greet (sync-only), slow_compute, failing_job (task support: required), protocol_error_job, confirm_delete, multi_input, and test_tool_with_task (MRTR -> task escalation), all backed by TaskManager with server-directed task creation gated on the client's tasks-extension capability - Advertise io.modelcontextprotocol/tasks in server capabilities and wire get_task/update_task/cancel_task handlers - Task-required tools reject with -32021 when the client did not declare the extension SDK wire-shape fixes surfaced by the suite: - Add resultType: "complete" to GetTaskResult and introduce TaskAckResult so tasks/update and tasks/cancel acks carry the SEP-2322 discriminator (spec: every non-CreateTaskResult response on the tasks surface is resultType complete); dispatch now returns ServerResult::task_ack - CreateTaskResult gets a strict deserializer requiring resultType: "task" so it does not shadow task-shaped results in untagged unions - Client update_task/cancel_task accept both TaskAckResult and EmptyResult All 9 runnable Tasks extension server scenarios now pass (35/35 checks; tasks-status-notifications remains upstream-skipped), so the corresponding entries are removed from conformance/expected-failures-extensions.yaml. 2025-11-25 server suite (40/40), 2026-07-28 server suite (114/114), draft client suite, and extensions client suite all pass their baselines. * fix: address PR 1020 review feedback on DetailedTask schema and cooperative cancel - DetailedTask's JsonSchema now derives from the actual flattened wire shape (DetailedTaskWire: base Task + optional inputRequests/result/error) instead of approximating with the base Task schema, so generated schemas for GetTaskResult and notifications/tasks document the status-specific payload fields. Golden message schemas regenerated. - TaskManager::cancel_task no longer aborts the underlying future. It records the observable cancelled state and acks immediately (spec's eventually consistent semantics), but lets the operation keep running so it can observe is_cancel_requested() or the error from a woken request_input() and perform cleanup; any late result is discarded. New unit tests cover both the cooperative-cleanup path and waking parked input requests. * fix: make tasks/cancel truly cooperative per SEP-2663 (FEEDBACK_2) - TaskManager::cancel_task no longer forces terminal 'cancelled'. It records the cancellation intent, acks immediately, and wakes parked request_input awaits, but the operation decides its own terminal state: a post-cancel error settles as 'cancelled', while an operation that finishes its work settles as 'completed' — per the spec, 'the task may still reach a non-cancelled terminal status'. - Add TaskContext::cancelled(), a watch-based await for use with tokio::select! as the cooperative cancellation exit path. - Correct the unsupported-notification method string from 'notifications/tasks/status' to 'notifications/tasks' and document that task status notifications are not yet routable through subscriptions/listen (SubscriptionFilter has no taskIds field; upstream check still skipped). - Update conformance slow_compute fixture, task_demo example, and tests to honor cancellation via ctx.cancelled(); lifecycle conformance still 8/8 (all 9 scenarios remain green, 35/35 checks). * fix: sweep and evict expired tasks from TaskManager (FEEDBACK_3) TTL handling previously only ran from get_task and only flipped overdue non-terminal tasks to 'failed', never removing entries — an unbounded leak for long-lived servers, and it made ttl_ms: None = 'unlimited retention' meaningless since everything was retained forever. - Rename expire_overdue to sweep_expired and run it from every entry point (spawn, get_task, update_task, cancel_task). - Track terminal_at on each entry; terminal tasks are evicted after being retained for one further ttl_ms window past their terminal transition, so well-behaved pollers can observe the final state before late tasks/get calls return -32602 (spec: servers may delete expired tasks at any time, and returning task-not-found for purged tasks is compliant behavior). - ttl_ms: None entries are never evicted (spec: unlimited retention); document the retention model on TaskManager, including that there is no background sweeper. - New tests: retention-window eviction, sweep of abandoned tasks via other entry points, unlimited-TTL retention, and error-code assertions (-32602) for unknown ids across get/update/cancel. Reviewed the second feedback item (dedicated task-not-found error code) against SEP-2663 §Protocol Errors and it is incorrect: the SEP explicitly specifies -32602 (Invalid params) for invalid or nonexistent taskIds, which is what unknown_task already returns. Kept -32602; strengthened tests to assert the code. * fix: address Copilot review feedback on PR #1020 - TaskManager: drop the JoinHandle as soon as the operation settles instead of retaining it for the whole retention window, and only store it at spawn time if the task is still non-terminal (avoids keeping a completed handle that the completion path could never clear). - Use RequestContext::client_capabilities() (which applies the initialize-time fallback for session peers) instead of raw context.meta.client_capabilities() in the task_demo example, the README snippet, and the test server — the meta-only form would wrongly treat session-declared tasks clients as unsupported. All 9 Tasks extension conformance scenarios remain green (35/35 checks). * fix: enforce SEP-2663 task-result gating in tools/call dispatch A handler could return CallToolResponse::Task without checking whether the request declared the io.modelcontextprotocol/tasks extension, sending a CreateTaskResult to a client that cannot parse it. The SDK dispatch now rejects that case with -32021 Missing Required Client Capability before the response leaves the server, using RequestContext::client_capabilities() (per-request _meta with initialize-time fallback). Adds a regression test with a deliberately misbehaving handler that always materializes a task; all Tasks extension conformance scenarios remain green (35/35 checks). * fix: align TTL boundary comparisons in sweep_expired Copilot review: ttlMs: 0 never expired immediately because phase 1 used a strict 'elapsed > ttl_ms' comparison, and phase 2 retained terminal tasks at exactly the TTL boundary ('elapsed <= ttl_ms'). Treat elapsed >= ttl_ms as expired in phase 1 and evict when elapsed >= ttl_ms in phase 2, so both phases agree at the boundary and ttlMs: 0 expires/evicts on the first sweep. * fix: strict TaskAckResult deserializer and TASK_REQUIRED_TOOLS consistency - TaskAckResult carried only resultType (+ optional _meta) with a derived Deserialize, so inside the untagged ServerResult union it greedily matched any result object containing a resultType key, shadowing CustomResult and losing data (verified with a probe). Replace with a strict deserializer: deny_unknown_fields and require resultType == "complete". Regression tests cover both the non-matching shapes and the genuine ack shape. - Conformance fixtures: confirm_delete and multi_input rejected non-tasks clients inline, contradicting the TASK_REQUIRED_TOOLS doc/constant. Move them into TASK_REQUIRED_TOOLS (they park on in-task elicitation and have no synchronous fallback) so the upfront -32021 gate is the single source of truth, and drop the now-unreachable inline checks. All 9 Tasks extension conformance scenarios remain green (35/35 checks). * fix: let the operation decide its terminal state via TaskExit After tasks/cancel, any operation error was coerced to terminal 'cancelled', masking real failures (e.g. an unrelated error landing just after a late cancel request) and contradicting the documented 'operation decides its own terminal state' contract. Change TaskFuture's error type from McpError to a new TaskExit enum: - TaskExit::Cancelled — an explicit cooperative-cancellation exit; settles as terminal 'cancelled'. - TaskExit::Error(McpError) — a real failure; settles as terminal 'failed' with the error inlined, even after tasks/cancel was received. From for TaskExit keeps '?' ergonomic in task bodies, and request_input() now returns TaskExit directly (its wake-on-cancel path yields TaskExit::Cancelled), so parked operations that propagate it with '?' settle as 'cancelled' automatically. Update the conformance fixtures, task_demo example, and tests; add a regression test asserting a post-cancel unrelated error settles as 'failed' with its error payload preserved. All 9 Tasks extension conformance scenarios remain green (35/35 checks). * fix: allow TaskExit enum to be exhaustive * fix: close spawn/shutdown race, sweep in running_task_count, clarify TTL retention docs Addresses branch-review findings: - spawn() raced with shutdown(): if shutdown drained the task map between the entry insert and the JoinHandle store, the handle was dropped and the operation kept running detached. If the entry is gone at store time, the handle is now aborted instead. - running_task_count() now runs the TTL sweep like every other entry point, so it no longer reports overdue tasks as running (matching the documented sweep-on-every-entry-point behavior). Regression test added. - Document that terminal-task retention intentionally extends one ttl_ms window past the terminal transition (observation grace period) beyond the creation-based lifetime ttlMs advertises on the wire — compliant since SEP-2663 allows deleting expired tasks at any time after the TTL. --- README.md | 38 +- conformance/expected-failures-extensions.yaml | 18 +- conformance/src/bin/server.rs | 345 ++++++ crates/rmcp-macros/README.md | 2 - crates/rmcp-macros/src/lib.rs | 19 +- crates/rmcp-macros/src/task_handler.rs | 286 ----- crates/rmcp-macros/src/tool.rs | 48 - crates/rmcp-macros/src/tool_handler.rs | 9 +- crates/rmcp/Cargo.toml | 2 +- crates/rmcp/src/handler/client.rs | 4 +- crates/rmcp/src/handler/server.rs | 194 ++- crates/rmcp/src/handler/server/router/tool.rs | 2 - .../handler/server/router/tool/tool_traits.rs | 6 +- crates/rmcp/src/handler/server/tool.rs | 7 +- crates/rmcp/src/model.rs | 171 +-- crates/rmcp/src/model/capabilities.rs | 284 ++--- crates/rmcp/src/model/meta.rs | 21 +- crates/rmcp/src/model/mrtr.rs | 26 +- crates/rmcp/src/model/serde_impl.rs | 8 - crates/rmcp/src/model/task.rs | 568 +++++++-- crates/rmcp/src/model/tool.rs | 77 -- crates/rmcp/src/service/client.rs | 80 +- crates/rmcp/src/service/server.rs | 7 +- crates/rmcp/src/task_manager.rs | 1051 +++++++++++++---- .../rmcp/src/transport/common/mcp_headers.rs | 6 + crates/rmcp/tests/test_deserialization.rs | 46 +- .../client_json_rpc_message_schema.json | 356 +----- ...lient_json_rpc_message_schema_current.json | 356 +----- .../server_json_rpc_message_schema.json | 533 +++------ ...erver_json_rpc_message_schema_current.json | 533 +++------ crates/rmcp/tests/test_task.rs | 484 ++++++-- .../tests/test_task_support_validation.rs | 251 ---- crates/rmcp/tests/test_tool_macros.rs | 4 +- examples/clients/README.md | 7 +- examples/clients/src/task_stdio.rs | 115 +- examples/servers/README.md | 10 +- examples/servers/src/common/counter.rs | 72 +- examples/servers/src/common/task_demo.rs | 140 ++- 38 files changed, 2950 insertions(+), 3236 deletions(-) delete mode 100644 crates/rmcp-macros/src/task_handler.rs delete mode 100644 crates/rmcp/tests/test_task_support_validation.rs diff --git a/README.md b/README.md index 7586d5c58..13219440f 100644 --- a/README.md +++ b/README.md @@ -971,21 +971,33 @@ and [client](examples/clients/src/subscriptions_streamhttp.rs) examples. ## Tasks (long-running tool invocations) -`rmcp` supports the [task-based tool invocation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) -flow defined in SEP-1319. Annotate a tool with `execution(task_support = "required" | "optional")` -and add `#[task_handler]` to your `ServerHandler` impl — `enqueue_task`, `tasks/list`, `tasks/get`, -`tasks/result`, and `tasks/cancel` are generated for you on top of an `OperationProcessor`. +`rmcp` implements the [MCP Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) +(SEP-2663, `io.modelcontextprotocol/tasks`). A client declares the extension in its +capabilities; the server then decides per request whether to materialize a `tools/call` +as a task, returning a `CreateTaskResult` (`resultType: "task"`). The client polls +`tasks/get`, answers in-task input requests via `tasks/update`, and may request +cooperative cancellation via `tasks/cancel`. Use `rmcp::task_manager::TaskManager` +to manage task lifecycles server-side. ```rust, ignore -#[tool( - description = "Sum two numbers after a 2-second delay", - execution(task_support = "required") -)] -async fn slow_sum(/* ... */) -> Result { /* ... */ } - -#[tool_handler] -#[task_handler] -impl ServerHandler for TaskDemo {} +// Client: declare the tasks extension capability. +let caps = ClientCapabilities::builder().enable_tasks().build(); + +// Server: decide per request whether to materialize a task. +async fn call_tool(&self, request: CallToolRequestParams, context: RequestContext) + -> Result +{ + let client_supports_tasks = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + if client_supports_tasks { + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { /* long-running work -> Ok(CallToolResult) */ }) + }); + return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); + } + // ... fall back to synchronous execution +} ``` See [`servers_task_stdio`](examples/servers/src/task_stdio.rs) and the matching diff --git a/conformance/expected-failures-extensions.yaml b/conformance/expected-failures-extensions.yaml index 5230f523f..13b94b668 100644 --- a/conformance/expected-failures-extensions.yaml +++ b/conformance/expected-failures-extensions.yaml @@ -12,19 +12,11 @@ # When bumping DRAFT_CONFORMANCE_VERSION, review the available extension and # pending scenarios and update this file deliberately. -server: - # SEP-2663 Tasks Extension, tracked in #868. - # `tasks-status-notifications` is intentionally absent: the upstream check is - # currently skipped, and CI should fail if it becomes active but does not pass. - - tasks-lifecycle - - tasks-capability-negotiation - - tasks-wire-fields - - tasks-request-state-removal - - tasks-mrtr-input - - tasks-request-headers - - tasks-dispatch-and-envelope - - tasks-required-task-error - - tasks-mrtr-composition +# The SEP-2663 Tasks Extension server scenarios (tracked in #868) all pass and +# were removed from this baseline. `tasks-status-notifications` remains +# upstream-skipped pending the subscriptions/listen rewrite; CI will fail if it +# becomes active but does not pass. +server: [] client: # Informational OAuth extension scenarios. diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 441c5512b..bf902f86f 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -11,6 +11,7 @@ use rmcp::{ ErrorData, RoleServer, ServerHandler, model::*, service::{RequestContext, SubscriptionContext, SubscriptionSink}, + task_manager::{TaskExit, TaskManager, TaskOptions}, transport::{ StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, @@ -59,6 +60,7 @@ struct ConformanceServer { next_subscription: Arc, log_level: Arc>, request_state_codec: RequestStateCodec, + tasks: TaskManager, } impl ConformanceServer { @@ -69,10 +71,97 @@ impl ConformanceServer { next_subscription: Arc::new(AtomicU64::new(0)), log_level: Arc::new(Mutex::new(LoggingLevel::Debug)), request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY), + tasks: TaskManager::new(), } } } +// ─── SEP-2663 Tasks extension fixtures ────────────────────────────────────── + +/// Fixture tools required by the Tasks extension conformance scenarios. +const TASK_FIXTURE_TOOLS: &[&str] = &[ + "greet", + "slow_compute", + "failing_job", + "protocol_error_job", + "confirm_delete", + "multi_input", + "test_tool_with_task", +]; + +/// Tools that are registered as task-supporting. `greet` is deliberately +/// sync-only. +const TASK_SUPPORTING_TOOLS: &[&str] = &[ + "slow_compute", + "failing_job", + "protocol_error_job", + "confirm_delete", + "multi_input", + "test_tool_with_task", +]; + +/// Tools that cannot be serviced without returning a `CreateTaskResult`: +/// calling them from a client that did not declare the tasks extension is +/// rejected with -32021 before the tool body runs (SEP-2663 §Required +/// Capabilities). `failing_job` and `test_tool_with_task` are registered +/// this way for the required-task-error and MRTR-composition scenarios; +/// `confirm_delete` and `multi_input` must park on in-task elicitation, so +/// they have no synchronous fallback either. +const TASK_REQUIRED_TOOLS: &[&str] = &[ + "failing_job", + "test_tool_with_task", + "confirm_delete", + "multi_input", +]; + +fn task_fixture_tool(name: &str) -> Tool { + let (description, schema) = match name { + "greet" => ( + "Sync-only greeting fixture (SEP-2663)", + json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + }), + ), + "slow_compute" => ( + "Task-supporting fixture: sleeps `seconds` then returns a result (SEP-2663)", + json!({ + "type": "object", + "properties": { + "seconds": { "type": "number" }, + "label": { "type": "string" } + } + }), + ), + "failing_job" => ( + "Task-supporting fixture (task support: required): returns a tool execution error (SEP-2663)", + json!({ "type": "object", "properties": {} }), + ), + "protocol_error_job" => ( + "Task-supporting fixture: fails with a protocol-level error (SEP-2663)", + json!({ "type": "object", "properties": {} }), + ), + "confirm_delete" => ( + "Task-supporting fixture: parks on a single elicitation inputRequest (SEP-2663)", + json!({ + "type": "object", + "properties": { "filename": { "type": "string" } } + }), + ), + "multi_input" => ( + "Task-supporting fixture: parks on two parallel elicitation inputRequests (SEP-2663)", + json!({ "type": "object", "properties": {} }), + ), + "test_tool_with_task" => ( + "MRTR round 1 gathers user_name, round 2 escalates to a task (SEP-2663 composition)", + json!({ "type": "object", "properties": {} }), + ), + other => panic!("unknown task fixture tool: {other}"), + }; + Tool::new(name.to_string(), description, json_object(schema)) +} + // ─── SEP-2322 MRTR (InputRequiredResult) helpers ──────────────────────────── fn mrtr_elicitation_request(message: &str, properties: Value, required: Value) -> InputRequest { @@ -118,6 +207,228 @@ impl ConformanceServer { ErrorData::invalid_params("requestState failed integrity verification", None) } + /// SEP-2663 task fixture tools. The server decides per request whether to + /// materialize a task: task-supporting tools create one when the client + /// declared the tasks extension capability; otherwise they fall through to + /// synchronous execution (except task-*required* tools, which reject with + /// -32021). + async fn call_task_fixture_tool( + &self, + request: CallToolRequestParams, + cx: &RequestContext, + ) -> Result { + let client_supports_tasks = cx + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + let name = request.name.as_ref(); + let args = request.arguments.clone().unwrap_or_default(); + + if TASK_REQUIRED_TOOLS.contains(&name) && !client_supports_tasks { + // SEP-2663 §Required Capabilities: this tool cannot be serviced + // without returning CreateTaskResult. + return Err(ErrorData::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )); + } + + let create_task = client_supports_tasks && TASK_SUPPORTING_TOOLS.contains(&name); + + match name { + "greet" => { + let who = args.get("name").and_then(Value::as_str).unwrap_or("friend"); + Ok( + CallToolResult::success(vec![ContentBlock::text(format!("Hello, {who}!"))]) + .into(), + ) + } + + "slow_compute" => { + let seconds = args.get("seconds").and_then(Value::as_f64).unwrap_or(1.0); + let label = args + .get("label") + .and_then(Value::as_str) + .unwrap_or("compute") + .to_string(); + if create_task { + // The lifecycle scenario requires slow_compute to settle + // to `cancelled` when tasks/cancel arrives while running; + // cancellation is cooperative, so honor it explicitly. + let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => Err(TaskExit::Cancelled), + _ = tokio::time::sleep( + std::time::Duration::from_secs_f64(seconds), + ) => Ok(CallToolResult::success(vec![ContentBlock::text( + format!("slow_compute({label}) done after {seconds}s"), + )])), + } + }) + }); + Ok(CreateTaskResult::new(task).into()) + } else { + tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await; + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "slow_compute({label}) done after {seconds}s" + ))]) + .into()) + } + } + + "failing_job" => { + // Tool execution error: surfaces as status "completed" with + // result.isError = true when run as a task. + let work = || async { + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + Ok(CallToolResult::error(vec![ContentBlock::text( + "failing_job: intentional tool execution error", + )])) + }; + if create_task { + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { work().await.map_err(TaskExit::Error) }) + }); + Ok(CreateTaskResult::new(task).into()) + } else { + Ok(work().await?.into()) + } + } + + "protocol_error_job" => { + // Protocol-level failure: surfaces as status "failed" with an + // inlined `error` object when run as a task. + let work = || async { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Err(ErrorData::internal_error( + "protocol_error_job: intentional protocol-level failure", + None, + )) + }; + if create_task { + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { work().await.map_err(TaskExit::Error) }) + }); + Ok(CreateTaskResult::new(task).into()) + } else { + work().await.map(CallToolResponse::from) + } + } + + "confirm_delete" => { + let filename = args + .get("filename") + .and_then(Value::as_str) + .unwrap_or("file.txt") + .to_string(); + let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { + Box::pin(async move { + let response = ctx + .request_input( + "confirm", + mrtr_elicitation_request( + &format!("Delete {filename}?"), + json!({ "confirm": { "type": "boolean" } }), + json!(["confirm"]), + ), + ) + .await?; + let confirmed = response + .get("content") + .and_then(|c| c.get("confirm")) + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "confirm_delete({filename}): confirmed = {confirmed}" + ))])) + }) + }); + Ok(CreateTaskResult::new(task).into()) + } + + "multi_input" => { + let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { + Box::pin(async move { + // Fan out two elicitation requests in parallel so two + // keys are pending at once (partial fulfillment check). + let first = ctx.request_input( + "input-a", + mrtr_elicitation_request( + "Provide value A", + json!({ "value": { "type": "string" } }), + json!(["value"]), + ), + ); + let second = ctx.request_input( + "input-b", + mrtr_elicitation_request( + "Provide value B", + json!({ "value": { "type": "string" } }), + json!(["value"]), + ), + ); + let (a, b) = tokio::join!(first, second); + let (a, b) = (a?, b?); + let get = |v: &Value| { + v.get("content") + .and_then(|c| c.get("value")) + .and_then(Value::as_str) + .unwrap_or("(none)") + .to_string() + }; + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "multi_input: a = {}, b = {}", + get(&a), + get(&b) + ))])) + }) + }); + Ok(CreateTaskResult::new(task).into()) + } + + "test_tool_with_task" => { + // SEP-2663 MRTR → Tasks composition. Round 1 (no inputResponses) + // is a plain MRTR InputRequiredResult; round 2 escalates to a + // task whose result reflects the gathered user_name. + match mrtr_response(request.input_responses.as_ref(), "user_name") { + None => { + let mut requests = InputRequests::new(); + requests.insert( + "user_name".into(), + mrtr_elicitation_request( + "What is your name?", + json!({ "name": { "type": "string" } }), + json!(["name"]), + ), + ); + Ok(InputRequiredResult::from_input_requests(requests).into()) + } + Some(response) => { + let user_name = response + .get("content") + .and_then(|c| c.get("name")) + .and_then(Value::as_str) + .unwrap_or("friend") + .to_string(); + let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| { + Box::pin(async move { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "Hello, {user_name}! (async)" + ))])) + }) + }); + Ok(CreateTaskResult::new(task).into()) + } + } + } + + other => Err(ErrorData::invalid_params( + format!("Unknown task fixture tool: {other}"), + None, + )), + } + } + /// SEP-2322 test tools. Each returns an `InputRequiredResult` until the /// client retries with the expected `inputResponses` (and, where used, the /// echoed `requestState`). @@ -400,6 +711,7 @@ impl ServerHandler for ConformanceServer { .enable_tools() .enable_tool_list_changed() .enable_logging() + .enable_tasks() .build(), ) .with_server_info(Implementation::new("rust-conformance-server", "0.1.0")) @@ -440,6 +752,31 @@ impl ServerHandler for ConformanceServer { Ok(()) } + async fn get_task( + &self, + request: GetTaskParams, + _cx: RequestContext, + ) -> Result { + Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) + } + + async fn update_task( + &self, + request: UpdateTaskParams, + _cx: RequestContext, + ) -> Result<(), ErrorData> { + self.tasks + .update_task(&request.task_id, request.input_responses) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _cx: RequestContext, + ) -> Result<(), ErrorData> { + self.tasks.cancel_task(&request.task_id) + } + async fn list_tools( &self, _request: Option, @@ -678,6 +1015,11 @@ impl ServerHandler for ConformanceServer { json_object(json!({ "type": "object", "properties": {} })), ) })) + .chain( + TASK_FIXTURE_TOOLS + .iter() + .map(|name| task_fixture_tool(name)), + ) .collect(); Ok(ListToolsResult { tools, @@ -695,6 +1037,9 @@ impl ServerHandler for ConformanceServer { if request.name.starts_with("test_input_required_result_") { return self.call_mrtr_tool(request, &cx.meta).await; } + if TASK_FIXTURE_TOOLS.contains(&request.name.as_ref()) { + return self.call_task_fixture_tool(request, &cx).await; + } let args = request.arguments.unwrap_or_default(); let result = match request.name.as_ref() { "test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text( diff --git a/crates/rmcp-macros/README.md b/crates/rmcp-macros/README.md index cd9262edc..adc838874 100644 --- a/crates/rmcp-macros/README.md +++ b/crates/rmcp-macros/README.md @@ -25,7 +25,6 @@ For **getting started** and **full MCP feature documentation**, see the [main RE | [`#[prompt]`][prompt] | Mark a function as an MCP prompt handler | | [`#[prompt_router]`][prompt_router] | Generate a prompt router from an impl block | | [`#[prompt_handler]`][prompt_handler] | Generate `get_prompt` and `list_prompts` handler methods | -| [`#[task_handler]`][task_handler] | Wire up the task lifecycle on top of an `OperationProcessor` | [tool]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool.html [tool_router]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool_router.html @@ -33,7 +32,6 @@ For **getting started** and **full MCP feature documentation**, see the [main RE [prompt]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt.html [prompt_router]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt_router.html [prompt_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt_handler.html -[task_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.task_handler.html ## Quick Example diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index e721338d9..156e53b4a 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -7,7 +7,6 @@ mod common; mod prompt; mod prompt_handler; mod prompt_router; -mod task_handler; mod tool; mod tool_handler; mod tool_router; @@ -100,7 +99,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream { /// impl MyToolHandler { /// #[tool] /// fn my_tool_a() { -/// +/// /// } /// } /// } @@ -110,7 +109,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream { /// impl MyToolHandler { /// #[tool] /// fn my_tool_b() { -/// +/// /// } /// } /// } @@ -299,17 +298,3 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> TokenStream { .unwrap_or_else(|err| err.to_compile_error()) .into() } - -/// # task_handler -/// -/// Generates basic task-handling methods (`enqueue_task` and `list_tasks`) for a server handler -/// using a shared \[`OperationProcessor`\]. The default processor expression assumes a -/// `self.processor` field holding an `Arc>`, but it can be customized -/// via `#[task_handler(processor = ...)]`. Because the macro captures `self` inside spawned -/// futures, the handler type must implement [`Clone`]. -#[proc_macro_attribute] -pub fn task_handler(attr: TokenStream, input: TokenStream) -> TokenStream { - task_handler::task_handler(attr.into(), input.into()) - .unwrap_or_else(|err| err.to_compile_error()) - .into() -} diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs deleted file mode 100644 index c743463cb..000000000 --- a/crates/rmcp-macros/src/task_handler.rs +++ /dev/null @@ -1,286 +0,0 @@ -use darling::{FromMeta, ast::NestedMeta}; -use proc_macro2::TokenStream; -use quote::{ToTokens, quote}; -use syn::{Expr, ImplItem, ItemImpl}; - -use crate::common::{has_method, has_sibling_handler}; - -#[derive(FromMeta)] -#[darling(default)] -struct TaskHandlerAttribute { - processor: Expr, -} - -impl Default for TaskHandlerAttribute { - fn default() -> Self { - Self { - processor: syn::parse2(quote! { self.processor }).expect("default processor expr"), - } - } -} - -pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result { - let attr_args = NestedMeta::parse_meta_list(attr)?; - let TaskHandlerAttribute { processor } = TaskHandlerAttribute::from_list(&attr_args)?; - let mut item_impl = syn::parse2::(input)?; - - if !has_method("list_tasks", &item_impl) { - let list_fn = quote! { - async fn list_tasks( - &self, - _request: Option, - _: rmcp::service::RequestContext, - ) -> Result { - let running_ids = (#processor).lock().await.list_running(); - let total = running_ids.len() as u64; - let tasks = running_ids - .into_iter() - .map(|task_id| { - let timestamp = rmcp::task_manager::current_timestamp(); - rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Working, - timestamp.clone(), - timestamp, - ) - }) - .collect::>(); - - Ok(rmcp::model::ListTasksResult::new(tasks)) - } - }; - item_impl.items.push(syn::parse2::(list_fn)?); - } - - if !has_method("enqueue_task", &item_impl) { - let enqueue_fn = quote! { - async fn enqueue_task( - &self, - request: rmcp::model::CallToolRequestParams, - context: rmcp::service::RequestContext, - ) -> Result { - use rmcp::task_manager::{ - current_timestamp, OperationDescriptor, OperationMessage, OperationResultTransport, - ToolCallTaskResult, - }; - let task_id = context.id.to_string(); - let operation_name = request.name.to_string(); - let future_request = request.clone(); - let future_context = context.clone(); - let server = self.clone(); - - let descriptor = OperationDescriptor::new(task_id.clone(), operation_name) - .with_context(context) - .with_client_request(rmcp::model::ClientRequest::CallToolRequest( - rmcp::model::Request::new(request), - )); - - let task_result_id = task_id.clone(); - let future = Box::pin(async move { - let result = server - .call_tool(future_request, future_context) - .await - .and_then(|response| match response { - rmcp::model::CallToolResponse::Complete(result) => Ok(result), - _ => Err(rmcp::ErrorData::internal_error( - "input_required is not supported for task-based tool calls", - None, - )), - }); - Ok( - Box::new(ToolCallTaskResult::new(task_result_id, result)) - as Box, - ) - }); - - (#processor) - .lock() - .await - .submit_operation(OperationMessage::new(descriptor, future)) - .map_err(|err| rmcp::ErrorData::internal_error( - format!("failed to enqueue task: {err}"), - None, - ))?; - - let timestamp = current_timestamp(); - let task = rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Working, - timestamp.clone(), - timestamp, - ).with_status_message("Task accepted"); - - Ok(rmcp::model::CreateTaskResult::new(task)) - } - }; - item_impl.items.push(syn::parse2::(enqueue_fn)?); - } - - if !has_method("get_task_info", &item_impl) { - let get_info_fn = quote! { - async fn get_task_info( - &self, - request: rmcp::model::GetTaskParams, - _context: rmcp::service::RequestContext, - ) -> Result { - use rmcp::task_manager::current_timestamp; - let task_id = request.task_id.clone(); - let mut processor = (#processor).lock().await; - - // Check completed results first - let completed = processor.peek_completed().iter().rev().find(|r| r.descriptor.operation_id == task_id); - if let Some(completed_result) = completed { - // Determine Finished vs Failed - let status = match &completed_result.result { - Ok(boxed) => { - if let Some(tool) = boxed.as_any().downcast_ref::() { - match &tool.result { - Ok(_) => rmcp::model::TaskStatus::Completed, - Err(_) => rmcp::model::TaskStatus::Failed, - } - } else { - rmcp::model::TaskStatus::Completed - } - } - Err(_) => rmcp::model::TaskStatus::Failed, - }; - let timestamp = current_timestamp(); - let mut task = rmcp::model::Task::new( - task_id, - status, - timestamp.clone(), - timestamp, - ); - if let Some(ttl) = completed_result.descriptor.ttl { - task = task.with_ttl(ttl); - } - return Ok(rmcp::model::GetTaskResult::new(task)); - } - - // If not completed, check running - let running = processor.list_running(); - if running.into_iter().any(|id| id == task_id) { - let timestamp = current_timestamp(); - let task = rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Working, - timestamp.clone(), - timestamp, - ); - return Ok(rmcp::model::GetTaskResult::new(task)); - } - - Err(McpError::resource_not_found(format!("task not found: {}", task_id), None)) - } - }; - item_impl.items.push(syn::parse2::(get_info_fn)?); - } - - if !has_method("get_task_result", &item_impl) { - let get_result_fn = quote! { - async fn get_task_result( - &self, - request: rmcp::model::GetTaskPayloadParams, - _context: rmcp::service::RequestContext, - ) -> Result { - use std::time::Duration; - let task_id = request.task_id.clone(); - - loop { - // Scope the lock so we can await outside if needed - { - let mut processor = (#processor).lock().await; - - if let Some(task_result) = processor.take_completed_result(&task_id) { - match task_result.result { - Ok(boxed) => { - if let Some(tool) = boxed.as_any().downcast_ref::() { - match &tool.result { - Ok(call_tool) => { - let value = ::rmcp::serde_json::to_value(call_tool).unwrap_or_default(); - return Ok(rmcp::model::GetTaskPayloadResult::new(value)); - } - Err(err) => return Err(McpError::internal_error( - format!("task failed: {}", err), - None, - )), - } - } else { - return Err(McpError::internal_error("unsupported task result transport", None)); - } - } - Err(err) => return Err(McpError::internal_error( - format!("task execution error: {}", err), - None, - )), - } - } - - // Not completed yet: if not running, return not found - let running = processor.list_running(); - if !running.iter().any(|id| id == &task_id) { - return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None)); - } - } - - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - }; - item_impl - .items - .push(syn::parse2::(get_result_fn)?); - } - - if !has_method("cancel_task", &item_impl) { - let cancel_fn = quote! { - async fn cancel_task( - &self, - request: rmcp::model::CancelTaskParams, - _context: rmcp::service::RequestContext, - ) -> Result { - use rmcp::task_manager::current_timestamp; - let task_id = request.task_id; - let mut processor = (#processor).lock().await; - - if processor.cancel_task(&task_id) { - let timestamp = current_timestamp(); - let task = rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Cancelled, - timestamp.clone(), - timestamp, - ); - return Ok(rmcp::model::CancelTaskResult::new(task)); - } - - // If already completed, signal it's not cancellable - let exists_completed = processor.peek_completed().iter().any(|r| r.descriptor.operation_id == task_id); - if exists_completed { - return Err(McpError::invalid_request(format!("task already completed: {}", task_id), None)); - } - - Err(McpError::resource_not_found(format!("task not found: {}", task_id), None)) - } - }; - item_impl.items.push(syn::parse2::(cancel_fn)?); - } - - // Auto-generate get_info() if not already provided and no sibling tool/prompt handler - // will generate it (they take priority since they run as outer attributes). - if !has_method("get_info", &item_impl) - && !has_sibling_handler(&item_impl, "tool_handler") - && !has_sibling_handler(&item_impl, "prompt_handler") - { - let get_info_fn = crate::tool_handler::build_get_info( - &item_impl, - None, - None, - None, - crate::tool_handler::CallerCapability::Tasks, - )?; - item_impl.items.push(get_info_fn); - } - - Ok(item_impl.into_token_stream()) -} diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index c289c32c8..cb11042eb 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -75,8 +75,6 @@ pub struct ToolAttribute { pub output_schema: Option, /// Optional additional tool information. pub annotations: Option, - /// Execution-related configuration including task support. - pub execution: Option, /// Optional icons for the tool pub icons: Option, /// Optional metadata for the tool @@ -86,13 +84,6 @@ pub struct ToolAttribute { pub local: bool, } -#[derive(FromMeta, Debug, Default)] -#[darling(default)] -pub struct ToolExecutionAttribute { - /// Task support mode: "forbidden", "optional", or "required" - pub task_support: Option, -} - pub struct ResolvedToolAttribute { pub name: String, pub title: Option, @@ -100,7 +91,6 @@ pub struct ResolvedToolAttribute { pub input_schema: Expr, pub output_schema: Option, pub annotations: Option, - pub execution: Option, pub icons: Option, pub meta: Option, } @@ -114,7 +104,6 @@ impl ResolvedToolAttribute { input_schema, output_schema, annotations, - execution, icons, meta, } = self; @@ -132,9 +121,6 @@ impl ResolvedToolAttribute { let annotations_call = annotations .map(|a| quote! { .with_annotations(#a) }) .unwrap_or_default(); - let execution_call = execution - .map(|e| quote! { .with_execution(#e) }) - .unwrap_or_default(); let icons_call = icons .map(|i| quote! { .with_icons(#i) }) .unwrap_or_default(); @@ -152,7 +138,6 @@ impl ResolvedToolAttribute { #title_call #output_schema_call #annotations_call - #execution_call #icons_call #meta_call } @@ -264,38 +249,6 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { } else { None }; - let execution_expr = if let Some(execution) = attribute.execution { - let ToolExecutionAttribute { task_support } = execution; - - let task_support_expr = if let Some(ts) = task_support { - let ts_ident = match ts.as_str() { - "forbidden" => quote! { rmcp::model::TaskSupport::Forbidden }, - "optional" => quote! { rmcp::model::TaskSupport::Optional }, - "required" => quote! { rmcp::model::TaskSupport::Required }, - _ => { - return Err(syn::Error::new( - Span::call_site(), - format!( - "Invalid task_support value '{}'. Expected 'forbidden', 'optional', or 'required'", - ts - ), - )); - } - }; - quote! { Some(#ts_ident) } - } else { - quote! { None } - }; - - let token_stream = quote! { - rmcp::model::ToolExecution::from_raw( - #task_support_expr, - ) - }; - Some(syn::parse2::(token_stream)?) - } else { - None - }; // Handle output_schema - either explicit or generated from return type let output_schema_expr = attribute.output_schema.or_else(|| { // Try to generate schema from return type @@ -319,7 +272,6 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { input_schema: input_schema_expr, output_schema: output_schema_expr, annotations: annotations_expr, - execution: execution_expr, title: attribute.title, icons: attribute.icons, meta: attribute.meta, diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index f09aec53e..7732687d0 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -110,13 +110,12 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result quote! { rmcp::model::Implementation::new(#n, #v) }, (Some(n), None) => { diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 280cfd376..60a4cb296 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -121,7 +121,7 @@ chrono = { version = "0.4.38", default-features = false, features = [ default = ["base64", "macros", "server"] local = ["rmcp-macros?/local"] client = ["dep:tokio-stream"] -server = ["transport-async-rw", "schemars", "dep:pastey"] +server = ["transport-async-rw", "schemars", "dep:pastey", "uuid"] macros = ["dep:rmcp-macros", "dep:pastey"] elicitation = ["dep:url"] diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 99d099d65..d61070b61 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -251,7 +251,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { fn on_task_status( &self, - params: TaskStatusNotificationParam, + params: TaskStatusNotificationParams, context: NotificationContext, ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) @@ -386,7 +386,7 @@ macro_rules! impl_client_handler_for_wrapper { fn on_task_status( &self, - params: TaskStatusNotificationParam, + params: TaskStatusNotificationParams, context: NotificationContext, ) -> impl Future + MaybeSendFuture + '_ { (**self).on_task_status(params, context) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 4c5f321c3..bebbcb9a9 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -4,7 +4,7 @@ use std::{borrow::Cow, sync::Arc}; use crate::{ error::ErrorData as McpError, - model::{TaskSupport, *}, + model::*, service::{ MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole, SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle, @@ -19,6 +19,34 @@ pub mod tool; pub mod tool_name_validation; pub mod wrapper; +/// SEP-2663: gate `tasks/*` methods on the client's declared tasks-extension +/// capability. +/// +/// - If the server does not advertise the tasks extension, the methods are +/// simply unimplemented: `-32601` Method not found. +/// - If the server advertises it but the client did not declare it (either in +/// the request's `_meta` per-request capabilities or, for session-mode +/// peers, at `initialize` time), the spec requires `-32021` Missing +/// Required Client Capability with the required capability in `data`. +fn validate_tasks_capability( + handler: &H, + context: &RequestContext, +) -> Result<(), McpError> { + if !handler.get_info().capabilities.supports_tasks() { + return Err(McpError::method_not_found::()); + } + let client_declared = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + if client_declared { + Ok(()) + } else { + Err(McpError::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )) + } +} + impl Service for H { async fn handle_request( &self, @@ -170,41 +198,20 @@ impl Service for H { } } ClientRequest::CallToolRequest(request) => { - let is_task = request.params.task.is_some(); - - // Validate task support mode per MCP specification - if let Some(tool) = self.get_tool(&request.params.name) { - match (tool.task_support(), is_task) { - // If taskSupport is "required", clients MUST invoke the tool as a task. - // Servers MUST return a -32601 (Method not found) error if they don't. - (TaskSupport::Required, false) => { - return Err(McpError::new( - ErrorCode::METHOD_NOT_FOUND, - "Tool requires task-based invocation", - None, - )); - } - // If taskSupport is "forbidden" (default), clients MUST NOT invoke as a task. - (TaskSupport::Forbidden, true) => { - return Err(McpError::invalid_params( - "Tool does not support task-based invocation", - None, - )); - } - _ => {} - } - } - - if is_task { - tracing::info!("Enqueueing task for tool call: {}", request.params.name); - self.enqueue_task(request.params, context.clone()) - .await - .map(ServerResult::CreateTaskResult) - } else { - self.call_tool(request.params, context) - .await - .map(ServerResult::from) + let client_declared_tasks = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + let response = self.call_tool(request.params, context).await?; + // SEP-2663: the server MUST NOT return CreateTaskResult unless + // the request declared the tasks extension capability. Guard + // against handlers that fail to check before materializing a + // task; such clients cannot parse a task handle. + if matches!(response, CallToolResponse::Task(_)) && !client_declared_tasks { + return Err(McpError::missing_required_client_capability( + ClientCapabilities::builder().enable_tasks().build(), + )); } + Ok(ServerResult::from(response)) } ClientRequest::ListToolsRequest(request) => self .list_tools(request.params, context) @@ -214,22 +221,24 @@ impl Service for H { .on_custom_request(request, context) .await .map(ServerResult::CustomResult), - ClientRequest::ListTasksRequest(request) => self - .list_tasks(request.params, context) - .await - .map(ServerResult::ListTasksResult), - ClientRequest::GetTaskRequest(request) => self - .get_task_info(request.params, context) - .await - .map(ServerResult::GetTaskResult), - ClientRequest::GetTaskPayloadRequest(request) => self - .get_task_result(request.params, context) - .await - .map(ServerResult::GetTaskPayloadResult), - ClientRequest::CancelTaskRequest(request) => self - .cancel_task(request.params, context) - .await - .map(ServerResult::CancelTaskResult), + ClientRequest::GetTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.get_task(request.params, context) + .await + .map(ServerResult::GetTaskResult) + } + ClientRequest::UpdateTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.update_task(request.params, context) + .await + .map(ServerResult::task_ack) + } + ClientRequest::CancelTaskRequest(request) => { + validate_tasks_capability::(self, &context)?; + self.cancel_task(request.params, context) + .await + .map(ServerResult::task_ack) + } }; let result = result.and_then(|result| { if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported { @@ -273,9 +282,6 @@ impl Service for H { ClientNotification::RootsListChangedNotification(_notification) => { self.on_roots_list_changed(context).await } - ClientNotification::TaskStatusNotification(notification) => { - self.on_task_status(notification.params, context).await - } ClientNotification::CustomNotification(notification) => { self.on_custom_notification(notification, context).await } @@ -290,16 +296,6 @@ impl Service for H { macro_rules! server_handler_methods { () => { - fn enqueue_task( - &self, - _request: CallToolRequestParams, - _context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::internal_error( - "Task processing not implemented".to_string(), - None, - ))) - } fn ping( &self, context: RequestContext, @@ -526,13 +522,6 @@ macro_rules! server_handler_methods { ) -> impl Future + MaybeSendFuture + '_ { std::future::ready(()) } - fn on_task_status( - &self, - params: TaskStatusNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } fn on_custom_notification( &self, notification: CustomNotification, @@ -546,15 +535,8 @@ macro_rules! server_handler_methods { ServerInfo::default() } - fn list_tasks( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err(McpError::method_not_found::())) - } - - fn get_task_info( + /// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state. + fn get_task( &self, request: GetTaskParams, context: RequestContext, @@ -563,20 +545,24 @@ macro_rules! server_handler_methods { std::future::ready(Err(McpError::method_not_found::())) } - fn get_task_result( + /// SEP-2663 `tasks/update`: accept responses to outstanding in-task + /// input requests. Returns an empty acknowledgement on success. + fn update_task( &self, - request: GetTaskPayloadParams, + request: UpdateTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) + std::future::ready(Err(McpError::method_not_found::())) } + /// SEP-2663 `tasks/cancel`: cooperative cancellation. Returns an empty + /// acknowledgement; the task's observable status may lag. fn cancel_task( &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -598,14 +584,6 @@ pub trait ServerHandler: Sized + 'static { macro_rules! impl_server_handler_for_wrapper { ($wrapper:ident) => { impl ServerHandler for $wrapper { - fn enqueue_task( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).enqueue_task(request, context) - } - fn ping( &self, context: RequestContext, @@ -777,14 +755,6 @@ macro_rules! impl_server_handler_for_wrapper { (**self).on_roots_list_changed(context) } - fn on_task_status( - &self, - params: TaskStatusNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - (**self).on_task_status(params, context) - } - fn on_custom_notification( &self, notification: CustomNotification, @@ -797,35 +767,27 @@ macro_rules! impl_server_handler_for_wrapper { (**self).get_info() } - fn list_tasks( - &self, - request: Option, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).list_tasks(request, context) - } - - fn get_task_info( + fn get_task( &self, request: GetTaskParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - (**self).get_task_info(request, context) + (**self).get_task(request, context) } - fn get_task_result( + fn update_task( &self, - request: GetTaskPayloadParams, + request: UpdateTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).get_task_result(request, context) + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).update_task(request, context) } fn cancel_task( &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { (**self).cancel_task(request, context) } } diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 215116250..31aa6d250 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -667,7 +667,6 @@ mod tests { meta: None, name: Cow::Borrowed("requires_params"), arguments: Some(Default::default()), - task: None, input_responses: None, request_state: None, }, @@ -711,7 +710,6 @@ mod tests { meta: None, name: Cow::Borrowed("test_tool"), arguments: None, - task: None, input_responses: None, request_state: None, }, diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs index 436c3df3b..bde92147d 100644 --- a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -9,7 +9,7 @@ use crate::{ tool::schema_for_output, wrapper::{Json, Parameters}, }, - model::{Icon, JsonObject, MetaObject, ToolAnnotations, ToolExecution}, + model::{Icon, JsonObject, MetaObject, ToolAnnotations}, schemars::JsonSchema, service::{MaybeSend, MaybeSendFuture}, }; @@ -71,9 +71,6 @@ pub trait ToolBase { fn annotations() -> Option { None } - fn execution() -> Option { - None - } fn icons() -> Option> { None } @@ -111,7 +108,6 @@ pub(crate) fn tool_attribute() -> crate::model::Tool { input_schema: T::input_schema().unwrap_or_else(schema_for_empty_input), output_schema: T::output_schema(), annotations: T::annotations(), - execution: T::execution(), icons: T::icons(), meta: T::meta(), } diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index cb4966df0..a90240660 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -38,7 +38,6 @@ pub struct ToolCallContext<'s, S> { pub service: &'s S, pub name: Cow<'static, str>, pub arguments: Option, - pub task: Option, } impl<'s, S> ToolCallContext<'s, S> { @@ -48,7 +47,6 @@ impl<'s, S> ToolCallContext<'s, S> { meta: _, name, arguments, - task, .. }: CallToolRequestParams, request_context: RequestContext, @@ -58,7 +56,6 @@ impl<'s, S> ToolCallContext<'s, S> { service, name, arguments, - task, } } pub fn name(&self) -> &str { @@ -120,6 +117,10 @@ impl IntoCallToolResult for Result "InputRequiredResult cannot be returned from a tool error branch", None, )), + Ok(CallToolResponse::Task(_)) => Err(crate::ErrorData::internal_error( + "CreateTaskResult cannot be returned from a tool error branch", + None, + )), Err(e) => Err(e), }, } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 09d296e9a..6531e6ee5 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -783,6 +783,8 @@ pub struct ResultType(Cow<'static, str>); impl ResultType { pub const COMPLETE: Self = Self(Cow::Borrowed("complete")); pub const INPUT_REQUIRED: Self = Self(Cow::Borrowed("input_required")); + /// SEP-2663 Tasks extension: the result is a task handle ([`CreateTaskResult`]). + pub const TASK: Self = Self(Cow::Borrowed("task")); pub fn as_str(&self) -> &str { &self.0 @@ -797,6 +799,11 @@ impl ResultType { pub fn is_complete(&self) -> bool { self.0 == "complete" } + + /// Returns `true` if this is `"task"` (SEP-2663 Tasks extension). + pub fn is_task(&self) -> bool { + self.0 == "task" + } } impl Default for ResultType { @@ -2740,9 +2747,6 @@ pub struct CreateMessageRequestParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - /// Task metadata for async task management (SEP-1319) - #[serde(skip_serializing_if = "Option::is_none")] - pub task: Option, /// The conversation history and current messages pub messages: Vec, /// Preferences for model selection and behavior @@ -2782,21 +2786,11 @@ impl RequestParamsMeta for CreateMessageRequestParams { } } -impl TaskAugmentedRequestParamsMeta for CreateMessageRequestParams { - fn task(&self) -> Option<&TaskMetadata> { - self.task.as_ref() - } - fn task_mut(&mut self) -> &mut Option { - &mut self.task - } -} - impl CreateMessageRequestParams { /// Create a new CreateMessageRequestParams with required fields. pub fn new(messages: Vec, max_tokens: u32) -> Self { Self { meta: None, - task: None, messages, model_preferences: None, system_prompt: None, @@ -3917,9 +3911,6 @@ const_string!(CallToolRequestMethod = "tools/call"); /// /// Contains the tool name and optional arguments needed to execute /// the tool operation. -/// -/// This implements `TaskAugmentedRequestParamsMeta` as tool calls can be -/// long-running and may benefit from task-based execution. #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -3933,9 +3924,6 @@ pub struct CallToolRequestParams { /// Arguments to pass to the tool (must match the tool's input schema) #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, - /// Task metadata for async task management (SEP-1319) - #[serde(skip_serializing_if = "Option::is_none")] - pub task: Option, /// Client responses to server-initiated input requests from a previous /// [`InputRequiredResult`]. Present only when retrying after an incomplete result. #[serde(skip_serializing_if = "Option::is_none")] @@ -3953,7 +3941,6 @@ impl CallToolRequestParams { meta: None, name: name.into(), arguments: None, - task: None, input_responses: None, request_state: None, } @@ -3965,12 +3952,6 @@ impl CallToolRequestParams { self } - /// Sets the task metadata for this tool call. - pub fn with_task(mut self, task: TaskMetadata) -> Self { - self.task = Some(task); - self - } - /// Sets the input responses for an MRTR retry. pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self { self.input_responses = Some(input_responses); @@ -3993,15 +3974,6 @@ impl RequestParamsMeta for CallToolRequestParams { } } -impl TaskAugmentedRequestParamsMeta for CallToolRequestParams { - fn task(&self) -> Option<&TaskMetadata> { - self.task.as_ref() - } - fn task_mut(&mut self) -> &mut Option { - &mut self.task - } -} - /// Deprecated: Use [`CallToolRequestParams`] instead (SEP-1319 compliance). #[deprecated(since = "0.13.0", note = "Use CallToolRequestParams instead")] pub type CallToolRequestParam = CallToolRequestParams; @@ -4103,25 +4075,20 @@ impl GetPromptResult { } // ============================================================================= -// TASK MANAGEMENT +// TASK MANAGEMENT (SEP-2663 Tasks extension: `io.modelcontextprotocol/tasks`) // ============================================================================= const_string!(GetTaskMethod = "tasks/get"); pub type GetTaskRequest = Request; -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskMethod")] -pub type GetTaskInfoMethod = GetTaskMethod; -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskRequest")] -pub type GetTaskInfoRequest = GetTaskRequest; - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetTaskParams { - /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, + /// Identifier of the task to query. pub task_id: String, } @@ -4143,44 +4110,37 @@ impl RequestParamsMeta for GetTaskParams { } } -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskParams")] -pub type GetTaskInfoParams = GetTaskParams; - -#[deprecated(since = "0.13.0", note = "Use GetTaskParams instead")] -pub type GetTaskInfoParam = GetTaskParams; - -const_string!(ListTasksMethod = "tasks/list"); -pub type ListTasksRequest = RequestOptionalParam; - -const_string!(GetTaskPayloadMethod = "tasks/result"); -pub type GetTaskPayloadRequest = Request; - -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadMethod")] -pub type GetTaskResultMethod = GetTaskPayloadMethod; -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadRequest")] -pub type GetTaskResultRequest = GetTaskPayloadRequest; +const_string!(UpdateTaskMethod = "tasks/update"); +pub type UpdateTaskRequest = Request; +/// Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding +/// in-task server-to-client requests surfaced via `tasks/get` `inputRequests`. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] -pub struct GetTaskPayloadParams { - /// Protocol-level metadata for this request (SEP-1319) +pub struct UpdateTaskParams { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, + /// Identifier of the task to update. pub task_id: String, + /// Responses to outstanding `inputRequests` previously surfaced by the + /// server. Each key MUST correspond to a currently-outstanding + /// `inputRequests` key. + pub input_responses: InputResponses, } -impl GetTaskPayloadParams { - pub fn new(task_id: impl Into) -> Self { +impl UpdateTaskParams { + pub fn new(task_id: impl Into, input_responses: InputResponses) -> Self { Self { meta: None, task_id: task_id.into(), + input_responses, } } } -impl RequestParamsMeta for GetTaskPayloadParams { +impl RequestParamsMeta for UpdateTaskParams { fn meta(&self) -> Option<&RequestMetaObject> { self.meta.as_ref() } @@ -4189,11 +4149,6 @@ impl RequestParamsMeta for GetTaskPayloadParams { } } -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadParams")] -pub type GetTaskResultParams = GetTaskPayloadParams; -#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadParams")] -pub type GetTaskResultParam = GetTaskPayloadParams; - const_string!(CancelTaskMethod = "tasks/cancel"); pub type CancelTaskRequest = Request; @@ -4226,31 +4181,29 @@ impl RequestParamsMeta for CancelTaskParams { } } -/// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")] -pub type CancelTaskParam = CancelTaskParams; - // --------------------------------------------------------------------------- -// Task status notification (spec `notifications/tasks/status`) +// Task status notification (SEP-2663 `notifications/tasks`) // --------------------------------------------------------------------------- -const_string!(TaskStatusNotificationMethod = "notifications/tasks/status"); +const_string!(TaskStatusNotificationMethod = "notifications/tasks"); /// Parameters for a task status notification (spec `TaskStatusNotificationParams`). /// -/// The task fields are flattened at the top level: `NotificationParams & Task`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +/// Carries a complete [`DetailedTask`] for the current status, identical to +/// what `tasks/get` would have returned at that moment. The task fields are +/// flattened at the top level: `NotificationParams & Task`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] -pub struct TaskStatusNotificationParam { +pub struct TaskStatusNotificationParams { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, #[serde(flatten)] - pub task: crate::model::Task, + pub task: crate::model::DetailedTask, } -impl TaskStatusNotificationParam { - pub fn new(task: crate::model::Task) -> Self { +impl TaskStatusNotificationParams { + pub fn new(task: crate::model::DetailedTask) -> Self { Self { meta: None, task } } @@ -4260,53 +4213,28 @@ impl TaskStatusNotificationParam { } } -impl From for TaskStatusNotificationParam { - fn from(task: crate::model::Task) -> Self { +impl From for TaskStatusNotificationParams { + fn from(task: crate::model::DetailedTask) -> Self { Self::new(task) } } -impl Deref for TaskStatusNotificationParam { - type Target = crate::model::Task; +impl Deref for TaskStatusNotificationParams { + type Target = crate::model::DetailedTask; fn deref(&self) -> &Self::Target { &self.task } } -impl DerefMut for TaskStatusNotificationParam { +impl DerefMut for TaskStatusNotificationParams { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.task } } pub type TaskStatusNotification = - Notification; -/// Deprecated: Use [`GetTaskResult`] instead (spec alignment). -#[deprecated(since = "0.15.0", note = "Use GetTaskResult instead")] -pub type GetTaskInfoResult = GetTaskResult; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ListTasksResult { - pub tasks: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -impl ListTasksResult { - pub fn new(tasks: Vec) -> Self { - Self { - tasks, - next_cursor: None, - meta: None, - } - } -} + Notification; // ============================================================================= // MESSAGE TYPE UNIONS @@ -4380,8 +4308,7 @@ ts_union!( | CallToolRequest | ListToolsRequest | GetTaskRequest - | ListTasksRequest - | GetTaskPayloadRequest + | UpdateTaskRequest | CancelTaskRequest | CustomRequest; ); @@ -4405,8 +4332,7 @@ impl ClientRequest { ClientRequest::CallToolRequest(r) => r.method.as_str(), ClientRequest::ListToolsRequest(r) => r.method.as_str(), ClientRequest::GetTaskRequest(r) => r.method.as_str(), - ClientRequest::ListTasksRequest(r) => r.method.as_str(), - ClientRequest::GetTaskPayloadRequest(r) => r.method.as_str(), + ClientRequest::UpdateTaskRequest(r) => r.method.as_str(), ClientRequest::CancelTaskRequest(r) => r.method.as_str(), ClientRequest::CustomRequest(r) => r.method.as_str(), } @@ -4419,7 +4345,6 @@ ts_union!( | ProgressNotification | InitializedNotification | RootsListChangedNotification - | TaskStatusNotification | CustomNotification; ); @@ -4477,12 +4402,13 @@ ts_union!( | ListToolsResult | ElicitResult | CreateTaskResult - | ListTasksResult | GetTaskResult - | CancelTaskResult | CallToolResult | InputRequiredResult - | GetTaskPayloadResult + // TaskAckResult must come after CallToolResult/InputRequiredResult in this + // untagged union: it only carries `resultType`, so it would otherwise + // shadow any result that includes `resultType: "complete"`. + | TaskAckResult | EmptyResult | CustomResult ; @@ -4492,6 +4418,12 @@ impl ServerResult { pub fn empty(_: ()) -> ServerResult { ServerResult::EmptyResult(EmptyResult {}) } + + /// Empty `tasks/update` / `tasks/cancel` acknowledgement carrying the + /// SEP-2322 `resultType: "complete"` discriminator (SEP-2663). + pub fn task_ack(_: ()) -> ServerResult { + ServerResult::TaskAckResult(TaskAckResult::new()) + } } pub type ServerJsonRpcMessage = JsonRpcMessage; @@ -4533,7 +4465,6 @@ mod tests { fn deprecated_aliases_still_resolve() { // 하위호환: 구 이름이 새 타입으로 여전히 resolve되는지 확인. let _: CreateElicitationResult = ElicitResult::new(ElicitationAction::Accept); - let _: GetTaskResultParams = GetTaskPayloadParams::new("task-1"); let _: ResourceReference = ResourceTemplateReference::new("res://x"); } diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index b42e40c67..f014f569f 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -72,130 +72,6 @@ pub struct RootsCapabilities { pub list_changed: Option, } -/// Task capabilities shared by client and server. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TasksCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub requests: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub list: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cancel: Option, -} - -/// Request types that support task-augmented execution. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TaskRequestsCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub sampling: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub elicitation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option, -} - -/// Sampling task capability. Deprecated by SEP-2577; remains functional and -/// will be removed in a future release. -/// See . -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct SamplingTaskCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub create_message: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ElicitationTaskCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub create: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ToolsTaskCapability { - #[serde(skip_serializing_if = "Option::is_none")] - pub call: Option, -} - -impl TasksCapability { - /// Default client tasks capability with sampling and elicitation support. - pub fn client_default() -> Self { - Self { - list: Some(JsonObject::new()), - cancel: Some(JsonObject::new()), - requests: Some(TaskRequestsCapability { - sampling: Some(SamplingTaskCapability { - create_message: Some(JsonObject::new()), - }), - elicitation: Some(ElicitationTaskCapability { - create: Some(JsonObject::new()), - }), - tools: None, - }), - } - } - - /// Default server tasks capability with tools/call support. - pub fn server_default() -> Self { - Self { - list: Some(JsonObject::new()), - cancel: Some(JsonObject::new()), - requests: Some(TaskRequestsCapability { - sampling: None, - elicitation: None, - tools: Some(ToolsTaskCapability { - call: Some(JsonObject::new()), - }), - }), - } - } - - pub fn supports_list(&self) -> bool { - self.list.is_some() - } - - pub fn supports_cancel(&self) -> bool { - self.cancel.is_some() - } - - pub fn supports_tools_call(&self) -> bool { - self.requests - .as_ref() - .and_then(|r| r.tools.as_ref()) - .and_then(|t| t.call.as_ref()) - .is_some() - } - - pub fn supports_sampling_create_message(&self) -> bool { - self.requests - .as_ref() - .and_then(|r| r.sampling.as_ref()) - .and_then(|s| s.create_message.as_ref()) - .is_some() - } - - pub fn supports_elicitation_create(&self) -> bool { - self.requests - .as_ref() - .and_then(|r| r.elicitation.as_ref()) - .and_then(|e| e.create.as_ref()) - .is_some() - } -} - /// Capability for handling elicitation requests from servers. /// Elicitation allows servers to request interactive input from users during tool execution. /// This capability indicates that a client can handle elicitation requests and present @@ -316,8 +192,16 @@ pub struct ClientCapabilities { /// Capability to handle elicitation requests from servers for interactive user input #[serde(skip_serializing_if = "Option::is_none")] pub elicitation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks: Option, +} + +impl ClientCapabilities { + /// Returns `true` if the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) is declared in [`Self::extensions`]. + pub fn supports_tasks(&self) -> bool { + self.extensions + .as_ref() + .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) + } } /// @@ -356,8 +240,16 @@ pub struct ServerCapabilities { pub resources: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks: Option, +} + +impl ServerCapabilities { + /// Returns `true` if the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) is declared in [`Self::extensions`]. + pub fn supports_tasks(&self) -> bool { + self.extensions + .as_ref() + .is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID)) + } } #[cfg(any(feature = "server", feature = "macros"))] @@ -484,20 +376,12 @@ builder! { prompts: PromptsCapability, resources: ResourcesCapability, tools: ToolsCapability, - tasks: TasksCapability } } #[cfg(any(feature = "server", feature = "macros"))] -impl< - const E: bool, - const EXT: bool, - const L: bool, - const C: bool, - const P: bool, - const R: bool, - const TASKS: bool, -> ServerCapabilitiesBuilder> +impl + ServerCapabilitiesBuilder> { pub fn enable_tool_list_changed(mut self) -> Self { if let Some(c) = self.tools.as_mut() { @@ -508,15 +392,8 @@ impl< } #[cfg(any(feature = "server", feature = "macros"))] -impl< - const E: bool, - const EXT: bool, - const L: bool, - const C: bool, - const R: bool, - const T: bool, - const TASKS: bool, -> ServerCapabilitiesBuilder> +impl + ServerCapabilitiesBuilder> { pub fn enable_prompts_list_changed(mut self) -> Self { if let Some(c) = self.prompts.as_mut() { @@ -527,15 +404,8 @@ impl< } #[cfg(any(feature = "server", feature = "macros"))] -impl< - const E: bool, - const EXT: bool, - const L: bool, - const C: bool, - const P: bool, - const T: bool, - const TASKS: bool, -> ServerCapabilitiesBuilder> +impl + ServerCapabilitiesBuilder> { pub fn enable_resources_list_changed(mut self) -> Self { if let Some(c) = self.resources.as_mut() { @@ -552,6 +422,18 @@ impl< } } +#[cfg(any(feature = "server", feature = "macros"))] +impl ServerCapabilitiesBuilder { + /// Declare support for the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) in the `extensions` capability map. + pub fn enable_tasks(mut self) -> Self { + self.extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); + self + } +} + #[cfg(any(feature = "server", feature = "macros"))] builder! { ClientCapabilities{ @@ -568,13 +450,24 @@ builder! { )] sampling: SamplingCapability, elicitation: ElicitationCapability, - tasks: TasksCapability, } } #[cfg(any(feature = "server", feature = "macros"))] -impl - ClientCapabilitiesBuilder> +impl ClientCapabilitiesBuilder { + /// Declare support for the `io.modelcontextprotocol/tasks` extension + /// (SEP-2663) in the `extensions` capability map. + pub fn enable_tasks(mut self) -> Self { + self.extensions + .get_or_insert_with(ExtensionCapabilities::new) + .insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new()); + self + } +} + +#[cfg(any(feature = "server", feature = "macros"))] +impl + ClientCapabilitiesBuilder> { #[deprecated( since = "1.8.0", @@ -589,8 +482,8 @@ impl - ClientCapabilitiesBuilder> +impl + ClientCapabilitiesBuilder> { /// Enable tool calling in sampling requests #[deprecated( @@ -618,8 +511,8 @@ impl - ClientCapabilitiesBuilder> +impl + ClientCapabilitiesBuilder> { /// Enable JSON Schema validation for elicitation responses in form mode. /// When enabled, the client will validate user input against the requested_schema @@ -679,68 +572,25 @@ mod test { } #[test] - fn test_task_capabilities_deserialization() { - // Test deserializing from the MCP spec format - let json = serde_json::json!({ - "list": {}, - "cancel": {}, - "requests": { - "tools": { "call": {} } - } - }); - - let tasks: TasksCapability = serde_json::from_value(json).unwrap(); - assert!(tasks.list.is_some()); - assert!(tasks.cancel.is_some()); - assert!(tasks.requests.is_some()); - let requests = tasks.requests.unwrap(); - assert!(requests.tools.is_some()); - assert!(requests.tools.unwrap().call.is_some()); - } - - #[test] - fn test_tasks_capability_client_default() { - let tasks = TasksCapability::client_default(); - - // Verify structure - assert!(tasks.supports_list()); - assert!(tasks.supports_cancel()); - assert!(tasks.supports_sampling_create_message()); - assert!(tasks.supports_elicitation_create()); - assert!(!tasks.supports_tools_call()); - - // Verify serialization matches expected format - let json = serde_json::to_value(&tasks).unwrap(); - assert_eq!(json["list"], serde_json::json!({})); - assert_eq!(json["cancel"], serde_json::json!({})); + fn test_tasks_extension_capability() { + // SEP-2663: tasks are declared via the extensions map. + let capabilities = ClientCapabilities::builder().enable_tasks().build(); + assert!(capabilities.supports_tasks()); + let json = serde_json::to_value(&capabilities).unwrap(); assert_eq!( - json["requests"]["sampling"]["createMessage"], + json["extensions"][crate::model::TASKS_EXTENSION_ID], serde_json::json!({}) ); + + let server = ServerCapabilities::builder().enable_tasks().build(); + assert!(server.supports_tasks()); + let json = serde_json::to_value(&server).unwrap(); assert_eq!( - json["requests"]["elicitation"]["create"], + json["extensions"][crate::model::TASKS_EXTENSION_ID], serde_json::json!({}) ); } - #[test] - fn test_tasks_capability_server_default() { - let tasks = TasksCapability::server_default(); - - // Verify structure - assert!(tasks.supports_list()); - assert!(tasks.supports_cancel()); - assert!(tasks.supports_tools_call()); - assert!(!tasks.supports_sampling_create_message()); - assert!(!tasks.supports_elicitation_create()); - - // Verify serialization matches expected format - let json = serde_json::to_value(&tasks).unwrap(); - assert_eq!(json["list"], serde_json::json!({})); - assert_eq!(json["cancel"], serde_json::json!({})); - assert_eq!(json["requests"]["tools"]["call"], serde_json::json!({})); - } - #[test] #[allow(deprecated)] fn test_client_extensions_capability() { diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index d86156aa9..0f7d90ce6 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -6,7 +6,7 @@ use serde_json::Value; use super::{ ClientCapabilities, ClientNotification, ClientRequest, CustomNotification, CustomRequest, Extensions, Implementation, JsonObject, JsonRpcMessage, LoggingLevel, ProgressToken, - ProtocolVersion, RequestId, ServerNotification, ServerRequest, TaskMetadata, + ProtocolVersion, RequestId, ServerNotification, ServerRequest, }; /// Access to the metadata carried by a message envelope's [`Extensions`]. @@ -94,21 +94,6 @@ pub trait RequestParamsMeta { } } -/// Trait for task-augmented request params that contain both `_meta` and `task` fields. -/// -/// Per the MCP 2025-11-25 spec, certain requests (like `tools/call` and `sampling/createMessage`) -/// can include a `task` field to signal that the caller wants task-augmented execution. -pub trait TaskAugmentedRequestParamsMeta: RequestParamsMeta { - /// Get a reference to the task field - fn task(&self) -> Option<&TaskMetadata>; - /// Get a mutable reference to the task field - fn task_mut(&mut self) -> &mut Option; - /// Set the task field - fn set_task(&mut self, task: TaskMetadata) { - *self.task_mut() = Some(task); - } -} - impl GetExtensions for CustomNotification { fn extensions(&self) -> &Extensions { &self.extensions @@ -204,8 +189,7 @@ variant_extension! { ListToolsRequest CustomRequest GetTaskRequest - ListTasksRequest - GetTaskPayloadRequest + UpdateTaskRequest CancelTaskRequest } } @@ -226,7 +210,6 @@ variant_extension! { ProgressNotification InitializedNotification RootsListChangedNotification - TaskStatusNotification CustomNotification } } diff --git a/crates/rmcp/src/model/mrtr.rs b/crates/rmcp/src/model/mrtr.rs index 7e08a5cc0..40621a7cd 100644 --- a/crates/rmcp/src/model/mrtr.rs +++ b/crates/rmcp/src/model/mrtr.rs @@ -42,8 +42,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use super::{ - CallToolResult, CreateMessageRequest, ElicitRequest, GetPromptResult, ListRootsRequest, - MetaObject, ReadResourceResult, ResultType, ServerResult, + CallToolResult, CreateMessageRequest, CreateTaskResult, ElicitRequest, GetPromptResult, + ListRootsRequest, MetaObject, ReadResourceResult, ResultType, ServerResult, }; /// Default maximum number of MRTR rounds a high-level client call will drive. @@ -72,6 +72,18 @@ pub enum InputRequest { ListRoots(ListRootsRequest), } +// Wire-level equality: two `InputRequest`s are equal if they serialize to the +// same JSON. The wrapped request envelopes do not implement `PartialEq` +// structurally (they carry `Extensions`). +impl PartialEq for InputRequest { + fn eq(&self, other: &Self) -> bool { + match (serde_json::to_value(self), serde_json::to_value(other)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } +} + /// A map of server-initiated requests that the client must fulfill. /// /// Keys are server-assigned string identifiers; values are request objects @@ -95,6 +107,9 @@ pub enum CallToolResponse { Complete(CallToolResult), /// The server requires client-side input before the tool call can complete. InputRequired(InputRequiredResult), + /// The server materialized a task for this call (SEP-2663 Tasks extension, + /// `resultType: "task"`). The client polls `tasks/get` for the result. + Task(CreateTaskResult), } impl From for CallToolResponse { @@ -114,10 +129,17 @@ impl From for ServerResult { match response { CallToolResponse::Complete(result) => ServerResult::CallToolResult(result), CallToolResponse::InputRequired(result) => ServerResult::InputRequiredResult(result), + CallToolResponse::Task(result) => ServerResult::CreateTaskResult(result), } } } +impl From for CallToolResponse { + fn from(result: CreateTaskResult) -> Self { + Self::Task(result) + } +} + /// Result of a `prompts/get` request, including the MRTR intermediate result. #[derive(Debug, Clone)] #[non_exhaustive] diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index 6a7197ae5..b974722af 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -497,7 +497,6 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -537,7 +536,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -560,7 +558,6 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -580,7 +577,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -612,7 +608,6 @@ mod test { meta: Some(params_meta), name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -638,7 +633,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])), - task: None, input_responses: None, request_state: None, }, @@ -798,7 +792,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, @@ -826,7 +819,6 @@ mod test { meta: None, name: "my_tool".into(), arguments: None, - task: None, input_responses: None, request_state: None, }, diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index e57fdd872..bbdd1c99d 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -1,114 +1,90 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::MetaObject; - -/// Metadata for augmenting a request with task execution (spec `TaskMetadata`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct TaskMetadata { - #[serde(skip_serializing_if = "Option::is_none")] - pub ttl: Option, -} +//! Task types for the MCP Tasks extension (SEP-2663). +//! +//! Tasks are defined by the official `io.modelcontextprotocol/tasks` extension. +//! A server may respond to a supported request (currently `tools/call`) with a +//! [`CreateTaskResult`] (`resultType: "task"`) instead of the standard result. +//! The client then polls `tasks/get`, answers in-task server-to-client requests +//! via `tasks/update`, and may signal cancellation via `tasks/cancel`. -impl TaskMetadata { - pub fn new() -> Self { - Self::default() - } - - pub fn with_ttl(mut self, ttl: u64) -> Self { - self.ttl = Some(ttl); - self - } -} - -/// Metadata for associating messages with a task (spec `RelatedTaskMetadata`). -/// -/// Carried in `_meta` under the key `"io.modelcontextprotocol/related-task"`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct RelatedTaskMetadata { - pub task_id: String, -} +use serde::{Deserialize, Serialize}; -impl RelatedTaskMetadata { - pub fn new(task_id: impl Into) -> Self { - Self { - task_id: task_id.into(), - } - } +use super::{InputRequests, JsonObject, MetaObject, ResultType}; - /// The well-known `_meta` key for related-task metadata. - pub const META_KEY: &str = "io.modelcontextprotocol/related-task"; -} +/// Extension identifier for the MCP Tasks extension (SEP-2663). +pub const TASKS_EXTENSION_ID: &str = "io.modelcontextprotocol/tasks"; -/// Canonical task lifecycle status as defined by SEP-1686. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +/// Canonical task lifecycle status (SEP-2663). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub enum TaskStatus { - /// The receiver accepted the request and is currently working on it. + /// The request is currently being processed. #[default] Working, - /// The receiver requires additional input before work can continue. + /// The server needs input from the client before the task can proceed. InputRequired, - /// The underlying operation completed successfully and the result is ready. + /// The request completed successfully and the result is available. + /// This includes tool calls that returned results with `isError: true`. Completed, - /// The underlying operation failed and will not continue. + /// The request failed due to a JSON-RPC error during execution. Failed, - /// The task was cancelled and will not continue processing. + /// The request was cancelled before completion. Cancelled, } -/// Primary Task object that surfaces metadata during the task lifecycle. -/// -/// Per spec, `lastUpdatedAt` and `ttl` are required fields. -/// `ttl` is nullable (`null` means unlimited retention). +impl TaskStatus { + /// Returns `true` for terminal statuses (`completed`, `failed`, `cancelled`). + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +/// Operational metadata about ongoing work (spec `Task`, SEP-2663). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct Task { - /// Unique task identifier generated by the receiver. + /// Stable identifier for this task, generated by the server. pub task_id: String, - /// Current lifecycle status (see [`TaskStatus`]). + /// Current task status. pub status: TaskStatus, - /// Optional human-readable status message for UI surfaces. + /// Optional message describing the current task state. + /// This MAY be exposed to the end-user or model. #[serde(skip_serializing_if = "Option::is_none")] pub status_message: Option, - /// ISO-8601 creation timestamp. + /// ISO 8601 timestamp when the task was created. pub created_at: String, - /// ISO-8601 timestamp for the most recent status change. + /// ISO 8601 timestamp when the task was last updated. pub last_updated_at: String, - /// Retention window in milliseconds that the receiver agreed to honor. - /// `None` (serialized as `null`) means unlimited retention. - pub ttl: Option, - /// Suggested polling interval (milliseconds). + /// Time-to-live duration from creation in integer milliseconds; `None` + /// (serialized as `null`) means unlimited. The server may discard the task + /// after the TTL elapses. This value MAY change over the lifetime of a task. + pub ttl_ms: Option, + /// Suggested polling interval in integer milliseconds. Clients SHOULD honor + /// this value to avoid overwhelming the server. This value MAY change over + /// the lifetime of a task. #[serde(skip_serializing_if = "Option::is_none")] - pub poll_interval: Option, + pub poll_interval_ms: Option, } impl Task { - /// Create a new Task with required fields. + /// Create a new task with required fields. pub fn new( - task_id: String, + task_id: impl Into, status: TaskStatus, - created_at: String, - last_updated_at: String, + created_at: impl Into, + last_updated_at: impl Into, ) -> Self { Self { - task_id, + task_id: task_id.into(), status, status_message: None, - created_at, - last_updated_at, - ttl: None, - poll_interval: None, + created_at: created_at.into(), + last_updated_at: last_updated_at.into(), + ttl_ms: None, + poll_interval_ms: None, } } @@ -119,33 +95,241 @@ impl Task { } /// Set the TTL in milliseconds. `None` means unlimited retention. - pub fn with_ttl(mut self, ttl: u64) -> Self { - self.ttl = Some(ttl); + pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { + self.ttl_ms = Some(ttl_ms); self } - /// Set the poll interval in milliseconds. - pub fn with_poll_interval(mut self, poll_interval: u64) -> Self { - self.poll_interval = Some(poll_interval); + /// Set the suggested poll interval in milliseconds. + pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self { + self.poll_interval_ms = Some(poll_interval_ms); self } } -/// Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// Status-specific payload carried alongside the base [`Task`] fields in a +/// [`DetailedTask`]. +/// +/// Mirrors the spec's `WorkingTask` / `InputRequiredTask` / `CompletedTask` / +/// `FailedTask` / `CancelledTask` union: the variant is discriminated by the +/// `status` field on the wire, with the payload fields inlined at the top level. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum TaskPayload { + /// `status: "working"` — no additional payload. + Working, + /// `status: "input_required"` — outstanding server-to-client requests. + InputRequired { + /// Server-to-client requests that need to be fulfilled during task + /// execution. Keys are arbitrary identifiers for matching requests + /// to responses, unique over the lifetime of the task. + input_requests: InputRequests, + }, + /// `status: "completed"` — the final result of the task. The structure + /// matches the result type of the original request (e.g. `CallToolResult`). + Completed { + /// The final result of the original request. + result: JsonObject, + }, + /// `status: "failed"` — the JSON-RPC error that caused the task to fail. + Failed { + /// The JSON-RPC error object. + error: JsonObject, + }, + /// `status: "cancelled"` — no additional payload. + Cancelled, +} + +impl TaskPayload { + /// The [`TaskStatus`] this payload corresponds to. + pub fn status(&self) -> TaskStatus { + match self { + Self::Working => TaskStatus::Working, + Self::InputRequired { .. } => TaskStatus::InputRequired, + Self::Completed { .. } => TaskStatus::Completed, + Self::Failed { .. } => TaskStatus::Failed, + Self::Cancelled => TaskStatus::Cancelled, + } + } +} + +/// A task with its status-specific payload inlined (spec `DetailedTask`). +/// +/// Used by `tasks/get` responses ([`GetTaskResult`]) and `notifications/tasks` +/// ([`TaskStatusNotificationParams`](crate::model::TaskStatusNotificationParams)). +/// On the wire, the payload fields (`inputRequests` / `result` / `error`) are +/// flattened at the top level next to the base [`Task`] fields, and `status` +/// discriminates the variant. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct DetailedTask { + /// Base task metadata. Its `status` always agrees with the payload. + pub task: Task, + /// Status-specific payload. + pub payload: TaskPayload, +} + +impl DetailedTask { + /// Build a `DetailedTask`, forcing `task.status` to match the payload. + pub fn new(mut task: Task, payload: TaskPayload) -> Self { + task.status = payload.status(); + Self { task, payload } + } + + /// The current status. + pub fn status(&self) -> TaskStatus { + self.task.status + } +} + +// Wire shape helper: base Task fields + optional payload fields, all flattened. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +struct DetailedTaskWire { + #[serde(flatten)] + task: Task, + #[serde(skip_serializing_if = "Option::is_none")] + input_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl From for DetailedTaskWire { + fn from(value: DetailedTask) -> Self { + let DetailedTask { task, payload } = value; + let (input_requests, result, error) = match payload { + TaskPayload::Working | TaskPayload::Cancelled => (None, None, None), + TaskPayload::InputRequired { input_requests } => (Some(input_requests), None, None), + TaskPayload::Completed { result } => (None, Some(result), None), + TaskPayload::Failed { error } => (None, None, Some(error)), + }; + Self { + task, + input_requests, + result, + error, + } + } +} + +impl TryFrom for DetailedTask { + type Error = String; + fn try_from(wire: DetailedTaskWire) -> Result { + let payload = match wire.task.status { + TaskStatus::Working => TaskPayload::Working, + TaskStatus::Cancelled => TaskPayload::Cancelled, + TaskStatus::InputRequired => TaskPayload::InputRequired { + input_requests: wire.input_requests.ok_or_else(|| { + "task with status \"input_required\" is missing `inputRequests`".to_owned() + })?, + }, + TaskStatus::Completed => TaskPayload::Completed { + result: wire.result.ok_or_else(|| { + "task with status \"completed\" is missing `result`".to_owned() + })?, + }, + TaskStatus::Failed => TaskPayload::Failed { + error: wire + .error + .ok_or_else(|| "task with status \"failed\" is missing `error`".to_owned())?, + }, + }; + Ok(DetailedTask { + task: wire.task, + payload, + }) + } +} + +impl Serialize for DetailedTask { + fn serialize(&self, serializer: S) -> Result { + DetailedTaskWire::from(self.clone()).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DetailedTask { + fn deserialize>(deserializer: D) -> Result { + let wire = DetailedTaskWire::deserialize(deserializer)?; + Self::try_from(wire).map_err(serde::de::Error::custom) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for DetailedTask { + fn schema_name() -> std::borrow::Cow<'static, str> { + "DetailedTask".into() + } + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + // The actual wire shape: base Task fields plus the optional + // status-specific payload fields (inputRequests / result / error). + ::json_schema(generator) + } +} + +/// Result returned in lieu of a standard result to indicate the request will +/// be processed asynchronously (spec `CreateTaskResult`, `resultType: "task"`). +/// +/// The embedded task is the seed state for the task; the client uses +/// `task.task_id` for all subsequent `tasks/get`, `tasks/update`, and +/// `tasks/cancel` calls. +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CreateTaskResult { + /// Always `"task"`. + pub result_type: ResultType, + /// Seed state of the newly created task, flattened at the top level. + #[serde(flatten)] pub task: Task, - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, } +// Custom deserializer that requires `resultType: "task"`. Without this, +// `CreateTaskResult` would greedily match other task-shaped results (e.g. +// `tasks/get` responses, which also carry `taskId`/`status` at the top level +// but use `resultType: "complete"`) inside `#[serde(untagged)]` unions such +// as `ServerResult`. +impl<'de> Deserialize<'de> for CreateTaskResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + result_type: ResultType, + #[serde(flatten)] + task: Task, + #[serde(rename = "_meta", default)] + meta: Option, + } + let helper = Helper::deserialize(deserializer)?; + if !helper.result_type.is_task() { + return Err(serde::de::Error::custom( + "CreateTaskResult requires resultType to be \"task\"", + )); + } + Ok(CreateTaskResult { + result_type: helper.result_type, + task: helper.task, + meta: helper.meta, + }) + } +} + impl CreateTaskResult { - /// Create a new CreateTaskResult. + /// Create a new `CreateTaskResult` from the seed task state. pub fn new(task: Task) -> Self { - Self { task, meta: None } + Self { + result_type: ResultType::TASK, + task, + meta: None, + } } /// Sets the protocol-level metadata for this result. @@ -155,80 +339,206 @@ impl CreateTaskResult { } } -/// Response to a `tasks/get` request. +/// Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`). /// -/// Per spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are -/// flattened at the top level, not nested under a `task` key. +/// `resultType` is `"complete"` — this is the standard result shape for +/// `tasks/get`, not a task handle. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetTaskResult { + /// Result type discriminator. `tasks/get` responses are standard results: + /// `"complete"` (SEP-2322). Absent values deserialize as `"complete"`. + #[serde(default)] + pub result_type: ResultType, #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, + /// The task with status-specific payload inlined. #[serde(flatten)] - pub task: Task, + pub task: DetailedTask, } impl GetTaskResult { - pub fn new(task: Task) -> Self { - Self { meta: None, task } + pub fn new(task: DetailedTask) -> Self { + Self { + result_type: ResultType::COMPLETE, + meta: None, + task, + } } } -/// Response to a `tasks/result` request. +/// Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663). /// -/// Per spec, the result structure matches the original request type -/// (e.g., `CallToolResult` for `tools/call`). This is represented as -/// an open object. The payload is the original request's result -/// serialized as a JSON value. +/// The spec requires these acks to be empty results carrying the SEP-2322 +/// `resultType: "complete"` discriminator; task state changes are observed +/// via the next `tasks/get`. #[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] -pub struct GetTaskPayloadResult(pub Value); - -impl GetTaskPayloadResult { - /// Create a new GetTaskPayloadResult with the given value. - pub fn new(value: Value) -> Self { - Self(value) - } +pub struct TaskAckResult { + /// Always `"complete"`. + pub result_type: ResultType, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, } -// Custom Deserialize that always fails, so that `GetTaskPayloadResult` is skipped -// during `#[serde(untagged)]` enum deserialization (e.g. `ServerResult`). -// The payload has the same JSON shape as `CustomResult(Value)`, so they are -// indistinguishable. `CustomResult` acts as the catch-all instead. -// `GetTaskPayloadResult` should be constructed programmatically via `::new()`. -impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult { +// Custom deserializer that requires `resultType: "complete"` and rejects any +// other fields. Without this, `TaskAckResult` would greedily match arbitrary +// result objects carrying a `resultType` key inside `#[serde(untagged)]` +// unions such as `ServerResult`, shadowing `CustomResult` and losing data. +impl<'de> Deserialize<'de> for TaskAckResult { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - // Consume the value so the deserializer state stays consistent. - serde::de::IgnoredAny::deserialize(deserializer)?; - Err(serde::de::Error::custom( - "GetTaskPayloadResult cannot be deserialized directly; \ - use CustomResult as the catch-all", - )) + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Helper { + result_type: ResultType, + #[serde(rename = "_meta", default)] + meta: Option, + } + let helper = Helper::deserialize(deserializer)?; + if !helper.result_type.is_complete() { + return Err(serde::de::Error::custom( + "TaskAckResult requires resultType to be \"complete\"", + )); + } + Ok(TaskAckResult { + result_type: helper.result_type, + meta: helper.meta, + }) } } -/// Response to a `tasks/cancel` request. -/// -/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct CancelTaskResult { - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - pub meta: Option, - #[serde(flatten)] - pub task: Task, +impl Default for TaskAckResult { + fn default() -> Self { + Self { + result_type: ResultType::COMPLETE, + meta: None, + } + } } -impl CancelTaskResult { - pub fn new(task: Task) -> Self { - Self { meta: None, task } +impl TaskAckResult { + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn base_task(status: TaskStatus) -> Task { + Task::new( + "task-1", + status, + "2025-11-25T10:30:00Z", + "2025-11-25T10:40:00Z", + ) + .with_ttl_ms(60000) + .with_poll_interval_ms(5000) + } + + #[test] + fn create_task_result_wire_shape() { + let result = CreateTaskResult::new(base_task(TaskStatus::Working)); + let value = serde_json::to_value(&result).unwrap(); + assert_eq!( + value, + json!({ + "resultType": "task", + "taskId": "task-1", + "status": "working", + "createdAt": "2025-11-25T10:30:00Z", + "lastUpdatedAt": "2025-11-25T10:40:00Z", + "ttlMs": 60000, + "pollIntervalMs": 5000 + }) + ); + let roundtrip: CreateTaskResult = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, result); + } + + #[test] + fn ttl_ms_null_means_unlimited() { + let mut task = base_task(TaskStatus::Working); + task.ttl_ms = None; + let value = serde_json::to_value(&task).unwrap(); + assert_eq!(value["ttlMs"], serde_json::Value::Null); + let roundtrip: Task = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip.ttl_ms, None); + } + + #[test] + fn detailed_task_completed_roundtrip() { + let detailed = DetailedTask::new( + base_task(TaskStatus::Working), + TaskPayload::Completed { + result: serde_json::from_value(json!({ + "content": [{"type": "text", "text": "ok"}], + "isError": false + })) + .unwrap(), + }, + ); + // Status is forced to match the payload. + assert_eq!(detailed.status(), TaskStatus::Completed); + let value = serde_json::to_value(&detailed).unwrap(); + assert_eq!(value["status"], "completed"); + assert_eq!(value["result"]["isError"], false); + let roundtrip: DetailedTask = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, detailed); + } + + #[test] + fn detailed_task_input_required_requires_input_requests() { + let err = serde_json::from_value::(json!({ + "taskId": "task-1", + "status": "input_required", + "createdAt": "2025-11-25T10:30:00Z", + "lastUpdatedAt": "2025-11-25T10:40:00Z", + "ttlMs": null + })) + .unwrap_err(); + assert!(err.to_string().contains("inputRequests")); + } + + #[test] + fn detailed_task_failed_roundtrip() { + let detailed = DetailedTask::new( + base_task(TaskStatus::Failed), + TaskPayload::Failed { + error: serde_json::from_value(json!({ + "code": -32603, + "message": "boom" + })) + .unwrap(), + }, + ); + let value = serde_json::to_value(&detailed).unwrap(); + assert_eq!(value["status"], "failed"); + assert_eq!(value["error"]["code"], -32603); + let roundtrip: DetailedTask = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, detailed); + } + + #[test] + fn get_task_result_flattens_detailed_task() { + let result = GetTaskResult::new(DetailedTask::new( + base_task(TaskStatus::Working), + TaskPayload::Working, + )); + let value = serde_json::to_value(&result).unwrap(); + assert_eq!(value["taskId"], "task-1"); + assert_eq!(value["status"], "working"); + let roundtrip: GetTaskResult = serde_json::from_value(value).unwrap(); + assert_eq!(roundtrip, result); } } diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index ec2ad741a..be7cbbd0a 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -31,9 +31,6 @@ pub struct Tool { #[serde(skip_serializing_if = "Option::is_none")] /// Optional additional tool information. pub annotations: Option, - /// Execution-related configuration including task support mode. - #[serde(skip_serializing_if = "Option::is_none")] - pub execution: Option, /// Optional list of icons for the tool #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -42,62 +39,6 @@ pub struct Tool { pub meta: Option, } -/// Per-tool task support mode as defined in the MCP specification. -/// -/// This enum indicates whether a tool supports task-based invocation, -/// allowing clients to know how to properly call the tool. -/// -/// See [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] -pub enum TaskSupport { - /// Clients MUST NOT invoke this tool as a task (default behavior). - #[default] - Forbidden, - /// Clients MAY invoke this tool as either a task or a normal call. - Optional, - /// Clients MUST invoke this tool as a task. - Required, -} - -/// Execution-related configuration for a tool. -/// -/// This struct contains settings that control how a tool should be executed, -/// including task support configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[non_exhaustive] -pub struct ToolExecution { - /// Indicates whether this tool supports task-based invocation. - /// - /// When not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task. - /// When set to `Optional`, clients MAY invoke this tool as a task or normal call. - /// When set to `Required`, clients MUST invoke this tool as a task. - #[serde(skip_serializing_if = "Option::is_none")] - pub task_support: Option, -} - -impl ToolExecution { - /// Create a new empty ToolExecution configuration. - pub fn new() -> Self { - Self::default() - } - - /// Create a ToolExecution from raw optional fields. - pub fn from_raw(task_support: Option) -> Self { - Self { task_support } - } - - /// Set the task support mode. - pub fn with_task_support(mut self, task_support: TaskSupport) -> Self { - self.task_support = Some(task_support); - self - } -} - /// Additional properties describing a Tool to clients. /// /// NOTE: all properties in ToolAnnotations are **hints**. @@ -232,7 +173,6 @@ impl Tool { input_schema: input_schema.into(), output_schema: None, annotations: None, - execution: None, icons: None, meta: None, } @@ -255,7 +195,6 @@ impl Tool { input_schema: input_schema.into(), output_schema: None, annotations: None, - execution: None, icons: None, meta: None, } @@ -298,22 +237,6 @@ impl Tool { } } - /// Set the execution configuration for this tool. - pub fn with_execution(mut self, execution: ToolExecution) -> Self { - self.execution = Some(execution); - self - } - - /// Returns the task support mode for this tool. - /// - /// Returns `TaskSupport::Forbidden` if not explicitly set. - pub fn task_support(&self) -> TaskSupport { - self.execution - .as_ref() - .and_then(|e| e.task_support) - .unwrap_or_default() - } - /// Set the output schema using a type that implements JsonSchema #[cfg(feature = "server")] pub fn with_output_schema(mut self) -> Self { diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 093a537b1..88336ed5a 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -8,22 +8,23 @@ use super::*; use crate::{ model::{ ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResponse, CallToolResult, - CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, - ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, - CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, - DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, - GetPromptRequest, GetPromptRequestParams, GetPromptResponse, GetPromptResult, - InitializeRequest, InitializedNotification, InputRequest, InputRequiredResult, - InputResponses, JsonRpcResponse, ListPromptsRequest, ListPromptsResult, - ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest, - ListResourcesResult, ListToolsRequest, ListToolsResult, NumberOrString, - PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, ProtocolVersion, - ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, - Reference, RequestId, RequestMetaObject, RootsListChangedNotification, ServerInfo, - ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, SetLevelRequest, - SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, SubscriptionFilter, - SubscriptionsListenRequest, SubscriptionsListenRequestParams, SubscriptionsListenResult, - UnsubscribeRequest, UnsubscribeRequestParams, + CancelTaskParams, CancelTaskRequest, CancelledNotification, CancelledNotificationParam, + ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, + CompleteRequest, CompleteRequestParams, CompleteResult, CompletionContext, CompletionInfo, + DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, + GetExtensions, GetMeta, GetPromptRequest, GetPromptRequestParams, GetPromptResponse, + GetPromptResult, GetTaskParams, GetTaskRequest, GetTaskResult, InitializeRequest, + InitializedNotification, InputRequest, InputRequiredResult, InputResponses, + JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, + ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest, + ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotification, + ProgressNotificationParam, ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, + ReadResourceResponse, ReadResourceResult, Reference, RequestId, RequestMetaObject, + RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification, + ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SubscribeRequest, + SubscribeRequestParams, SubscriptionFilter, SubscriptionsListenRequest, + SubscriptionsListenRequestParams, SubscriptionsListenResult, UnsubscribeRequest, + UnsubscribeRequestParams, UpdateTaskParams, UpdateTaskRequest, }, transport::DynamicTransportError, }; @@ -1044,6 +1045,47 @@ impl Peer { ServerResult::InputRequiredResult(result) => { Ok(CallToolResponse::InputRequired(result)) } + // SEP-2663 Tasks extension: the server materialized a task. + ServerResult::CreateTaskResult(result) => Ok(CallToolResponse::Task(result)), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/get`: poll the current state of a task. + pub async fn get_task(&self, params: GetTaskParams) -> Result { + let result = self + .send_request(ClientRequest::GetTaskRequest(GetTaskRequest::new(params))) + .await?; + match result { + ServerResult::GetTaskResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/update`: deliver responses to outstanding in-task + /// input requests. The acknowledgement is eventually consistent. + pub async fn update_task(&self, params: UpdateTaskParams) -> Result<(), ServiceError> { + let result = self + .send_request(ClientRequest::UpdateTaskRequest(UpdateTaskRequest::new( + params, + ))) + .await?; + match result { + ServerResult::TaskAckResult(_) | ServerResult::EmptyResult(_) => Ok(()), + _ => Err(ServiceError::UnexpectedResponse), + } + } + + /// SEP-2663 `tasks/cancel`: signal intent to cancel a task. Cancellation + /// is cooperative; the ack does not guarantee the task stops. + pub async fn cancel_task(&self, params: CancelTaskParams) -> Result<(), ServiceError> { + let result = self + .send_request(ClientRequest::CancelTaskRequest(CancelTaskRequest::new( + params, + ))) + .await?; + match result { + ServerResult::TaskAckResult(_) | ServerResult::EmptyResult(_) => Ok(()), _ => Err(ServiceError::UnexpectedResponse), } } @@ -1209,7 +1251,7 @@ impl Peer { /// /// # Arguments /// * `prompt_name` - Name of the prompt being completed - /// * `argument_name` - Name of the argument being completed + /// * `argument_name` - Name of the argument being completed /// * `current_value` - Current partial value of the argument /// * `context` - Optional context with previously resolved arguments /// @@ -1368,6 +1410,10 @@ where params.input_responses = input_responses; params.request_state = request_state; } + // SEP-2663: this helper does not drive the task polling + // lifecycle. Callers that declare the tasks extension + // capability should use `call_tool_once` and poll `tasks/get`. + CallToolResponse::Task(_) => return Err(ServiceError::UnexpectedResponse), } } Err(ServiceError::InputRequiredRoundsExceeded { max_rounds }) diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index b7aa6b968..f45299082 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -224,9 +224,14 @@ impl SubscriptionSink { "notifications/message", )); } + // SEP-2663 task status notifications are not yet routable through + // `subscriptions/listen`: `SubscriptionFilter` has no `taskIds` + // field yet (the upstream conformance check for this flow is also + // still skipped, pending the subscriptions/listen rewrite). + // Clients currently observe task state by polling `tasks/get`. ServerNotification::TaskStatusNotification(_) => { return Err(SubscriptionSendError::UnsupportedNotification( - "notifications/tasks/status", + "notifications/tasks", )); } ServerNotification::CustomNotification(_) => { diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 21adb38b3..df1c389a3 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -1,307 +1,920 @@ -use std::{any::Any, collections::HashMap, pin::Pin}; +//! Server-side runtime for the MCP Tasks extension (SEP-2663, +//! `io.modelcontextprotocol/tasks`). +//! +//! [`TaskManager`] owns the durable state for tasks a server has materialized +//! in response to task-eligible requests (currently `tools/call`). It: +//! +//! - spawns the underlying operation and tracks its lifecycle as a +//! [`DetailedTask`] (`working` → terminal, optionally via `input_required`), +//! - answers `tasks/get` with the current state (including in-flight +//! `inputRequests` and terminal `result`/`error` payloads), +//! - accepts `tasks/update` `inputResponses` and routes them to the running +//! operation (ignoring unknown or already-answered keys per spec), +//! - handles cooperative `tasks/cancel`, +//! - enforces TTL-based expiry (`ttl_ms`), marking overdue tasks `failed`. +//! +//! Tasks are only durably observable once [`TaskManager::spawn`] returns, +//! satisfying the spec requirement that a server not return `CreateTaskResult` +//! before `tasks/get` for that id would resolve. + +use std::{ + collections::HashMap, + pin::Pin, + sync::{Arc, Mutex}, + time::Instant, +}; use futures::Future; -use tokio::{ - sync::mpsc, - time::{Duration, timeout}, -}; +use tokio::sync::oneshot; use crate::{ - RoleServer, - error::{ErrorData as McpError, RmcpError as Error}, - model::{CallToolResult, ClientRequest}, - service::RequestContext, + error::ErrorData as McpError, + model::{ + CallToolResult, DetailedTask, InputRequest, InputRequests, JsonObject, Task, TaskPayload, + TaskStatus, + }, }; -/// Boxed future that represents an asynchronous operation managed by the processor. -pub type OperationFuture = - Pin, Error>> + Send>>; +/// Default TTL (5 minutes, in milliseconds) applied when none is specified. +pub const DEFAULT_TASK_TTL_MS: u64 = 300_000; -/// Describes metadata associated with an enqueued task. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct OperationDescriptor { - pub operation_id: String, - pub name: String, - pub client_request: Option, - pub context: Option>, - pub ttl: Option, +/// Default suggested polling interval, in milliseconds. +pub const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000; + +/// Helper to generate an ISO 8601 timestamp for task metadata. +pub fn current_timestamp() -> String { + chrono::Utc::now().to_rfc3339() } -impl OperationDescriptor { - pub fn new(operation_id: impl Into, name: impl Into) -> Self { - Self { - operation_id: operation_id.into(), - name: name.into(), - client_request: None, - context: None, - ttl: None, +/// Handle passed to a running task operation, allowing it to surface +/// server-to-client requests (elicitation, sampling, roots) mid-task and +/// await the client's `tasks/update` response. +#[derive(Clone)] +pub struct TaskContext { + task_id: String, + inner: Arc>, +} + +impl TaskContext { + /// The id of the task this context belongs to. + pub fn task_id(&self) -> &str { + &self.task_id + } + + /// Surface a server-to-client request under `key` and wait for the + /// client's response delivered via `tasks/update`. + /// + /// While at least one request is outstanding the task reports + /// `input_required` from `tasks/get`, with all outstanding requests in + /// `inputRequests`. Keys must be unique over the lifetime of the task; + /// reusing a key returns an error. + pub async fn request_input( + &self, + key: impl Into, + request: InputRequest, + ) -> Result { + let key = key.into(); + let (tx, rx) = oneshot::channel(); + { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + let entry = inner.tasks.get_mut(&self.task_id).ok_or_else(|| { + TaskExit::Error(McpError::internal_error( + "task no longer exists".to_string(), + None, + )) + })?; + if !entry.used_input_keys.insert(key.clone()) { + return Err(TaskExit::Error(McpError::internal_error( + format!("inputRequests key {key:?} was already used for this task"), + None, + ))); + } + entry.pending_inputs.insert(key.clone(), (request, tx)); + entry.touch(); } + // The sender is dropped when `tasks/cancel` clears pending inputs. + rx.await.map_err(|_| TaskExit::Cancelled) } - pub fn with_client_request(mut self, request: ClientRequest) -> Self { - self.client_request = Some(request); - self + /// Update the task's human-readable status message. + pub fn set_status_message(&self, message: impl Into) { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + if let Some(entry) = inner.tasks.get_mut(&self.task_id) { + entry.task.status_message = Some(message.into()); + entry.touch(); + } } - pub fn with_context(mut self, context: RequestContext) -> Self { - self.context = Some(context); - self + /// Returns `true` if `tasks/cancel` has been received for this task. + /// Cooperative: operations should check this and stop when set. + pub fn is_cancel_requested(&self) -> bool { + let inner = self.inner.lock().expect("task manager lock poisoned"); + inner + .tasks + .get(&self.task_id) + .is_some_and(|e| e.cancel_requested) } - /// Time-to-live in milliseconds, matching `TaskMetadata.ttl` from the MCP spec. - pub fn with_ttl(mut self, ttl: u64) -> Self { - self.ttl = Some(ttl); - self + /// Resolves once `tasks/cancel` has been received for this task (or + /// immediately, if it already has). Cooperative: pair with + /// `tokio::select!` around long-running work to implement a cancellation + /// exit path. + /// + /// An operation that stops in response should return + /// [`TaskExit::Cancelled`] so the task settles as `cancelled`. Returning + /// [`TaskExit::Error`] settles as `failed`, and finishing the work + /// anyway settles as `completed` — per SEP-2663 cancellation is + /// cooperative and a task may reach a non-`cancelled` terminal status. + pub async fn cancelled(&self) { + let mut rx = { + let inner = self.inner.lock().expect("task manager lock poisoned"); + let Some(entry) = inner.tasks.get(&self.task_id) else { + return; + }; + if entry.cancel_requested { + return; + } + entry.cancel_signal.subscribe() + }; + // Wait until the watch flips to true; a closed channel means the + // manager dropped the entry, which also unblocks the operation. + while !*rx.borrow_and_update() { + if rx.changed().await.is_err() { + return; + } + } } } -/// Operation message describing a unit of asynchronous work. -#[non_exhaustive] -pub struct OperationMessage { - pub descriptor: OperationDescriptor, - pub future: OperationFuture, +/// How a task operation finished without producing a result. +#[expect( + clippy::exhaustive_enums, + reason = "error variant for task exit may only be due to error or cancellation" +)] +#[derive(Debug)] +pub enum TaskExit { + /// The operation is exiting in response to a cancellation request; + /// the task settles as terminal `cancelled`. + Cancelled, + /// A real failure; the task settles as terminal `failed` with the + /// error inlined, even after `tasks/cancel` was received. + Error(McpError), } -impl OperationMessage { - pub fn new(descriptor: OperationDescriptor, future: OperationFuture) -> Self { - Self { descriptor, future } +impl From for TaskExit { + fn from(error: McpError) -> Self { + TaskExit::Error(error) } } -/// Trait for operation result transport -pub trait OperationResultTransport: Send + Sync + 'static { - fn operation_id(&self) -> &String; - fn as_any(&self) -> &dyn std::any::Any; +/// Boxed future representing the async operation backing a task. +pub type TaskFuture = Pin> + Send>>; + +struct TaskEntry { + task: Task, + /// Terminal payload, if the task has finished. + terminal: Option, + /// When the task reached its terminal state; drives retention eviction. + terminal_at: Option, + /// Outstanding input requests keyed by their unique identifier. + pending_inputs: HashMap)>, + /// Every key ever used, to enforce uniqueness across the task lifetime. + used_input_keys: std::collections::HashSet, + cancel_requested: bool, + /// Signals the running operation that cancellation was requested + /// (`true` once `tasks/cancel` arrives). Cooperative: the operation + /// decides whether and how to stop. + cancel_signal: tokio::sync::watch::Sender, + created: Instant, + join_handle: Option>, } -// ===== Operation Processor ===== -#[deprecated(note = "use DEFAULT_TASK_TIMEOUT_MS; ttl values are milliseconds per the MCP spec")] -pub const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; -/// Default execution timeout (5 minutes), in milliseconds, applied when a -/// descriptor does not specify a `ttl`. -pub const DEFAULT_TASK_TIMEOUT_MS: u64 = 300_000; -/// Operation processor that coordinates extractors and handlers -pub struct OperationProcessor { - /// Currently running tasks keyed by id - running_tasks: HashMap, - /// Completed results waiting to be collected - completed_results: Vec, - task_result_receiver: mpsc::UnboundedReceiver, - task_result_sender: mpsc::UnboundedSender, -} +impl TaskEntry { + fn touch(&mut self) { + self.task.last_updated_at = current_timestamp(); + } -struct RunningTask { - task_handle: tokio::task::JoinHandle<()>, - started_at: std::time::Instant, - timeout: Option, - descriptor: OperationDescriptor, -} + fn current_status(&self) -> TaskStatus { + match &self.terminal { + Some(payload) => payload.status(), + None if !self.pending_inputs.is_empty() => TaskStatus::InputRequired, + None => TaskStatus::Working, + } + } -#[non_exhaustive] -pub struct TaskResult { - pub descriptor: OperationDescriptor, - pub result: Result, Error>, + fn detailed(&self) -> DetailedTask { + let payload = match &self.terminal { + Some(p) => p.clone(), + None if !self.pending_inputs.is_empty() => TaskPayload::InputRequired { + input_requests: self + .pending_inputs + .iter() + .map(|(k, (req, _))| (k.clone(), req.clone())) + .collect::(), + }, + None => TaskPayload::Working, + }; + DetailedTask::new(self.task.clone(), payload) + } } -/// Helper to generate an ISO 8601 timestamp for task metadata. -pub fn current_timestamp() -> String { - chrono::Utc::now().to_rfc3339() +#[derive(Default)] +struct TaskManagerInner { + tasks: HashMap, } -/// Result transport for tool calls executed as tasks. -pub struct ToolCallTaskResult { - id: String, - pub result: Result, +/// Options controlling a spawned task. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct TaskOptions { + /// TTL in milliseconds; `None` means unlimited retention. + pub ttl_ms: Option, + /// Suggested polling interval in milliseconds. + pub poll_interval_ms: Option, + /// Initial status message. + pub status_message: Option, } -impl ToolCallTaskResult { - pub fn new(id: impl Into, result: Result) -> Self { +impl Default for TaskOptions { + fn default() -> Self { Self { - id: id.into(), - result, + ttl_ms: Some(DEFAULT_TASK_TTL_MS), + poll_interval_ms: Some(DEFAULT_POLL_INTERVAL_MS), + status_message: None, } } } -impl OperationResultTransport for ToolCallTaskResult { - fn operation_id(&self) -> &String { - &self.id +impl TaskOptions { + pub fn new() -> Self { + Self::default() } - fn as_any(&self) -> &dyn Any { + /// Set the TTL in milliseconds. `None` means unlimited retention. + pub fn with_ttl_ms(mut self, ttl_ms: impl Into>) -> Self { + self.ttl_ms = ttl_ms.into(); self } -} -impl Default for OperationProcessor { - fn default() -> Self { - Self::new() + /// Set the suggested polling interval in milliseconds. + pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self { + self.poll_interval_ms = Some(poll_interval_ms); + self } + + /// Set the initial status message. + pub fn with_status_message(mut self, message: impl Into) -> Self { + self.status_message = Some(message.into()); + self + } +} + +/// Server-side task store and executor for the SEP-2663 Tasks extension. +/// +/// Cheaply cloneable; all clones share the same state. +/// +/// # Retention +/// +/// Entries are swept opportunistically on every `spawn` / `get_task` / +/// `update_task` / `cancel_task` / `running_task_count` call: non-terminal +/// tasks whose `ttl_ms` has elapsed are marked `failed` (their operation is +/// aborted), and terminal tasks are evicted after being retained for one +/// further `ttl_ms` window past their terminal transition so pollers can +/// observe the final state. +/// +/// Note that the retention window intentionally extends past the +/// creation-based lifetime that `ttl_ms` advertises on the wire: a task that +/// runs to its TTL deadline is marked `failed` around `created + ttl_ms` and +/// stays observable until roughly `created + 2 × ttl_ms`. This is compliant — +/// SEP-2663 lets servers delete expired tasks *at any time* after the TTL, +/// so retaining them longer as an observation grace period is a server-side +/// policy choice, not a wire-contract change. Clients may treat the task as +/// unusable after `createdAt + ttlMs` regardless. +/// +/// Tasks with `ttl_ms: None` are retained for the lifetime of the manager +/// (spec: unlimited retention) — bound task creation or call +/// [`Self::shutdown`] yourself if you spawn such tasks in a long-lived +/// server. There is no background sweeper; an idle manager holds its entries +/// until the next call. +#[derive(Clone, Default)] +pub struct TaskManager { + inner: Arc>, } -impl OperationProcessor { +impl TaskManager { pub fn new() -> Self { - let (task_result_sender, task_result_receiver) = mpsc::unbounded_channel(); - Self { - running_tasks: HashMap::new(), - completed_results: Vec::new(), - task_result_receiver, - task_result_sender, - } + Self::default() } - /// Submit an operation for asynchronous execution. - #[allow(clippy::result_large_err)] - pub fn submit_operation(&mut self, message: OperationMessage) -> Result<(), Error> { - if self - .running_tasks - .contains_key(&message.descriptor.operation_id) + /// Spawn an operation as a task and return its seed [`Task`] state for a + /// `CreateTaskResult`. The task is durably observable via + /// [`Self::get_task`] before this method returns. + /// + /// `make_future` receives a [`TaskContext`] for mid-task input requests, + /// status messages, and cooperative cancellation checks. + pub fn spawn(&self, options: TaskOptions, make_future: F) -> Task + where + F: FnOnce(TaskContext) -> TaskFuture, + { + let task_id = uuid::Uuid::new_v4().to_string(); + let now = current_timestamp(); + let mut task = Task::new(task_id.clone(), TaskStatus::Working, now.clone(), now); + task.ttl_ms = options.ttl_ms; + task.poll_interval_ms = options.poll_interval_ms; + task.status_message = options.status_message; + + let entry = TaskEntry { + task: task.clone(), + terminal: None, + terminal_at: None, + pending_inputs: HashMap::new(), + used_input_keys: std::collections::HashSet::new(), + cancel_requested: false, + cancel_signal: tokio::sync::watch::channel(false).0, + created: Instant::now(), + join_handle: None, + }; + { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + // Opportunistic TTL sweep on every task creation, so terminal + // entries are evicted even if clients never poll again. + Self::sweep_expired(&mut inner); + inner.tasks.insert(task_id.clone(), entry); + } + + let context = TaskContext { + task_id: task_id.clone(), + inner: self.inner.clone(), + }; + let future = make_future(context); + let inner = self.inner.clone(); + let id_for_task = task_id.clone(); + let handle = tokio::spawn(async move { + let result = future.await; + let mut inner = inner.lock().expect("task manager lock poisoned"); + if let Some(entry) = inner.tasks.get_mut(&id_for_task) { + if entry.terminal.is_none() { + entry.terminal = Some(match result { + Ok(result) => TaskPayload::Completed { + result: result_to_object(&result), + }, + Err(TaskExit::Cancelled) => TaskPayload::Cancelled, + Err(TaskExit::Error(error)) => TaskPayload::Failed { + error: error_to_object(&error), + }, + }); + entry.terminal_at = Some(Instant::now()); + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = entry.current_status(); + } + // The operation has finished; drop the JoinHandle so it is + // not retained for the rest of the retention window. + entry.join_handle = None; + } + }); + match self + .inner + .lock() + .expect("task manager lock poisoned") + .tasks + .get_mut(&task_id) { - return Err(Error::TaskError(format!( - "Operation with id {} is already running", - message.descriptor.operation_id - ))); + // Only store the handle while the operation is still running: if + // it already settled, the completion path above ran first and a + // stored handle would never be cleared. + Some(entry) => { + if entry.terminal.is_none() { + entry.join_handle = Some(handle); + } + } + // The entry is gone: shutdown() drained the map between the + // insert and here. Abort rather than leak a detached operation. + None => handle.abort(), + } + task + } + + /// Handle `tasks/get`: return the current [`DetailedTask`] state. + pub fn get_task(&self, task_id: &str) -> Result { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + entry.task.status = entry.current_status(); + Ok(entry.detailed()) + } + + /// Handle `tasks/update`: deliver `inputResponses` to the running + /// operation. Unknown, already-answered, or superseded keys are ignored + /// per spec; a partial set of responses is accepted. + pub fn update_task( + &self, + task_id: &str, + input_responses: impl IntoIterator, + ) -> Result<(), McpError> { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + for (key, value) in input_responses { + if let Some((_, tx)) = entry.pending_inputs.remove(&key) { + // Receiver dropped means the operation moved on; ignore. + let _ = tx.send(value); + } } - self.spawn_async_task(message); + entry.touch(); + entry.task.status = entry.current_status(); Ok(()) } - fn spawn_async_task(&mut self, message: OperationMessage) { - let OperationMessage { descriptor, future } = message; - let task_id = descriptor.operation_id.clone(); - let timeout_ms = descriptor.ttl.or(Some(DEFAULT_TASK_TIMEOUT_MS)); - let sender = self.task_result_sender.clone(); - let descriptor_for_result = descriptor.clone(); + /// Handle `tasks/cancel`: cooperative cancellation (SEP-2663). + /// + /// Records the cancellation *intent* and acknowledges immediately, but + /// does **not** abort the underlying future or force a terminal state. + /// The operation observes cancellation via + /// [`TaskContext::is_cancel_requested`] / [`TaskContext::cancelled`], or + /// via the error returned from a pending [`TaskContext::request_input`] + /// call (whose response channel is dropped here), and decides its own + /// terminal status: + /// + /// - stops with [`TaskExit::Cancelled`] → recorded as `cancelled`, + /// - stops with [`TaskExit::Error`] → recorded as `failed` with the + /// error inlined (a real failure after a cancel request is not masked), + /// - finishes its work anyway → recorded as `completed` — per the spec, + /// "the task may still reach a non-`cancelled` terminal status". + pub fn cancel_task(&self, task_id: &str) -> Result<(), McpError> { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + let entry = inner + .tasks + .get_mut(task_id) + .ok_or_else(|| unknown_task(task_id))?; + entry.cancel_requested = true; + let _ = entry.cancel_signal.send(true); + if entry.terminal.is_none() { + // Wake any operation parked on `request_input`: dropping the + // response senders resolves those awaits with an error, giving + // parked operations a cooperative exit path. The task leaves + // `input_required` and reports `working` until it settles. + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = entry.current_status(); + } + Ok(()) + } - let timed_future = async move { - if let Some(ms) = timeout_ms { - match timeout(Duration::from_millis(ms), future).await { - Ok(result) => result, - Err(_) => Err(Error::TaskError("Operation timed out".to_string())), - } - } else { - future.await + /// Number of tasks currently in a non-terminal state. + pub fn running_task_count(&self) -> usize { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + Self::sweep_expired(&mut inner); + inner + .tasks + .values() + .filter(|e| e.terminal.is_none()) + .count() + } + + /// Abort all running tasks and clear all task state. + pub fn shutdown(&self) { + let mut inner = self.inner.lock().expect("task manager lock poisoned"); + for (_, mut entry) in inner.tasks.drain() { + if let Some(handle) = entry.join_handle.take() { + handle.abort(); } - }; + } + } - let handle = tokio::spawn(async move { - let result = timed_future.await; - let task_result = TaskResult { - descriptor: descriptor_for_result, - result, + /// TTL sweep, run from every `TaskManager` entry point (SEP-2663: servers + /// MAY mark a task `failed` any time after its TTL elapses, and + /// subsequently delete it at any time; `ttl_ms: None` means unlimited + /// retention). + /// + /// Two phases per entry: + /// 1. A non-terminal task whose TTL has elapsed is marked `failed` (its + /// operation is aborted — the TTL is the SDK's hard-stop safety valve, + /// unlike cooperative `tasks/cancel`). + /// 2. A *terminal* task is evicted once it has been retained for a full + /// TTL window after reaching its terminal state, so well-behaved + /// pollers get a chance to observe the final status before late + /// `tasks/get` calls start returning `-32602`. + fn sweep_expired(inner: &mut TaskManagerInner) { + // Phase 1: fail overdue non-terminal tasks. + for entry in inner.tasks.values_mut() { + if entry.terminal.is_none() + && let Some(ttl_ms) = entry.task.ttl_ms + && entry.created.elapsed().as_millis() >= u128::from(ttl_ms) + { + if let Some(handle) = entry.join_handle.take() { + handle.abort(); + } + entry.terminal = Some(TaskPayload::Failed { + error: error_to_object(&McpError::internal_error( + "task expired: TTL elapsed before completion".to_string(), + None, + )), + }); + entry.terminal_at = Some(Instant::now()); + entry.pending_inputs.clear(); + entry.touch(); + entry.task.status = TaskStatus::Failed; + } + } + // Phase 2: evict terminal tasks whose retention window has passed. + inner.tasks.retain(|_, entry| { + let (Some(ttl_ms), Some(terminal_at)) = (entry.task.ttl_ms, entry.terminal_at) else { + return true; }; - let _ = sender.send(task_result); + terminal_at.elapsed().as_millis() < u128::from(ttl_ms) }); - let running_task = RunningTask { - task_handle: handle, - started_at: std::time::Instant::now(), - timeout: timeout_ms, - descriptor, - }; - self.running_tasks.insert(task_id, running_task); } +} - /// Collect completed results from running tasks and remove them from the running tasks map. - fn collect_completed_results(&mut self) { - while let Ok(result) = self.task_result_receiver.try_recv() { - self.running_tasks.remove(&result.descriptor.operation_id); - self.completed_results.push(result); - } +fn unknown_task(task_id: &str) -> McpError { + McpError::invalid_params(format!("unknown task: {task_id}"), None) +} + +fn result_to_object(result: &CallToolResult) -> JsonObject { + match serde_json::to_value(result) { + Ok(serde_json::Value::Object(map)) => map, + _ => JsonObject::new(), } +} - /// Check for tasks that have exceeded their timeout and handle them appropriately. - pub fn check_timeouts(&mut self) { - self.collect_completed_results(); - let now = std::time::Instant::now(); - let mut timed_out_tasks = Vec::new(); +fn error_to_object(error: &McpError) -> JsonObject { + match serde_json::to_value(error) { + Ok(serde_json::Value::Object(map)) => map, + _ => JsonObject::new(), + } +} - for (task_id, task) in &self.running_tasks { - if let Some(timeout_duration) = task.timeout { - if now.duration_since(task.started_at).as_millis() > u128::from(timeout_duration) { - task.task_handle.abort(); - timed_out_tasks.push(task_id.clone()); +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ContentBlock; + + fn ok_result(text: &str) -> CallToolResult { + CallToolResult::success(vec![ContentBlock::text(text.to_string())]) + } + + #[tokio::test] + async fn task_completes_and_result_is_inlined() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("42")) }) + }); + assert_eq!(task.status, TaskStatus::Working); + + // Durable immediately. + manager.get_task(&task.task_id).unwrap(); + + // Wait for completion. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status() == TaskStatus::Completed { + match detailed.payload { + TaskPayload::Completed { result } => { + assert!(result.contains_key("content")); + return; + } + other => panic!("unexpected payload: {other:?}"), } } } + panic!("task did not complete"); + } - for task_id in timed_out_tasks { - if let Some(task) = self.running_tasks.remove(&task_id) { - let timeout_result = TaskResult { - descriptor: task.descriptor, - result: Err(Error::TaskError("Operation timed out".to_string())), - }; - self.completed_results.push(timeout_result); + #[tokio::test] + async fn cancel_settles_to_cancelled_when_operation_honors_it() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => Err(TaskExit::Cancelled), + _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { + Ok(ok_result("never")) + } + } + }) + }); + manager.cancel_task(&task.task_id).unwrap(); + + // The ack is immediate but the terminal state is set by the + // operation; poll until it settles. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Cancelled); + return; } } + panic!("task did not settle after cancel"); } - /// Get the number of running tasks. - pub fn running_task_count(&mut self) -> usize { - self.collect_completed_results(); - self.running_tasks.len() + #[tokio::test] + async fn post_cancel_unrelated_error_settles_as_failed() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + // Fail for an unrelated reason after observing the cancel: + // must be recorded as `failed`, not masked as `cancelled`. + ctx.cancelled().await; + Err(TaskExit::Error(McpError::internal_error( + "database write failed", + None, + ))) + }) + }); + manager.cancel_task(&task.task_id).unwrap(); + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Failed); + match detailed.payload { + TaskPayload::Failed { error } => { + assert!( + error.get("message").is_some_and(|m| m + .as_str() + .is_some_and(|s| s.contains("database write failed"))), + "error payload should be preserved: {error:?}" + ); + } + other => panic!("unexpected payload: {other:?}"), + } + return; + } + } + panic!("task did not settle after cancel"); } - /// Cancel all running tasks. - pub fn cancel_all_tasks(&mut self) { - for (_, task) in self.running_tasks.drain() { - task.task_handle.abort(); + #[tokio::test] + async fn cancel_is_cooperative_and_lets_the_operation_clean_up() { + let manager = TaskManager::new(); + let (cleanup_tx, cleanup_rx) = oneshot::channel::<&'static str>(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + // Wait for cancellation, then run cleanup and finish the + // work anyway (spec: a task may still reach a + // non-`cancelled` terminal status). + ctx.cancelled().await; + let _ = cleanup_tx.send("cleaned up"); + Ok(ok_result("finished despite cancel")) + }) + }); + + manager.cancel_task(&task.task_id).unwrap(); + + // The ack is immediate and does not force a terminal state. + let detailed = manager.get_task(&task.task_id).unwrap(); + assert!( + !detailed.status().is_terminal(), + "cancel must not force terminal state" + ); + + // The operation observes the cancel and performs cleanup. + let cleanup = tokio::time::timeout(std::time::Duration::from_secs(5), cleanup_rx) + .await + .expect("cleanup should not time out") + .expect("cleanup channel should not be dropped"); + assert_eq!(cleanup, "cleaned up"); + + // The operation chose to complete: the task settles as `completed`. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status().is_terminal() { + assert_eq!(detailed.status(), TaskStatus::Completed); + return; + } } - while self.task_result_receiver.try_recv().is_ok() {} - self.completed_results.clear(); + panic!("task did not settle after cancel"); } - /// List running task ids. - pub fn list_running(&mut self) -> Vec { - self.collect_completed_results(); - self.running_tasks.keys().cloned().collect() - } + #[tokio::test] + async fn cancel_wakes_parked_input_requests() { + let manager = TaskManager::new(); + let (exit_tx, exit_rx) = oneshot::channel::<&'static str>(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + let request: InputRequest = serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "Waiting forever", + "requestedSchema": {"type": "object", "properties": {}} + } + })) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + // Parked on input; cancel must wake this await with an error. + let err = ctx.request_input("k1", request).await.unwrap_err(); + let _ = exit_tx.send("woken"); + Err(err) + }) + }); + + // Wait until the task is parked on the input request. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::InputRequired { + break; + } + } - /// Returns a snapshot of completed task results. - pub fn peek_completed(&mut self) -> &[TaskResult] { - self.collect_completed_results(); - &self.completed_results + manager.cancel_task(&task.task_id).unwrap(); + let woken = tokio::time::timeout(std::time::Duration::from_secs(5), exit_rx) + .await + .expect("parked operation should be woken by cancel") + .expect("exit channel should not be dropped"); + assert_eq!(woken, "woken"); + assert_eq!( + manager.get_task(&task.task_id).unwrap().status(), + TaskStatus::Cancelled + ); } - /// Fetch the metadata for a running or recently completed task. - pub fn task_descriptor(&self, task_id: &str) -> Option<&OperationDescriptor> { - if let Some(task) = self.running_tasks.get(task_id) { - return Some(&task.descriptor); + #[tokio::test] + async fn unknown_task_is_invalid_params() { + // SEP-2663 §Protocol Errors: invalid or nonexistent taskId is -32602 + // (Invalid params) — MUST for tasks/get, SHOULD for update/cancel. + let manager = TaskManager::new(); + for err in [ + manager.get_task("nope").unwrap_err(), + manager.cancel_task("nope").unwrap_err(), + manager.update_task("nope", []).unwrap_err(), + ] { + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); } - self.completed_results - .iter() - .rev() - .find(|result| result.descriptor.operation_id == task_id) - .map(|result| &result.descriptor) - } - - /// Attempt to cancel a running task. - pub fn cancel_task(&mut self, task_id: &str) -> bool { - self.collect_completed_results(); - if let Some(task) = self.running_tasks.remove(task_id) { - task.task_handle.abort(); - // Insert a cancelled result so callers can observe the terminal state. - let cancel_result = TaskResult { - descriptor: task.descriptor, - result: Err(Error::TaskError("Operation cancelled".to_string())), - }; - self.completed_results.push(cancel_result); - return true; + } + + #[tokio::test] + async fn terminal_tasks_are_evicted_after_retention_window() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::new().with_ttl_ms(50), |_ctx| { + Box::pin(async { Ok(ok_result("fast")) }) + }); + + // Wait for completion; the terminal state stays observable during + // the retention window. + let mut completed = false; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + if manager.get_task(&task.task_id).unwrap().status() == TaskStatus::Completed { + completed = true; + break; + } } - false + assert!(completed, "task should have completed"); + + // After a full TTL window past terminal, the entry is evicted and + // late polls get -32602. + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + let err = manager.get_task(&task.task_id).unwrap_err(); + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + assert_eq!(manager.running_task_count(), 0); } - /// Retrieve a completed task result if available. - pub fn take_completed_result(&mut self, task_id: &str) -> Option { - self.collect_completed_results(); - if let Some(position) = self - .completed_results - .iter() - .position(|result| result.descriptor.operation_id == task_id) - { - Some(self.completed_results.remove(position)) - } else { - None + #[tokio::test] + async fn abandoned_tasks_are_swept_by_other_entry_points() { + // A task nobody ever polls again must still be failed + evicted; the + // sweep runs from spawn() too, so activity on *other* tasks is enough. + let manager = TaskManager::new(); + let abandoned = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }); + + // Let TTL elapse (fails the task), then a second full window + // (evicts it), without ever calling get_task on the abandoned id. + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other")) }) + }); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other2")) }) + }); + + let err = manager.get_task(&abandoned.task_id).unwrap_err(); + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + } + + #[tokio::test] + async fn running_task_count_sweeps_expired_tasks() { + let manager = TaskManager::new(); + let _task = manager.spawn(TaskOptions::new().with_ttl_ms(10), |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }); + assert_eq!(manager.running_task_count(), 1); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + // The count itself must sweep: the overdue task is failed, not + // reported as running. + assert_eq!(manager.running_task_count(), 0); + } + + #[tokio::test] + async fn unlimited_ttl_tasks_are_retained() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::new().with_ttl_ms(None), |_ctx| { + Box::pin(async { Ok(ok_result("kept")) }) + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + // Sweeps triggered by other entry points must not evict it. + let _ = manager.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(ok_result("other")) }) + }); + assert_eq!( + manager.get_task(&task.task_id).unwrap().status(), + TaskStatus::Completed + ); + } + + #[tokio::test] + async fn ttl_expiry_fails_task() { + let manager = TaskManager::new(); + let task = manager.spawn( + TaskOptions { + ttl_ms: Some(10), + ..Default::default() + }, + |_ctx| { + Box::pin(async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + Ok(ok_result("never")) + }) + }, + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + assert_eq!(detailed.status(), TaskStatus::Failed); + } + + #[tokio::test] + async fn input_required_roundtrip() { + let manager = TaskManager::new(); + let task = manager.spawn(TaskOptions::default(), |ctx| { + Box::pin(async move { + let request: InputRequest = serde_json::from_value(serde_json::json!({ + "method": "elicitation/create", + "params": { + "message": "What is your name?", + "requestedSchema": {"type": "object", "properties": {}} + } + })) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + let response = ctx.request_input("name-1", request).await?; + let name = response + .get("content") + .and_then(|c| c.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + Ok(ok_result(&format!("hello {name}"))) + }) + }); + + // Wait for the task to surface the input request. + let mut saw_input_required = false; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if let TaskPayload::InputRequired { input_requests } = &detailed.payload { + assert!(input_requests.contains_key("name-1")); + saw_input_required = true; + break; + } + } + assert!(saw_input_required, "task never reached input_required"); + + // Respond via tasks/update. + manager + .update_task( + &task.task_id, + [( + "name-1".to_string(), + serde_json::json!({"action": "accept", "content": {"name": "Ada"}}), + )], + ) + .unwrap(); + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let detailed = manager.get_task(&task.task_id).unwrap(); + if detailed.status() == TaskStatus::Completed { + return; + } } + panic!("task did not complete after input response"); } } diff --git a/crates/rmcp/src/transport/common/mcp_headers.rs b/crates/rmcp/src/transport/common/mcp_headers.rs index 0b38981a4..12f8594c2 100644 --- a/crates/rmcp/src/transport/common/mcp_headers.rs +++ b/crates/rmcp/src/transport/common/mcp_headers.rs @@ -25,6 +25,10 @@ const NAME_FROM_URI: &[&str] = &[ "resources/subscribe", "resources/unsubscribe", ]; +/// Methods whose `Mcp-Name` is sourced from `params.taskId` (SEP-2663 Tasks +/// extension): allows intermediaries to route task polling to the server +/// instance holding the task's state. +const NAME_FROM_TASK_ID: &[&str] = &["tasks/get", "tasks/update", "tasks/cancel"]; /// Returns the `Mcp-Name` value for a request, if the method carries one. fn extract_name(method: &str, params: Option<&Value>) -> Option { @@ -33,6 +37,8 @@ fn extract_name(method: &str, params: Option<&Value>) -> Option { "name" } else if NAME_FROM_URI.contains(&method) { "uri" + } else if NAME_FROM_TASK_ID.contains(&method) { + "taskId" } else { return None; }; diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index 58e9a58af..c3d08cd52 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -17,10 +17,8 @@ fn test_tool_list_result() { /// Regression tests for `#[serde(untagged)]` deserialization of `ServerResult`. /// /// `ServerResult` is an untagged enum, so serde tries each variant in declaration -/// order. `GetTaskPayloadResult` has a custom `Deserialize` impl that always fails -/// so it is skipped, and `CustomResult(Value)` acts as the catch-all. If variant -/// ordering changes or the custom impl is removed, these tests will catch the -/// regression. +/// order, with `CustomResult(Value)` acting as the catch-all. If variant ordering +/// changes, these tests will catch the regression. mod untagged_server_result { use rmcp::model::{CallToolResult, JsonRpcResponse, ServerJsonRpcMessage, ServerResult}; use serde_json::json; @@ -84,7 +82,7 @@ mod untagged_server_result { #[test] fn unknown_shape_falls_through_to_custom_result() { // A value that doesn't match any known result type should land in - // CustomResult, NOT GetTaskPayloadResult. + // CustomResult. let result = parse_result(wrap_response(json!({ "some_unknown_field": "some_value", "number": 42 @@ -96,10 +94,40 @@ mod untagged_server_result { } #[test] - fn arbitrary_json_value_does_not_deserialize_as_get_task_payload_result() { - // GetTaskPayloadResult wraps a bare Value, but its custom Deserialize - // always fails so serde skips it during untagged resolution. - // Any JSON value must fall through to CustomResult instead. + fn result_type_bearing_objects_do_not_match_task_ack() { + // TaskAckResult carries only `resultType` (+ optional `_meta`), so it + // must not greedily swallow arbitrary results that happen to include + // a `resultType` key inside the untagged ServerResult union. + let result = parse_result(wrap_response(json!({ + "resultType": "weird-custom", + "payload": { "a": 1 } + }))); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "expected CustomResult, got {result:?}" + ); + + let result = parse_result(wrap_response(json!({ + "resultType": "complete", + "customField": 42 + }))); + assert!( + matches!(result, ServerResult::CustomResult(_)), + "expected CustomResult, got {result:?}" + ); + + // A bare complete ack (the actual tasks/update / tasks/cancel ack + // shape) still parses as TaskAckResult. + let result = parse_result(wrap_response(json!({ "resultType": "complete" }))); + assert!( + matches!(result, ServerResult::TaskAckResult(_)), + "expected TaskAckResult, got {result:?}" + ); + } + + #[test] + fn arbitrary_json_value_falls_through_to_custom_result() { + // Any bare JSON value must fall through to CustomResult. for value in [json!(42), json!("hello"), json!(null), json!([1, 2, 3])] { let result = parse_result(wrap_response(value.clone())); assert!( diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 922469c23..fe2eb5e70 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -126,7 +126,7 @@ "const": "tools/call" }, "CallToolRequestParams": { - "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.\n\nThis implements `TaskAugmentedRequestParamsMeta` as tool calls can be\nlong-running and may benefit from task-based execution.", + "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", "type": "object", "properties": { "_meta": { @@ -166,17 +166,6 @@ "string", "null" ] - }, - "task": { - "description": "Task metadata for async task management (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/TaskMetadata" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -304,16 +293,6 @@ "type": "null" } ] - }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] } } }, @@ -657,18 +636,6 @@ } } }, - "ElicitationTaskCapability": { - "type": "object", - "properties": { - "create": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "EmbeddedResource": { "description": "Embedded resource content (spec `EmbeddedResource`).", "type": "object", @@ -815,34 +782,6 @@ "type": "object", "properties": { "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "GetTaskPayloadMethod": { - "type": "string", - "format": "const", - "const": "tasks/result" - }, - "GetTaskPayloadParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", "anyOf": [ { "$ref": "#/definitions/RequestMetaObject" @@ -853,6 +792,7 @@ ] }, "taskId": { + "description": "Identifier of the task to query.", "type": "string" } }, @@ -1099,9 +1039,6 @@ { "$ref": "#/definitions/NotificationNoParam2" }, - { - "$ref": "#/definitions/Notification3" - }, { "$ref": "#/definitions/CustomNotification" } @@ -1169,9 +1106,6 @@ { "$ref": "#/definitions/Request11" }, - { - "$ref": "#/definitions/RequestOptionalParam5" - }, { "$ref": "#/definitions/Request12" }, @@ -1251,11 +1185,6 @@ "roots" ] }, - "ListTasksMethod": { - "type": "string", - "format": "const", - "const": "tasks/list" - }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -1311,21 +1240,6 @@ "params" ] }, - "Notification3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/TaskStatusNotificationMethod" - }, - "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, "NotificationMetaObject": { "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", "type": "object", @@ -1606,10 +1520,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskPayloadMethod" + "$ref": "#/definitions/UpdateTaskMethod" }, "params": { - "$ref": "#/definitions/GetTaskPayloadParams" + "$ref": "#/definitions/UpdateTaskParams" } }, "required": [ @@ -1878,27 +1792,6 @@ "method" ] }, - "RequestOptionalParam5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListTasksMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, "Resource": { "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", "type": "object", @@ -2246,19 +2139,6 @@ } ] }, - "SamplingTaskCapability": { - "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", - "type": "object", - "properties": { - "createMessage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "SetLevelRequestMethod": { "type": "string", "format": "const", @@ -2393,188 +2273,6 @@ "notifications" ] }, - "TaskMetadata": { - "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", - "type": "object", - "properties": { - "ttl": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - } - }, - "TaskRequestsCapability": { - "description": "Request types that support task-augmented execution.", - "type": "object", - "properties": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "anyOf": [ - { - "$ref": "#/definitions/SamplingTaskCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "TaskStatus": { - "description": "Canonical task lifecycle status as defined by SEP-1686.", - "oneOf": [ - { - "description": "The receiver accepted the request and is currently working on it.", - "type": "string", - "const": "working" - }, - { - "description": "The receiver requires additional input before work can continue.", - "type": "string", - "const": "input_required" - }, - { - "description": "The underlying operation completed successfully and the result is ready.", - "type": "string", - "const": "completed" - }, - { - "description": "The underlying operation failed and will not continue.", - "type": "string", - "const": "failed" - }, - { - "description": "The task was cancelled and will not continue processing.", - "type": "string", - "const": "cancelled" - } - ] - }, - "TaskStatusNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tasks/status" - }, - "TaskStatusNotificationParam": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO-8601 creation timestamp.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", - "type": "string" - }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Unique task identifier generated by the receiver.", - "type": "string" - }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "TasksCapability": { - "description": "Task capabilities shared by client and server.", - "type": "object", - "properties": { - "cancel": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "list": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requests": { - "anyOf": [ - { - "$ref": "#/definitions/TaskRequestsCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TextContent": { "description": "Text content block (spec `TextContent`).", "type": "object", @@ -2679,18 +2377,6 @@ "input" ] }, - "ToolsTaskCapability": { - "type": "object", - "properties": { - "call": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "UnsubscribeRequestMethod": { "type": "string", "format": "const", @@ -2720,6 +2406,40 @@ "uri" ] }, + "UpdateTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/update" + }, + "UpdateTaskParams": { + "description": "Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding\nin-task server-to-client requests surfaced via `tasks/get` `inputRequests`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "inputResponses": { + "description": "Responses to outstanding `inputRequests` previously surfaced by the\nserver. Each key MUST correspond to a currently-outstanding\n`inputRequests` key.", + "type": "object", + "additionalProperties": true + }, + "taskId": { + "description": "Identifier of the task to update.", + "type": "string" + } + }, + "required": [ + "taskId", + "inputResponses" + ] + }, "UrlElicitationCapability": { "description": "Capability for URL mode elicitation.", "type": "object" diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 922469c23..fe2eb5e70 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -126,7 +126,7 @@ "const": "tools/call" }, "CallToolRequestParams": { - "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.\n\nThis implements `TaskAugmentedRequestParamsMeta` as tool calls can be\nlong-running and may benefit from task-based execution.", + "description": "Parameters for calling a tool provided by an MCP server.\n\nContains the tool name and optional arguments needed to execute\nthe tool operation.", "type": "object", "properties": { "_meta": { @@ -166,17 +166,6 @@ "string", "null" ] - }, - "task": { - "description": "Task metadata for async task management (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/TaskMetadata" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -304,16 +293,6 @@ "type": "null" } ] - }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] } } }, @@ -657,18 +636,6 @@ } } }, - "ElicitationTaskCapability": { - "type": "object", - "properties": { - "create": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "EmbeddedResource": { "description": "Embedded resource content (spec `EmbeddedResource`).", "type": "object", @@ -815,34 +782,6 @@ "type": "object", "properties": { "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/RequestMetaObject" - }, - { - "type": "null" - } - ] - }, - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ] - }, - "GetTaskPayloadMethod": { - "type": "string", - "format": "const", - "const": "tasks/result" - }, - "GetTaskPayloadParams": { - "type": "object", - "properties": { - "_meta": { - "description": "Protocol-level metadata for this request (SEP-1319)", "anyOf": [ { "$ref": "#/definitions/RequestMetaObject" @@ -853,6 +792,7 @@ ] }, "taskId": { + "description": "Identifier of the task to query.", "type": "string" } }, @@ -1099,9 +1039,6 @@ { "$ref": "#/definitions/NotificationNoParam2" }, - { - "$ref": "#/definitions/Notification3" - }, { "$ref": "#/definitions/CustomNotification" } @@ -1169,9 +1106,6 @@ { "$ref": "#/definitions/Request11" }, - { - "$ref": "#/definitions/RequestOptionalParam5" - }, { "$ref": "#/definitions/Request12" }, @@ -1251,11 +1185,6 @@ "roots" ] }, - "ListTasksMethod": { - "type": "string", - "format": "const", - "const": "tasks/list" - }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -1311,21 +1240,6 @@ "params" ] }, - "Notification3": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/TaskStatusNotificationMethod" - }, - "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" - } - }, - "required": [ - "method", - "params" - ] - }, "NotificationMetaObject": { "description": "Metadata reserved by MCP on notifications. Extension keys are also allowed.", "type": "object", @@ -1606,10 +1520,10 @@ "type": "object", "properties": { "method": { - "$ref": "#/definitions/GetTaskPayloadMethod" + "$ref": "#/definitions/UpdateTaskMethod" }, "params": { - "$ref": "#/definitions/GetTaskPayloadParams" + "$ref": "#/definitions/UpdateTaskParams" } }, "required": [ @@ -1878,27 +1792,6 @@ "method" ] }, - "RequestOptionalParam5": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/ListTasksMethod" - }, - "params": { - "anyOf": [ - { - "$ref": "#/definitions/PaginatedRequestParams" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "method" - ] - }, "Resource": { "description": "A known resource that the server is capable of reading (spec `Resource`).\n\nAlso used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).", "type": "object", @@ -2246,19 +2139,6 @@ } ] }, - "SamplingTaskCapability": { - "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", - "type": "object", - "properties": { - "createMessage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "SetLevelRequestMethod": { "type": "string", "format": "const", @@ -2393,188 +2273,6 @@ "notifications" ] }, - "TaskMetadata": { - "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", - "type": "object", - "properties": { - "ttl": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - } - }, - "TaskRequestsCapability": { - "description": "Request types that support task-augmented execution.", - "type": "object", - "properties": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { - "anyOf": [ - { - "$ref": "#/definitions/SamplingTaskCapability" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, - { - "type": "null" - } - ] - } - } - }, - "TaskStatus": { - "description": "Canonical task lifecycle status as defined by SEP-1686.", - "oneOf": [ - { - "description": "The receiver accepted the request and is currently working on it.", - "type": "string", - "const": "working" - }, - { - "description": "The receiver requires additional input before work can continue.", - "type": "string", - "const": "input_required" - }, - { - "description": "The underlying operation completed successfully and the result is ready.", - "type": "string", - "const": "completed" - }, - { - "description": "The underlying operation failed and will not continue.", - "type": "string", - "const": "failed" - }, - { - "description": "The task was cancelled and will not continue processing.", - "type": "string", - "const": "cancelled" - } - ] - }, - "TaskStatusNotificationMethod": { - "type": "string", - "format": "const", - "const": "notifications/tasks/status" - }, - "TaskStatusNotificationParam": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/NotificationMetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO-8601 creation timestamp.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", - "type": "string" - }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Unique task identifier generated by the receiver.", - "type": "string" - }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "TasksCapability": { - "description": "Task capabilities shared by client and server.", - "type": "object", - "properties": { - "cancel": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "list": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requests": { - "anyOf": [ - { - "$ref": "#/definitions/TaskRequestsCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TextContent": { "description": "Text content block (spec `TextContent`).", "type": "object", @@ -2679,18 +2377,6 @@ "input" ] }, - "ToolsTaskCapability": { - "type": "object", - "properties": { - "call": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "UnsubscribeRequestMethod": { "type": "string", "format": "const", @@ -2720,6 +2406,40 @@ "uri" ] }, + "UpdateTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/update" + }, + "UpdateTaskParams": { + "description": "Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding\nin-task server-to-client requests surfaced via `tasks/get` `inputRequests`.", + "type": "object", + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/definitions/RequestMetaObject" + }, + { + "type": "null" + } + ] + }, + "inputResponses": { + "description": "Responses to outstanding `inputRequests` previously surfaced by the\nserver. Each key MUST correspond to a currently-outstanding\n`inputRequests` key.", + "type": "object", + "additionalProperties": true + }, + "taskId": { + "description": "Identifier of the task to update.", + "type": "string" + } + }, + "required": [ + "taskId", + "inputResponses" + ] + }, "UrlElicitationCapability": { "description": "Capability for URL mode elicitation.", "type": "object" diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 520702420..b38db540c 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -212,73 +212,6 @@ } } }, - "CancelTaskResult": { - "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO-8601 creation timestamp.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", - "type": "string" - }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Unique task identifier generated by the receiver.", - "type": "string" - }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, "CancelledNotificationMethod": { "type": "string", "format": "const", @@ -373,16 +306,6 @@ "type": "null" } ] - }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] } } }, @@ -645,17 +568,6 @@ "null" ] }, - "task": { - "description": "Task metadata for async task management (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/TaskMetadata" - }, - { - "type": "null" - } - ] - }, "temperature": { "description": "Temperature for controlling randomness (0.0 to 1.0)", "type": [ @@ -693,7 +605,7 @@ ] }, "CreateTaskResult": { - "description": "Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).", + "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", "type": "object", "properties": { "_meta": { @@ -706,12 +618,66 @@ } ] }, - "task": { - "$ref": "#/definitions/Task" + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "resultType": { + "description": "Always `\"task\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ - "task" + "resultType", + "taskId", + "status", + "createdAt", + "lastUpdatedAt" ] }, "CustomNotification": { @@ -1047,18 +1013,6 @@ "properties" ] }, - "ElicitationTaskCapability": { - "type": "object", - "properties": { - "create": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "EmbeddedResource": { "description": "Embedded resource content (spec `EmbeddedResource`).", "type": "object", @@ -1199,11 +1153,8 @@ "messages" ] }, - "GetTaskPayloadResult": { - "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." - }, "GetTaskResult": { - "description": "Response to a `tasks/get` request.\n\nPer spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are\nflattened at the top level, not nested under a `task` key.", + "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", "type": "object", "properties": { "_meta": { @@ -1217,15 +1168,31 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", + "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", "type": [ "integer", "null" @@ -1233,8 +1200,24 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "resultType": { + "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -1242,18 +1225,18 @@ ] }, "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", "type": [ "string", "null" ] }, "taskId": { - "description": "Unique task identifier generated by the receiver.", + "description": "Stable identifier for this task, generated by the server.", "type": "string" }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", "type": [ "integer", "null" @@ -1945,36 +1928,6 @@ "format": "const", "const": "roots/list" }, - "ListTasksResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/definitions/Task" - } - } - }, - "required": [ - "tasks" - ] - }, "ListToolsResult": { "type": "object", "properties": { @@ -2246,7 +2199,7 @@ "$ref": "#/definitions/TaskStatusNotificationMethod" }, "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" + "$ref": "#/definitions/TaskStatusNotificationParams" } }, "required": [ @@ -3191,19 +3144,6 @@ } ] }, - "SamplingTaskCapability": { - "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", - "type": "object", - "properties": { - "createMessage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "ServerCapabilities": { "title": "Builder", "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", @@ -3265,16 +3205,6 @@ } ] }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] - }, "tools": { "anyOf": [ { @@ -3325,15 +3255,9 @@ { "$ref": "#/definitions/CreateTaskResult" }, - { - "$ref": "#/definitions/ListTasksResult" - }, { "$ref": "#/definitions/GetTaskResult" }, - { - "$ref": "#/definitions/CancelTaskResult" - }, { "$ref": "#/definitions/CallToolResult" }, @@ -3341,7 +3265,7 @@ "$ref": "#/definitions/InputRequiredResult" }, { - "$ref": "#/definitions/GetTaskPayloadResult" + "$ref": "#/definitions/TaskAckResult" }, { "$ref": "#/definitions/EmptyObject" @@ -3528,138 +3452,58 @@ "io.modelcontextprotocol/subscriptionId" ] }, - "Task": { - "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", - "type": "object", - "properties": { - "createdAt": { - "description": "ISO-8601 creation timestamp.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", - "type": "string" - }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Unique task identifier generated by the receiver.", - "type": "string" - }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "TaskMetadata": { - "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", - "type": "object", - "properties": { - "ttl": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - } - }, - "TaskRequestsCapability": { - "description": "Request types that support task-augmented execution.", + "TaskAckResult": { + "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", "type": "object", "properties": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { + "_meta": { "anyOf": [ { - "$ref": "#/definitions/SamplingTaskCapability" + "$ref": "#/definitions/MetaObject" }, { "type": "null" } ] }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ { - "type": "null" + "$ref": "#/definitions/ResultType" } ] } - } + }, + "required": [ + "resultType" + ] }, "TaskStatus": { - "description": "Canonical task lifecycle status as defined by SEP-1686.", + "description": "Canonical task lifecycle status (SEP-2663).", "oneOf": [ { - "description": "The receiver accepted the request and is currently working on it.", + "description": "The request is currently being processed.", "type": "string", "const": "working" }, { - "description": "The receiver requires additional input before work can continue.", + "description": "The server needs input from the client before the task can proceed.", "type": "string", "const": "input_required" }, { - "description": "The underlying operation completed successfully and the result is ready.", + "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", "type": "string", "const": "completed" }, { - "description": "The underlying operation failed and will not continue.", + "description": "The request failed due to a JSON-RPC error during execution.", "type": "string", "const": "failed" }, { - "description": "The task was cancelled and will not continue processing.", + "description": "The request was cancelled before completion.", "type": "string", "const": "cancelled" } @@ -3668,10 +3512,10 @@ "TaskStatusNotificationMethod": { "type": "string", "format": "const", - "const": "notifications/tasks/status" + "const": "notifications/tasks" }, - "TaskStatusNotificationParam": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", + "TaskStatusNotificationParams": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", "type": "object", "properties": { "_meta": { @@ -3685,15 +3529,31 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", + "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", "type": [ "integer", "null" @@ -3701,8 +3561,15 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -3710,18 +3577,18 @@ ] }, "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", "type": [ "string", "null" ] }, "taskId": { - "description": "Unique task identifier generated by the receiver.", + "description": "Stable identifier for this task, generated by the server.", "type": "string" }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", "type": [ "integer", "null" @@ -3737,56 +3604,6 @@ "lastUpdatedAt" ] }, - "TaskSupport": { - "description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).", - "oneOf": [ - { - "description": "Clients MUST NOT invoke this tool as a task (default behavior).", - "type": "string", - "const": "forbidden" - }, - { - "description": "Clients MAY invoke this tool as either a task or a normal call.", - "type": "string", - "const": "optional" - }, - { - "description": "Clients MUST invoke this tool as a task.", - "type": "string", - "const": "required" - } - ] - }, - "TasksCapability": { - "description": "Task capabilities shared by client and server.", - "type": "object", - "properties": { - "cancel": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "list": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requests": { - "anyOf": [ - { - "$ref": "#/definitions/TaskRequestsCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TextContent": { "description": "Text content block (spec `TextContent`).", "type": "object", @@ -3960,17 +3777,6 @@ "null" ] }, - "execution": { - "description": "Execution-related configuration including task support mode.", - "anyOf": [ - { - "$ref": "#/definitions/ToolExecution" - }, - { - "type": "null" - } - ] - }, "icons": { "description": "Optional list of icons for the tool", "type": [ @@ -4089,23 +3895,6 @@ } ] }, - "ToolExecution": { - "description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.", - "type": "object", - "properties": { - "taskSupport": { - "description": "Indicates whether this tool supports task-based invocation.\n\nWhen not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.\nWhen set to `Optional`, clients MAY invoke this tool as a task or normal call.\nWhen set to `Required`, clients MUST invoke this tool as a task.", - "anyOf": [ - { - "$ref": "#/definitions/TaskSupport" - }, - { - "type": "null" - } - ] - } - } - }, "ToolListChangedNotificationMethod": { "type": "string", "format": "const", @@ -4191,18 +3980,6 @@ } } }, - "ToolsTaskCapability": { - "type": "object", - "properties": { - "call": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "UntitledItems": { "description": "Items for untitled multi-select options", "type": "object", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 520702420..b38db540c 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -212,73 +212,6 @@ } } }, - "CancelTaskResult": { - "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "description": "ISO-8601 creation timestamp.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", - "type": "string" - }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Unique task identifier generated by the receiver.", - "type": "string" - }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, "CancelledNotificationMethod": { "type": "string", "format": "const", @@ -373,16 +306,6 @@ "type": "null" } ] - }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] } } }, @@ -645,17 +568,6 @@ "null" ] }, - "task": { - "description": "Task metadata for async task management (SEP-1319)", - "anyOf": [ - { - "$ref": "#/definitions/TaskMetadata" - }, - { - "type": "null" - } - ] - }, "temperature": { "description": "Temperature for controlling randomness (0.0 to 1.0)", "type": [ @@ -693,7 +605,7 @@ ] }, "CreateTaskResult": { - "description": "Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).", + "description": "Result returned in lieu of a standard result to indicate the request will\nbe processed asynchronously (spec `CreateTaskResult`, `resultType: \"task\"`).\n\nThe embedded task is the seed state for the task; the client uses\n`task.task_id` for all subsequent `tasks/get`, `tasks/update`, and\n`tasks/cancel` calls.", "type": "object", "properties": { "_meta": { @@ -706,12 +618,66 @@ } ] }, - "task": { - "$ref": "#/definitions/Task" + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "resultType": { + "description": "Always `\"task\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ] + }, + "status": { + "description": "Current task status.", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Stable identifier for this task, generated by the server.", + "type": "string" + }, + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ - "task" + "resultType", + "taskId", + "status", + "createdAt", + "lastUpdatedAt" ] }, "CustomNotification": { @@ -1047,18 +1013,6 @@ "properties" ] }, - "ElicitationTaskCapability": { - "type": "object", - "properties": { - "create": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "EmbeddedResource": { "description": "Embedded resource content (spec `EmbeddedResource`).", "type": "object", @@ -1199,11 +1153,8 @@ "messages" ] }, - "GetTaskPayloadResult": { - "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." - }, "GetTaskResult": { - "description": "Response to a `tasks/get` request.\n\nPer spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are\nflattened at the top level, not nested under a `task` key.", + "description": "Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).\n\n`resultType` is `\"complete\"` — this is the standard result shape for\n`tasks/get`, not a task handle.", "type": "object", "properties": { "_meta": { @@ -1217,15 +1168,31 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", + "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", "type": [ "integer", "null" @@ -1233,8 +1200,24 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "resultType": { + "description": "Result type discriminator. `tasks/get` responses are standard results:\n`\"complete\"` (SEP-2322). Absent values deserialize as `\"complete\"`.", + "allOf": [ + { + "$ref": "#/definitions/ResultType" + } + ], + "default": "complete" + }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -1242,18 +1225,18 @@ ] }, "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", "type": [ "string", "null" ] }, "taskId": { - "description": "Unique task identifier generated by the receiver.", + "description": "Stable identifier for this task, generated by the server.", "type": "string" }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", "type": [ "integer", "null" @@ -1945,36 +1928,6 @@ "format": "const", "const": "roots/list" }, - "ListTasksResult": { - "type": "object", - "properties": { - "_meta": { - "anyOf": [ - { - "$ref": "#/definitions/MetaObject" - }, - { - "type": "null" - } - ] - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/definitions/Task" - } - } - }, - "required": [ - "tasks" - ] - }, "ListToolsResult": { "type": "object", "properties": { @@ -2246,7 +2199,7 @@ "$ref": "#/definitions/TaskStatusNotificationMethod" }, "params": { - "$ref": "#/definitions/TaskStatusNotificationParam" + "$ref": "#/definitions/TaskStatusNotificationParams" } }, "required": [ @@ -3191,19 +3144,6 @@ } ] }, - "SamplingTaskCapability": { - "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee .", - "type": "object", - "properties": { - "createMessage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "ServerCapabilities": { "title": "Builder", "description": "```rust\n# use rmcp::model::ServerCapabilities;\nlet cap = ServerCapabilities::builder()\n .enable_experimental()\n .enable_prompts()\n .enable_resources()\n .enable_tools()\n .enable_tool_list_changed()\n .build();\n```", @@ -3265,16 +3205,6 @@ } ] }, - "tasks": { - "anyOf": [ - { - "$ref": "#/definitions/TasksCapability" - }, - { - "type": "null" - } - ] - }, "tools": { "anyOf": [ { @@ -3325,15 +3255,9 @@ { "$ref": "#/definitions/CreateTaskResult" }, - { - "$ref": "#/definitions/ListTasksResult" - }, { "$ref": "#/definitions/GetTaskResult" }, - { - "$ref": "#/definitions/CancelTaskResult" - }, { "$ref": "#/definitions/CallToolResult" }, @@ -3341,7 +3265,7 @@ "$ref": "#/definitions/InputRequiredResult" }, { - "$ref": "#/definitions/GetTaskPayloadResult" + "$ref": "#/definitions/TaskAckResult" }, { "$ref": "#/definitions/EmptyObject" @@ -3528,138 +3452,58 @@ "io.modelcontextprotocol/subscriptionId" ] }, - "Task": { - "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", - "type": "object", - "properties": { - "createdAt": { - "description": "ISO-8601 creation timestamp.", - "type": "string" - }, - "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", - "type": "string" - }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - }, - "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", - "allOf": [ - { - "$ref": "#/definitions/TaskStatus" - } - ] - }, - "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "taskId": { - "description": "Unique task identifier generated by the receiver.", - "type": "string" - }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt" - ] - }, - "TaskMetadata": { - "description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).", - "type": "object", - "properties": { - "ttl": { - "type": [ - "integer", - "null" - ], - "format": "uint64", - "minimum": 0 - } - } - }, - "TaskRequestsCapability": { - "description": "Request types that support task-augmented execution.", + "TaskAckResult": { + "description": "Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).\n\nThe spec requires these acks to be empty results carrying the SEP-2322\n`resultType: \"complete\"` discriminator; task state changes are observed\nvia the next `tasks/get`.", "type": "object", "properties": { - "elicitation": { - "anyOf": [ - { - "$ref": "#/definitions/ElicitationTaskCapability" - }, - { - "type": "null" - } - ] - }, - "sampling": { + "_meta": { "anyOf": [ { - "$ref": "#/definitions/SamplingTaskCapability" + "$ref": "#/definitions/MetaObject" }, { "type": "null" } ] }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsTaskCapability" - }, + "resultType": { + "description": "Always `\"complete\"`.", + "allOf": [ { - "type": "null" + "$ref": "#/definitions/ResultType" } ] } - } + }, + "required": [ + "resultType" + ] }, "TaskStatus": { - "description": "Canonical task lifecycle status as defined by SEP-1686.", + "description": "Canonical task lifecycle status (SEP-2663).", "oneOf": [ { - "description": "The receiver accepted the request and is currently working on it.", + "description": "The request is currently being processed.", "type": "string", "const": "working" }, { - "description": "The receiver requires additional input before work can continue.", + "description": "The server needs input from the client before the task can proceed.", "type": "string", "const": "input_required" }, { - "description": "The underlying operation completed successfully and the result is ready.", + "description": "The request completed successfully and the result is available.\nThis includes tool calls that returned results with `isError: true`.", "type": "string", "const": "completed" }, { - "description": "The underlying operation failed and will not continue.", + "description": "The request failed due to a JSON-RPC error during execution.", "type": "string", "const": "failed" }, { - "description": "The task was cancelled and will not continue processing.", + "description": "The request was cancelled before completion.", "type": "string", "const": "cancelled" } @@ -3668,10 +3512,10 @@ "TaskStatusNotificationMethod": { "type": "string", "format": "const", - "const": "notifications/tasks/status" + "const": "notifications/tasks" }, - "TaskStatusNotificationParam": { - "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nThe task fields are flattened at the top level: `NotificationParams & Task`.", + "TaskStatusNotificationParams": { + "description": "Parameters for a task status notification (spec `TaskStatusNotificationParams`).\n\nCarries a complete [`DetailedTask`] for the current status, identical to\nwhat `tasks/get` would have returned at that moment. The task fields are\nflattened at the top level: `NotificationParams & Task`.", "type": "object", "properties": { "_meta": { @@ -3685,15 +3529,31 @@ ] }, "createdAt": { - "description": "ISO-8601 creation timestamp.", + "description": "ISO 8601 timestamp when the task was created.", "type": "string" }, + "error": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "inputRequests": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/InputRequest" + } + }, "lastUpdatedAt": { - "description": "ISO-8601 timestamp for the most recent status change.", + "description": "ISO 8601 timestamp when the task was last updated.", "type": "string" }, - "pollInterval": { - "description": "Suggested polling interval (milliseconds).", + "pollIntervalMs": { + "description": "Suggested polling interval in integer milliseconds. Clients SHOULD honor\nthis value to avoid overwhelming the server. This value MAY change over\nthe lifetime of a task.", "type": [ "integer", "null" @@ -3701,8 +3561,15 @@ "format": "uint64", "minimum": 0 }, + "result": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "status": { - "description": "Current lifecycle status (see [`TaskStatus`]).", + "description": "Current task status.", "allOf": [ { "$ref": "#/definitions/TaskStatus" @@ -3710,18 +3577,18 @@ ] }, "statusMessage": { - "description": "Optional human-readable status message for UI surfaces.", + "description": "Optional message describing the current task state.\nThis MAY be exposed to the end-user or model.", "type": [ "string", "null" ] }, "taskId": { - "description": "Unique task identifier generated by the receiver.", + "description": "Stable identifier for this task, generated by the server.", "type": "string" }, - "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "ttlMs": { + "description": "Time-to-live duration from creation in integer milliseconds; `None`\n(serialized as `null`) means unlimited. The server may discard the task\nafter the TTL elapses. This value MAY change over the lifetime of a task.", "type": [ "integer", "null" @@ -3737,56 +3604,6 @@ "lastUpdatedAt" ] }, - "TaskSupport": { - "description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).", - "oneOf": [ - { - "description": "Clients MUST NOT invoke this tool as a task (default behavior).", - "type": "string", - "const": "forbidden" - }, - { - "description": "Clients MAY invoke this tool as either a task or a normal call.", - "type": "string", - "const": "optional" - }, - { - "description": "Clients MUST invoke this tool as a task.", - "type": "string", - "const": "required" - } - ] - }, - "TasksCapability": { - "description": "Task capabilities shared by client and server.", - "type": "object", - "properties": { - "cancel": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "list": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "requests": { - "anyOf": [ - { - "$ref": "#/definitions/TaskRequestsCapability" - }, - { - "type": "null" - } - ] - } - } - }, "TextContent": { "description": "Text content block (spec `TextContent`).", "type": "object", @@ -3960,17 +3777,6 @@ "null" ] }, - "execution": { - "description": "Execution-related configuration including task support mode.", - "anyOf": [ - { - "$ref": "#/definitions/ToolExecution" - }, - { - "type": "null" - } - ] - }, "icons": { "description": "Optional list of icons for the tool", "type": [ @@ -4089,23 +3895,6 @@ } ] }, - "ToolExecution": { - "description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.", - "type": "object", - "properties": { - "taskSupport": { - "description": "Indicates whether this tool supports task-based invocation.\n\nWhen not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.\nWhen set to `Optional`, clients MAY invoke this tool as a task or normal call.\nWhen set to `Required`, clients MUST invoke this tool as a task.", - "anyOf": [ - { - "$ref": "#/definitions/TaskSupport" - }, - { - "type": "null" - } - ] - } - } - }, "ToolListChangedNotificationMethod": { "type": "string", "format": "const", @@ -4191,18 +3980,6 @@ } } }, - "ToolsTaskCapability": { - "type": "object", - "properties": { - "call": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - } - } - }, "UntitledItems": { "description": "Items for untitled multi-select options", "type": "object", diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 6f9d6604b..ea1a2595e 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -1,119 +1,423 @@ -use std::{any::Any, time::Duration}; +//! End-to-end tests for the MCP Tasks extension (SEP-2663, +//! `io.modelcontextprotocol/tasks`). +#![cfg(all(feature = "server", feature = "client", not(feature = "local")))] use rmcp::{ - model::TaskStatusNotificationParam, - task_manager::{ - OperationDescriptor, OperationMessage, OperationProcessor, OperationResultTransport, - }, + ErrorData as McpError, ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::*, + service::{RequestContext, RoleServer}, + task_manager::{TaskExit, TaskManager, TaskOptions}, + tool, tool_router, }; use serde_json::json; -struct DummyTransport { - id: String, - value: u32, +#[derive(Debug, serde::Deserialize, rmcp::schemars::JsonSchema)] +pub struct SumArgs { + pub a: i32, + pub b: i32, } -impl OperationResultTransport for DummyTransport { - fn operation_id(&self) -> &String { - &self.id +#[derive(Clone)] +struct TaskServer { + tool_router: ToolRouter, + tasks: TaskManager, +} + +#[tool_router] +impl TaskServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + tasks: TaskManager::new(), + } + } + + #[tool(description = "Sum two numbers")] + async fn sum( + &self, + Parameters(SumArgs { a, b }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::text( + (a + b).to_string(), + )])) + } +} + +impl ServerHandler for TaskServer { + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let client_supports_tasks = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + + if request.name == "sum" && client_supports_tasks { + let args: SumArgs = serde_json::from_value(serde_json::Value::Object( + request.arguments.clone().unwrap_or_default(), + )) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let task = self + .tasks + .spawn(TaskOptions::new().with_poll_interval_ms(10), move |ctx| { + Box::pin(async move { + tokio::select! { + _ = ctx.cancelled() => { + Err(TaskExit::Cancelled) + } + _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { + Ok(CallToolResult::success(vec![ContentBlock::text( + (args.a + args.b).to_string(), + )])) + } + } + }) + }); + return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); + } + + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + self.tool_router.call(tcc).await + } + + async fn get_task( + &self, + request: GetTaskParams, + _context: RequestContext, + ) -> Result { + Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) + } + + async fn update_task( + &self, + request: UpdateTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks + .update_task(&request.task_id, request.input_responses) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks.cancel_task(&request.task_id) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks() + .build(), + ) } +} + +fn tasks_client_info() -> ClientInfo { + ClientInfo::new( + ClientCapabilities::builder().enable_tasks().build(), + Implementation::from_build_env(), + ) +} + +#[tokio::test] +async fn task_lifecycle_create_poll_complete() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + let client = tasks_client_info().serve(client_transport).await.unwrap(); + + // Server materializes a task because we declared the extension. + let response = client + .call_tool_once( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 40, "b": 2})).unwrap()), + ) + .await + .unwrap(); + let create = match response { + CallToolResponse::Task(create) => create, + other => panic!("expected CreateTaskResult, got {other:?}"), + }; + assert_eq!(create.result_type, ResultType::TASK); + let task_id = create.task.task_id.clone(); + + // Poll until terminal. + let final_task = loop { + tokio::time::sleep(std::time::Duration::from_millis( + create.task.poll_interval_ms.unwrap_or(10), + )) + .await; + let info = client + .peer() + .get_task(GetTaskParams::new(task_id.clone())) + .await + .unwrap(); + if info.task.status().is_terminal() { + break info.task; + } + }; + + match final_task.payload { + TaskPayload::Completed { result } => { + let result: CallToolResult = + serde_json::from_value(serde_json::Value::Object(result)).unwrap(); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "42"); + } + other => panic!("expected completed task, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn task_cancel_acknowledged() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + let client = tasks_client_info().serve(client_transport).await.unwrap(); - fn as_any(&self) -> &dyn Any { - self + let response = client + .call_tool_once( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 1, "b": 1})).unwrap()), + ) + .await + .unwrap(); + let create = match response { + CallToolResponse::Task(create) => create, + other => panic!("expected CreateTaskResult, got {other:?}"), + }; + + client + .peer() + .cancel_task(CancelTaskParams::new(create.task.task_id.clone())) + .await + .unwrap(); + + // Cancellation is cooperative (SEP-2663): the ack is immediate, and the + // operation settles the terminal status; poll until it does. + let mut final_status = None; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let info = client + .peer() + .get_task(GetTaskParams::new(create.task.task_id.clone())) + .await + .unwrap(); + if info.task.status().is_terminal() { + final_status = Some(info.task.status()); + break; + } } + assert_eq!(final_status, Some(TaskStatus::Cancelled)); + + client.cancel().await.unwrap(); + server.abort(); } #[tokio::test] -async fn executes_enqueued_future() { - let mut processor = OperationProcessor::new(); - let descriptor = OperationDescriptor::new("op1", "dummy"); - let future = Box::pin(async { - tokio::time::sleep(Duration::from_millis(10)).await; - Ok(Box::new(DummyTransport { - id: "op1".to_string(), - value: 42, - }) as Box) +async fn no_task_without_extension_capability() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) }); - processor - .submit_operation(OperationMessage::new(descriptor, future)) - .expect("submit operation"); - - tokio::time::sleep(Duration::from_millis(30)).await; - let results = processor.peek_completed(); - assert_eq!(results.len(), 1); - let payload = results[0] - .result - .as_ref() - .unwrap() - .as_any() - .downcast_ref::() + // Plain client: no tasks extension declared. + let client = ().serve(client_transport).await.unwrap(); + let result = client + .call_tool( + CallToolRequestParams::new("sum") + .with_arguments(serde_json::from_value(json!({"a": 2, "b": 3})).unwrap()), + ) + .await .unwrap(); - assert_eq!(payload.value, 42); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "5"); + + client.cancel().await.unwrap(); + server.abort(); +} + +/// A misbehaving handler that materializes a task without checking the +/// client's capabilities. The SDK dispatch must catch this and reject with +/// -32021 rather than sending a task handle the client cannot parse. +#[derive(Clone)] +struct AlwaysTaskServer { + tasks: TaskManager, +} + +impl ServerHandler for AlwaysTaskServer { + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + let task = self.tasks.spawn(TaskOptions::default(), |_ctx| { + Box::pin(async { Ok(CallToolResult::success(vec![ContentBlock::text("late")])) }) + }); + Ok(CallToolResponse::Task(CreateTaskResult::new(task))) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks() + .build(), + ) + } } #[tokio::test] -async fn rejects_duplicate_operation_ids() { - let mut processor = OperationProcessor::new(); - let descriptor = OperationDescriptor::new("dup", "dummy"); - let future = Box::pin(async { - Ok(Box::new(DummyTransport { - id: "dup".to_string(), - value: 1, - }) as Box) +async fn dispatch_rejects_task_result_for_non_declaring_client() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = AlwaysTaskServer { + tasks: TaskManager::new(), + } + .serve(server_transport) + .await?; + service.waiting().await?; + anyhow::Ok(()) }); - processor - .submit_operation(OperationMessage::new(descriptor, future)) - .expect("first submit"); - - let descriptor_dup = OperationDescriptor::new("dup", "dummy"); - let future_dup = Box::pin(async { - Ok(Box::new(DummyTransport { - id: "dup".to_string(), - value: 2, - }) as Box) + + // Plain client: no tasks extension declared. The handler tries to return + // a CreateTaskResult anyway; dispatch must reject with -32021. + let client = ().serve(client_transport).await.unwrap(); + let err = client + .call_tool(CallToolRequestParams::new("anything")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + } + other => panic!("expected McpError, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn tasks_methods_without_capability_return_missing_capability_error() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) }); - let err = processor - .submit_operation(OperationMessage::new(descriptor_dup, future_dup)) - .expect_err("duplicate should fail"); - assert!(format!("{err}").contains("already running")); + // Plain client: no tasks extension declared. tasks/* must be rejected + // with -32021 Missing Required Client Capability (SEP-2663), not -32601. + let client = ().serve(client_transport).await.unwrap(); + let err = client + .peer() + .get_task(GetTaskParams::new("whatever")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + let data = e.data.expect("error data should be present"); + assert!( + data["requiredCapabilities"]["extensions"] + .as_object() + .is_some_and(|ext| ext.contains_key("io.modelcontextprotocol/tasks")), + "error data should name the tasks extension: {data}" + ); + } + other => panic!("expected McpError, got {other:?}"), + } + + let err = client + .peer() + .cancel_task(CancelTaskParams::new("whatever")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); + } + other => panic!("expected McpError, got {other:?}"), + } + + client.cancel().await.unwrap(); + server.abort(); } #[tokio::test] -async fn ttl_is_interpreted_as_milliseconds() { - let mut processor = OperationProcessor::new(); - let descriptor = OperationDescriptor::new("slow", "dummy").with_ttl(50); - let future = Box::pin(async { - tokio::time::sleep(Duration::from_millis(500)).await; - Ok(Box::new(DummyTransport { - id: "slow".to_string(), - value: 0, - }) as Box) +async fn unknown_task_id_returns_invalid_params() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) }); - processor - .submit_operation(OperationMessage::new(descriptor, future)) - .expect("submit operation"); - - tokio::time::sleep(Duration::from_millis(200)).await; - let results = processor.peek_completed(); - assert_eq!( - results.len(), - 1, - "50ms ttl should have timed out the operation well within 200ms" - ); - match &results[0].result { - Err(err) => assert!( - err.to_string().contains("timed out"), - "unexpected error: {err}" - ), - Ok(_) => panic!("expected the operation to time out, but it completed"), + let client = tasks_client_info().serve(client_transport).await.unwrap(); + let err = client + .peer() + .get_task(GetTaskParams::new("no-such-task")) + .await + .unwrap_err(); + match err { + rmcp::ServiceError::McpError(e) => { + // SEP-2663: unknown taskId is -32602 Invalid params. + assert_eq!(e.code, ErrorCode::INVALID_PARAMS); + } + other => panic!("expected McpError, got {other:?}"), } + + client.cancel().await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn legacy_task_param_is_ignored() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let service = TaskServer::new().serve(server_transport).await?; + service.waiting().await?; + anyhow::Ok(()) + }); + + // Plain client sending a legacy 2025-11-25 `task: {...}` param: it must + // be silently ignored and the call answered synchronously (SEP-2663). + let client = ().serve(client_transport).await.unwrap(); + let params: CallToolRequestParams = serde_json::from_value(json!({ + "name": "sum", + "arguments": {"a": 2, "b": 3}, + "task": {"ttl": 60000} + })) + .expect("legacy task param must not break deserialization"); + let result = client.call_tool(params).await.unwrap(); + let text = result.content[0].as_text().unwrap(); + assert_eq!(text.text, "5"); + + client.cancel().await.unwrap(); + server.abort(); } #[test] -fn task_status_notification_param_preserves_meta() { +fn task_status_notification_params_preserve_meta() { let raw = json!({ "_meta": { "traceId": "trace-1" @@ -122,17 +426,17 @@ fn task_status_notification_param_preserves_meta() { "status": "working", "createdAt": "2026-06-24T00:00:00Z", "lastUpdatedAt": "2026-06-24T00:00:01Z", - "ttl": null + "ttlMs": null }); - let params: TaskStatusNotificationParam = serde_json::from_value(raw).unwrap(); + let params: TaskStatusNotificationParams = serde_json::from_value(raw).unwrap(); - assert_eq!(params.task.task_id, "task-1"); - assert_eq!(params.task_id, "task-1"); + assert_eq!(params.task.task.task_id, "task-1"); + assert_eq!(params.status(), TaskStatus::Working); assert_eq!(params.meta.as_ref().unwrap().0["traceId"], json!("trace-1")); let serialized = serde_json::to_value(¶ms).unwrap(); - assert_eq!(serialized["_meta"]["traceId"], json!("trace-1")); assert_eq!(serialized["taskId"], json!("task-1")); + assert_eq!(serialized["ttlMs"], serde_json::Value::Null); } diff --git a/crates/rmcp/tests/test_task_support_validation.rs b/crates/rmcp/tests/test_task_support_validation.rs deleted file mode 100644 index 41d03f031..000000000 --- a/crates/rmcp/tests/test_task_support_validation.rs +++ /dev/null @@ -1,251 +0,0 @@ -#![cfg(not(feature = "local"))] -//! Tests for task support validation in tool calls. -//! -//! Verifies that the server correctly validates `execution.taskSupport` settings -//! per the MCP specification: -//! - `Required`: MUST be invoked as a task, returns -32601 otherwise -//! - `Forbidden`: MUST NOT be invoked as a task, returns error otherwise -//! - `Optional`: MAY be invoked either way -#![cfg(feature = "client")] - -use rmcp::{ - ClientHandler, ServerHandler, ServiceError, ServiceExt, - handler::server::router::tool::ToolRouter, - model::{CallToolRequestParams, ClientInfo, ErrorCode, TaskMetadata}, - tool, tool_handler, tool_router, -}; - -/// Server with tools having different task support modes. -#[derive(Debug, Clone)] -pub struct TaskSupportTestServer { - #[expect(dead_code, reason = "tool_handler macro accesses this router field")] - tool_router: ToolRouter, -} - -impl Default for TaskSupportTestServer { - fn default() -> Self { - Self::new() - } -} - -impl TaskSupportTestServer { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } - } -} - -#[tool_router] -impl TaskSupportTestServer { - #[tool( - description = "Tool that requires task-based invocation", - execution(task_support = "required") - )] - async fn required_task_tool(&self) -> String { - "required task executed".to_string() - } - - #[tool( - description = "Tool that forbids task-based invocation", - execution(task_support = "forbidden") - )] - async fn forbidden_task_tool(&self) -> String { - "forbidden task executed".to_string() - } - - #[tool( - description = "Tool that optionally supports task-based invocation", - execution(task_support = "optional") - )] - async fn optional_task_tool(&self) -> String { - "optional task executed".to_string() - } -} - -#[tool_handler] -impl ServerHandler for TaskSupportTestServer {} - -#[derive(Debug, Clone, Default)] -struct DummyClientHandler {} - -impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientInfo { - ClientInfo::default() - } -} - -/// Helper to create a task object for tool calls -fn make_task() -> TaskMetadata { - TaskMetadata::new() -} - -#[tokio::test] -async fn test_required_task_tool_without_task_returns_method_not_found() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server = TaskSupportTestServer::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Call the task-required tool without a task - should fail with -32601 - let result = client - .call_tool(CallToolRequestParams::new("required_task_tool")) - .await; - - // Should be an error with code -32601 (METHOD_NOT_FOUND) - assert!( - result.is_err(), - "Expected error for required task tool without task" - ); - let error = result.unwrap_err(); - - // Check the error data contains the expected code - match error { - ServiceError::McpError(error_data) => { - assert_eq!( - error_data.code, - ErrorCode::METHOD_NOT_FOUND, - "Expected METHOD_NOT_FOUND error code (-32601)" - ); - assert!( - error_data - .message - .contains("requires task-based invocation"), - "Error message should indicate task-based invocation is required, got: {}", - error_data.message - ); - } - _ => panic!("Expected McpError variant, got: {:?}", error), - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_forbidden_task_tool_with_task_returns_error() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server = TaskSupportTestServer::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Call the forbidden task tool WITH a task - should fail - let result = client - .call_tool(CallToolRequestParams::new("forbidden_task_tool").with_task(make_task())) - .await; - - // Should be an error with code INVALID_PARAMS - assert!( - result.is_err(), - "Expected error for forbidden task tool with task" - ); - let error = result.unwrap_err(); - - // Check the error data contains the expected code - match error { - ServiceError::McpError(error_data) => { - assert_eq!( - error_data.code, - ErrorCode::INVALID_PARAMS, - "Expected INVALID_PARAMS error code" - ); - assert!( - error_data - .message - .contains("does not support task-based invocation"), - "Error message should indicate task-based invocation is not supported, got: {}", - error_data.message - ); - } - _ => panic!("Expected McpError variant, got: {:?}", error), - } - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_forbidden_task_tool_without_task_succeeds() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server = TaskSupportTestServer::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Call the forbidden task tool WITHOUT a task - should succeed - let result = client - .call_tool(CallToolRequestParams::new("forbidden_task_tool")) - .await; - - assert!( - result.is_ok(), - "Forbidden task tool without task should succeed" - ); - let result = result.unwrap(); - let text = result - .content - .first() - .and_then(|c| c.as_text()) - .map(|t| t.text.as_str()) - .unwrap_or(""); - assert_eq!(text, "forbidden task executed"); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} - -#[tokio::test] -async fn test_optional_task_tool_without_task_succeeds() -> anyhow::Result<()> { - let (server_transport, client_transport) = tokio::io::duplex(4096); - - let server = TaskSupportTestServer::new(); - let server_handle = tokio::spawn(async move { - server.serve(server_transport).await?.waiting().await?; - anyhow::Ok(()) - }); - - let client_handler = DummyClientHandler::default(); - let client = client_handler.serve(client_transport).await?; - - // Call the optional task tool WITHOUT a task - should succeed - let result = client - .call_tool(CallToolRequestParams::new("optional_task_tool")) - .await; - - assert!( - result.is_ok(), - "Optional task tool without task should succeed" - ); - let result = result.unwrap(); - let text = result - .content - .first() - .and_then(|c| c.as_text()) - .map(|t| t.text.as_str()) - .unwrap_or(""); - assert_eq!(text, "optional task executed"); - - client.cancel().await?; - server_handle.await??; - Ok(()) -} diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index 89846fa79..9b9530aa8 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -397,8 +397,8 @@ fn test_minimal_server_get_info_auto_generated() { "prompts should not be auto-enabled" ); assert!( - info.capabilities.tasks.is_none(), - "tasks should not be auto-enabled" + !info.capabilities.supports_tasks(), + "tasks extension should not be auto-enabled" ); assert!( !info.server_info.name.is_empty(), diff --git a/examples/clients/README.md b/examples/clients/README.md index f082a4926..76aa97389 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -69,12 +69,13 @@ A client demonstrating how to use the sampling tool. ### Task Standard I/O Client (`task_stdio.rs`) -A client that exercises the task lifecycle against `servers_task_stdio` -(per [SEP-1319](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)). +A client that exercises the SEP-2663 Tasks extension lifecycle against `servers_task_stdio` +([SEP-2663](https://modelcontextprotocol.io/extensions/tasks/overview), `io.modelcontextprotocol/tasks`). - Spawns `servers_task_stdio` as a child process over stdio +- Declares the tasks extension in its client capabilities - Calls `quick_echo` synchronously -- Calls `slow_sum` as a task via `CallToolRequestParams::with_task(...)`, polls `tasks/get` until completion, then fetches the result via `tasks/result` +- Calls `slow_sum`, receives a `CreateTaskResult` (`resultType: "task"`), polls `tasks/get` honoring `pollIntervalMs`, and reads the final `CallToolResult` inlined in the completed task ### Progress Test Client (`progress_client.rs`) diff --git a/examples/clients/src/task_stdio.rs b/examples/clients/src/task_stdio.rs index ffcc0dc22..465cb7062 100644 --- a/examples/clients/src/task_stdio.rs +++ b/examples/clients/src/task_stdio.rs @@ -1,18 +1,20 @@ //! Client for the task-demo server in `examples/servers/src/task_stdio.rs`. //! -//! Walks through the task lifecycle (SEP-1319): +//! Walks through the SEP-2663 Tasks extension lifecycle: //! 1. Call a regular tool (`quick_echo`) — synchronous response. -//! 2. Call a task-required tool (`slow_sum`) by attaching `task: {}` to -//! the `tools/call` request. The server returns a `Task` with a `task_id`. -//! 3. Poll `tasks/get` until status becomes `Completed`. -//! 4. Fetch the underlying `CallToolResult` via `tasks/result`. +//! 2. Call `slow_sum` while declaring the `io.modelcontextprotocol/tasks` +//! extension capability. The server decides to materialize a task and +//! returns a `CreateTaskResult` (`resultType: "task"`). +//! 3. Poll `tasks/get` (honoring `pollIntervalMs`) until the task reaches a +//! terminal status; the final `CallToolResult` is inlined in the +//! `completed` task's `result` field. use anyhow::{Result, anyhow}; use rmcp::{ ServiceExt, model::{ - CallToolRequestParams, CallToolResult, ClientRequest, GetTaskParams, GetTaskPayloadParams, - Request, ServerResult, TaskMetadata, TaskStatus, + CallToolRequestParams, CallToolResponse, CallToolResult, ClientCapabilities, GetTaskParams, + TaskPayload, TaskStatus, }, object, transport::{ConfigureCommandExt, TokioChildProcess}, @@ -30,8 +32,14 @@ async fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); + // Declare the tasks extension in our client capabilities (SEP-2663). + let client_info = rmcp::model::ClientInfo::new( + ClientCapabilities::builder().enable_tasks().build(), + rmcp::model::Implementation::from_build_env(), + ); + // Spawn the task-demo server as a child process over stdio. - let client = () + let client = client_info .serve(TokioChildProcess::new(Command::new("cargo").configure( |cmd| { cmd.arg("run") @@ -44,7 +52,7 @@ async fn main() -> Result<()> { ))?) .await?; - // 1) Synchronous call. `quick_echo` has the default task_support = forbidden. + // 1) Synchronous call. let echo = client .call_tool( CallToolRequestParams::new("quick_echo") @@ -53,68 +61,63 @@ async fn main() -> Result<()> { .await?; tracing::info!("quick_echo -> {echo:#?}"); - // 2) Task call. `slow_sum` is task_support = required, so we MUST attach - // `task` metadata. An empty `TaskMetadata` is fine; use `.with_ttl(...)` - // to set a retention window. - let create = client - .send_request(ClientRequest::CallToolRequest(Request::new( - CallToolRequestParams::new("slow_sum") - .with_arguments(object!({ "a": 40, "b": 2 })) - .with_task(TaskMetadata::new()), - ))) + // 2) Task-eligible call. The server sees our tasks capability and + // materializes a task instead of blocking. + let response = client + .call_tool_once( + CallToolRequestParams::new("slow_sum").with_arguments(object!({ "a": 40, "b": 2 })), + ) .await?; - let ServerResult::CreateTaskResult(create) = create else { - return Err(anyhow!("expected CreateTaskResult, got {create:?}")); + let create = match response { + CallToolResponse::Task(create) => create, + CallToolResponse::Complete(result) => { + // The server is allowed to answer synchronously. + tracing::info!("slow_sum answered synchronously -> {result:#?}"); + client.cancel().await?; + return Ok(()); + } + other => return Err(anyhow!("unexpected response: {other:?}")), }; let task_id = create.task.task_id.clone(); + let poll_ms = create.task.poll_interval_ms.unwrap_or(500); tracing::info!( - "slow_sum enqueued as task {task_id} (status = {:?})", + "slow_sum materialized as task {task_id} (status = {:?})", create.task.status ); - // 3) Poll `tasks/get` until the server reports a terminal status. - let final_status = loop { - tokio::time::sleep(std::time::Duration::from_millis(250)).await; + // 3) Poll `tasks/get` until the task reaches a terminal status. + let final_task = loop { + tokio::time::sleep(std::time::Duration::from_millis(poll_ms)).await; let info = client - .send_request(ClientRequest::GetTaskRequest(Request::new( - GetTaskParams::new(task_id.clone()), - ))) + .peer() + .get_task(GetTaskParams::new(task_id.clone())) .await?; - let ServerResult::GetTaskResult(info) = info else { - return Err(anyhow!("expected GetTaskResult, got {info:?}")); - }; - tracing::info!("status = {:?}", info.task.status); + tracing::info!("status = {:?}", info.task.status()); - match info.task.status { - TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => { - break info.task.status; - } - _ => {} + if info.task.status().is_terminal() { + break info.task; } }; - if final_status != TaskStatus::Completed { - return Err(anyhow!("task ended in {final_status:?}")); + // The completed task carries the final CallToolResult inline. + match &final_task.payload { + TaskPayload::Completed { result } => { + let call_result: CallToolResult = + serde_json::from_value(serde_json::Value::Object(result.clone()))?; + tracing::info!("slow_sum result -> {call_result:#?}"); + } + TaskPayload::Failed { error } => { + return Err(anyhow!("task failed: {error:?}")); + } + other => { + return Err(anyhow!( + "task ended in unexpected state {:?}", + other.status() + )); + } } - - // 4) Fetch the payload. The server-side handler returns a serialized - // `CallToolResult`. On the wire the response is just a JSON value, and - // `ServerResult` is `#[serde(untagged)]`, so the client decodes it as - // whichever variant the JSON shape matches first — a `CallToolResult` - // here. (For a non-tool task the same value would surface as - // `ServerResult::CustomResult` and need manual `serde_json::from_value`.) - let payload = client - .send_request(ClientRequest::GetTaskPayloadRequest(Request::new( - GetTaskPayloadParams::new(task_id.clone()), - ))) - .await?; - let call_result: CallToolResult = match payload { - ServerResult::CallToolResult(r) => r, - ServerResult::CustomResult(c) => serde_json::from_value(c.0)?, - other => return Err(anyhow!("unexpected task result: {other:?}")), - }; - tracing::info!("slow_sum result -> {call_result:#?}"); + debug_assert_eq!(final_task.status(), TaskStatus::Completed); client.cancel().await?; Ok(()) diff --git a/examples/servers/README.md b/examples/servers/README.md index 5fa5011ec..86bf3a35e 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -71,13 +71,13 @@ A server demonstrating the prompt framework capabilities. ### Task Demo Server (`task_stdio.rs`) -A minimal stdio server demonstrating task-based tool invocation per -[SEP-1319](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks). +A minimal stdio server demonstrating the MCP Tasks extension +([SEP-2663](https://modelcontextprotocol.io/extensions/tasks/overview), `io.modelcontextprotocol/tasks`). -- `slow_sum` is declared with `execution(task_support = "required")`, so clients MUST invoke it as a task +- `slow_sum` is materialized as a task (`CreateTaskResult`, `resultType: "task"`) whenever the client declares the tasks extension capability; other clients get a normal synchronous response - `quick_echo` is a regular synchronous tool for contrast -- Wires up `enqueue_task` / `tasks/get` / `tasks/result` / `tasks/cancel` via `#[task_handler]` -- Pair with `examples/clients/src/task_stdio.rs` to see the full lifecycle (create → poll → fetch result) +- Serves `tasks/get` / `tasks/update` / `tasks/cancel` via a `TaskManager` +- Pair with `examples/clients/src/task_stdio.rs` to see the full lifecycle (create → poll → inline result) ### MRTR Demo (`mrtr.rs`) diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index 3cac2b2ba..c6602770f 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -1,5 +1,5 @@ #![allow(dead_code)] -use std::{any::Any, sync::Arc}; +use std::sync::Arc; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, @@ -10,28 +10,11 @@ use rmcp::{ model::*, prompt, prompt_handler, prompt_router, schemars, service::RequestContext, - task_handler, - task_manager::{OperationProcessor, OperationResultTransport}, tool, tool_handler, tool_router, }; use serde_json::json; use tokio::sync::Mutex; -struct ToolCallOperationResult { - id: String, - result: Result, -} - -impl OperationResultTransport for ToolCallOperationResult { - fn operation_id(&self) -> &String { - &self.id - } - - fn as_any(&self) -> &dyn Any { - self - } -} - #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct StructRequest { pub a: i32, @@ -78,7 +61,6 @@ pub struct Counter { counter: Arc>, tool_router: ToolRouter, prompt_router: PromptRouter, - processor: Arc>, } #[tool_router] @@ -89,7 +71,6 @@ impl Counter { counter: Arc::new(Mutex::new(0)), tool_router: Self::tool_router(), prompt_router: Self::prompt_router(), - processor: Arc::new(Mutex::new(OperationProcessor::new())), } } @@ -123,10 +104,7 @@ impl Counter { )])) } - #[tool( - description = "Long running task example", - execution(task_support = "optional") - )] + #[tool(description = "Long running task example")] async fn long_task(&self) -> Result { tokio::time::sleep(std::time::Duration::from_secs(10)).await; Ok(CallToolResult::success(vec![ContentBlock::text( @@ -227,7 +205,6 @@ impl Counter { #[tool_handler(meta = MetaObject(rmcp::object!({"tool_meta_key": "tool_meta_value"})))] #[prompt_handler(meta = MetaObject(rmcp::object!({"router_meta_key": "router_meta_value"})))] -#[task_handler] impl ServerHandler for Counter { fn get_info(&self) -> ServerInfo { ServerInfo::new( @@ -380,49 +357,4 @@ mod tests { let prompts = router.list_all(); assert_eq!(prompts.len(), 2); } - - #[tokio::test] - async fn test_client_enqueues_long_task() -> anyhow::Result<()> { - let counter = Counter::new(); - let processor = counter.processor.clone(); - let client = TestClient::default(); - - let (server_transport, client_transport) = tokio::io::duplex(4096); - let server_handle = tokio::spawn(async move { - let service = counter.serve(server_transport).await?; - service.waiting().await?; - anyhow::Ok(()) - }); - - let client_service = client.serve(client_transport).await?; - let params = CallToolRequestParams::new("long_task").with_task(TaskMetadata::new()); - let response = client_service - .send_request(ClientRequest::CallToolRequest(Request::new(params.clone()))) - .await?; - - let ServerResult::CreateTaskResult(info) = response else { - panic!("expected task creation result, got {response:?}"); - }; - let task = info.task; - - assert_eq!(task.status, TaskStatus::Working); - // task list should show the task - let tasks = client_service - .send_request(ClientRequest::ListTasksRequest( - RequestOptionalParam::default(), - )) - .await - .unwrap(); - let ServerResult::ListTasksResult(listed) = tasks else { - panic!("expected list tasks result, got {tasks:?}"); - }; - assert_eq!(listed.tasks[0].task_id, task.task_id); - tokio::time::sleep(Duration::from_millis(50)).await; - let running = processor.lock().await.running_task_count(); - assert_eq!(running, 1); - - client_service.cancel().await?; - let _ = server_handle.await; - Ok(()) - } } diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs index 275047a55..4d8d53acf 100644 --- a/examples/servers/src/common/task_demo.rs +++ b/examples/servers/src/common/task_demo.rs @@ -1,27 +1,26 @@ -//! Minimal example of a tool that supports task-based invocation (SEP-1319). +//! Minimal example of a server that supports the MCP Tasks extension +//! (SEP-2663, `io.modelcontextprotocol/tasks`). //! -//! - `slow_sum` is marked `task_support = "required"`, so the client MUST invoke -//! it as a task. The server enqueues the call into an `OperationProcessor`, -//! returns a task id immediately, and the client polls `tasks/get` and -//! fetches the payload via `tasks/result`. -//! - `quick_echo` is a regular synchronous tool for contrast (the default, -//! `task_support = "forbidden"`). +//! - `slow_sum` is executed as a task whenever the client declares the tasks +//! extension capability: the server returns a `CreateTaskResult` +//! (`resultType: "task"`) immediately and the client polls `tasks/get`. +//! Clients that do not declare the extension get a normal synchronous +//! response. +//! - `quick_echo` is a regular synchronous tool for contrast. //! //! See `examples/clients/src/task_stdio.rs` for the matching client. #![allow(dead_code)] -use std::sync::Arc; - use rmcp::{ ErrorData as McpError, ServerHandler, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolResult, ContentBlock}, - schemars, task_handler, - task_manager::OperationProcessor, - tool, tool_handler, tool_router, + model::*, + schemars, + service::{RequestContext, RoleServer}, + task_manager::{TaskExit, TaskManager, TaskOptions}, + tool, tool_router, }; -use tokio::sync::Mutex; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct SumArgs { @@ -34,13 +33,10 @@ pub struct EchoArgs { pub message: String, } -/// Server state. The `processor` field is required by `#[task_handler]`: -/// the macro generates `enqueue_task` / `tasks/*` handlers that submit and -/// poll operations through it. #[derive(Clone)] pub struct TaskDemo { tool_router: ToolRouter, - processor: Arc>, + tasks: TaskManager, } impl Default for TaskDemo { @@ -54,17 +50,12 @@ impl TaskDemo { pub fn new() -> Self { Self { tool_router: Self::tool_router(), - processor: Arc::new(Mutex::new(OperationProcessor::new())), + tasks: TaskManager::new(), } } - /// Long-running tool. The `execution(task_support = "required")` attribute - /// tells clients they MUST call this tool as a task; the server returns - /// `-32601` if they don't. - #[tool( - description = "Sum two numbers after a 2-second delay", - execution(task_support = "required") - )] + /// Long-running tool. Run as a task when the client supports tasks. + #[tool(description = "Sum two numbers after a 2-second delay")] async fn slow_sum( &self, Parameters(SumArgs { a, b }): Parameters, @@ -75,7 +66,7 @@ impl TaskDemo { )])) } - /// Synchronous tool with the default `task_support = "forbidden"`. + /// Synchronous tool. #[tool(description = "Echo a message back immediately")] async fn quick_echo( &self, @@ -85,9 +76,92 @@ impl TaskDemo { } } -/// `#[task_handler]` reads `self.processor` (configurable via the macro's -/// `processor = ...` argument) and synthesizes `enqueue_task`, `list_tasks`, -/// `get_task_info`, `get_task_result`, and `cancel_task` for us. -#[tool_handler] -#[task_handler] -impl ServerHandler for TaskDemo {} +impl ServerHandler for TaskDemo { + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + // SEP-2663: the server decides per-request whether to materialize a + // task, but MUST NOT return one unless the request declared the tasks + // extension capability. + let client_supports_tasks = context + .client_capabilities() + .is_some_and(|caps| caps.supports_tasks()); + + if request.name == "slow_sum" && client_supports_tasks { + let params: SumArgs = serde_json::from_value(serde_json::Value::Object( + request.arguments.clone().unwrap_or_default(), + )) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let task = self.tasks.spawn(TaskOptions::default(), move |ctx| { + Box::pin(async move { + // Cancellation is cooperative (SEP-2663): honor + // tasks/cancel by exiting with TaskExit::Cancelled. + tokio::select! { + _ = ctx.cancelled() => { + Err(TaskExit::Cancelled) + } + _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => { + Ok(CallToolResult::success(vec![ContentBlock::text( + (params.a + params.b).to_string(), + )])) + } + } + }) + }); + return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); + } + + // Fall back to synchronous execution via the tool router. + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + self.tool_router.call(tcc).await + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(self.tool_router.list_all())) + } + + async fn get_task( + &self, + request: GetTaskParams, + _context: RequestContext, + ) -> Result { + Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) + } + + async fn update_task( + &self, + request: UpdateTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks + .update_task(&request.task_id, request.input_responses) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _context: RequestContext, + ) -> Result<(), McpError> { + self.tasks.cancel_task(&request.task_id) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks() + .build(), + ) + .with_instructions( + "Task demo server (SEP-2663). `slow_sum` runs as a task for \ + clients that declare the tasks extension." + .to_string(), + ) + } +} From 151970775cca27a9386a759d1c9bf3cd47b87338 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 22 Jul 2026 16:41:48 -0400 Subject: [PATCH 261/333] feat: add client-side TTL-honoring response cache (SEP-2549) (#1025) * feat: add client-side TTL-honoring response cache (SEP-2549) Add a configurable client response cache in the service layer that honors SEP-2549 caching hints (ttlMs / cacheScope) for tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. Closes #974 Co-Authored-by: John-Francis Nnadi * fix: address PR feedback --------- Co-authored-by: John-Francis Nnadi --- README.md | 47 ++ crates/rmcp/src/lib.rs | 4 +- crates/rmcp/src/service.rs | 20 + crates/rmcp/src/service/client.rs | 579 ++++++++++++++++++++++-- crates/rmcp/src/service/client/cache.rs | 401 ++++++++++++++++ 5 files changed, 1022 insertions(+), 29 deletions(-) create mode 100644 crates/rmcp/src/service/client/cache.rs diff --git a/README.md b/README.md index 13219440f..57f69c5bb 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ For the full MCP specification, see [modelcontextprotocol.io](https://modelconte - [Notifications](#notifications) - [Subscriptions](#subscriptions) - [Tasks](#tasks-long-running-tool-invocations) +- [Caching](#caching) - [Examples](#examples) - [OAuth Support](#oauth-support) - [Related Resources](#related-resources) @@ -1003,6 +1004,52 @@ async fn call_tool(&self, request: CallToolRequestParams, context: RequestContex See [`servers_task_stdio`](examples/servers/src/task_stdio.rs) and the matching [`clients_task_stdio`](examples/clients/src/task_stdio.rs) for a runnable end-to-end example. +## Caching + +`rmcp` clients transparently cache responses that carry the +[SEP-2549](https://modelcontextprotocol.io/specification/draft/server/utilities/caching) +caching hints (`ttlMs` / `cacheScope`) for `server/discover`, `tools/list`, +`prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`. + +Caching is on by default but only stores a response when the server sends a +positive `ttlMs`, so servers that omit the hint behave exactly as before. Entries +expire after their TTL, are partitioned by cache scope, and are invalidated +automatically by the matching `list_changed` / `resource updated` notifications. + +No call-site changes are needed — existing calls benefit automatically: + +```rust, ignore +let tools = peer.list_tools(None).await?; // served from cache while fresh +let res = peer.read_resource(params).await?; // cached per-URI +``` + +Tune or disable it per connection via the `Peer`: + +```rust, ignore +use std::time::Duration; +use rmcp::ClientCacheConfig; + +// Customize behavior. +peer.set_response_cache_config( + ClientCacheConfig::default() + .with_default_ttl(Duration::from_secs(30)) // TTL for servers that omit ttlMs + .with_max_ttl(Duration::from_secs(3600)) // upper bound on any TTL + .with_max_entries(1024) + .with_private_partition(user_id) // separate private caches per principal + .with_serve_stale_on_error(false), // surface errors instead of stale data +).await; + +// Or turn it off entirely. +peer.set_response_cache_config(ClientCacheConfig::disabled()).await; + +// Manually flush. +peer.clear_response_cache().await; +``` + +> **Note:** with the default `serve_stale_on_error`, a failed re-fetch returns the +> last cached response (even if expired) as `Ok(..)` instead of an error. Set +> `with_serve_stale_on_error(false)` if callers must observe fetch failures. + ## Examples See [examples](examples/README.md). diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index ca195a6e8..7c9b7b195 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -18,8 +18,8 @@ pub use handler::server::ServerHandler; pub use handler::server::wrapper::Json; #[cfg(feature = "client")] pub use service::{ - ClientLifecycleMode, ClientServiceExt, RoleClient, select_protocol_version, serve_client, - serve_client_with_lifecycle, + ClientCacheConfig, ClientLifecycleMode, ClientServiceExt, MAX_CLIENT_CACHE_TTL, RoleClient, + select_protocol_version, serve_client, serve_client_with_lifecycle, }; #[cfg(any(feature = "client", feature = "server"))] pub use service::{Peer, Service, ServiceError, ServiceExt}; diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 4eeaeadf4..75f34d30c 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -136,6 +136,19 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { None } + /// Invalidate any response cache affected by an inbound peer notification. + /// + /// The serve loop calls this for every notification *before* subscription + /// routing, so cache invalidation still runs when a notification is + /// delivered through a `listen` subscription channel rather than the + /// [`Service::handle_notification`] callbacks. + #[doc(hidden)] + fn invalidate_response_cache( + _peer: &Peer, + _notification: &Self::PeerNot, + ) -> impl Future + MaybeSendFuture { + async {} + } } pub(crate) fn uses_legacy_lifecycle( @@ -571,6 +584,8 @@ pub struct Peer { client_request_metadata: Arc>, request_metadata_required: Arc, subscription_channels: Arc>>, + #[cfg(feature = "client")] + response_cache: client::cache::PeerResponseCache, } impl Clone for Peer @@ -587,6 +602,8 @@ where client_request_metadata: self.client_request_metadata.clone(), request_metadata_required: self.request_metadata_required.clone(), subscription_channels: self.subscription_channels.clone(), + #[cfg(feature = "client")] + response_cache: self.response_cache.clone(), } } } @@ -661,6 +678,8 @@ impl Peer { client_request_metadata: Default::default(), request_metadata_required: Default::default(), subscription_channels: Default::default(), + #[cfg(feature = "client")] + response_cache: Default::default(), }, rx, ) @@ -1402,6 +1421,7 @@ where .. })) => { tracing::info!(?notification, "received notification"); + R::invalidate_response_cache(&peer, ¬ification).await; let cancellation_request_id = if let Some(cancelled) = R::peer_cancelled_params(¬ification) { let request_id = cancelled.request_id.clone(); diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 88336ed5a..166207b77 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -1,30 +1,35 @@ // Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected. #![expect(deprecated)] +pub(super) mod cache; + use std::{borrow::Cow, num::NonZeroUsize, sync::Arc, time::Duration}; +use cache::CacheGeneration; +pub use cache::{ClientCacheConfig, MAX_CLIENT_CACHE_TTL}; use thiserror::Error; use super::*; use crate::{ model::{ - ArgumentInfo, CallToolRequest, CallToolRequestParams, CallToolResponse, CallToolResult, - CancelTaskParams, CancelTaskRequest, CancelledNotification, CancelledNotificationParam, - ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, - CompleteRequest, CompleteRequestParams, CompleteResult, CompletionContext, CompletionInfo, - DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, - GetExtensions, GetMeta, GetPromptRequest, GetPromptRequestParams, GetPromptResponse, - GetPromptResult, GetTaskParams, GetTaskRequest, GetTaskResult, InitializeRequest, - InitializedNotification, InputRequest, InputRequiredResult, InputResponses, - JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, - ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest, - ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotification, - ProgressNotificationParam, ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, - ReadResourceResponse, ReadResourceResult, Reference, RequestId, RequestMetaObject, - RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification, - ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SubscribeRequest, - SubscribeRequestParams, SubscriptionFilter, SubscriptionsListenRequest, - SubscriptionsListenRequestParams, SubscriptionsListenResult, UnsubscribeRequest, - UnsubscribeRequestParams, UpdateTaskParams, UpdateTaskRequest, + ArgumentInfo, CacheScope, CallToolRequest, CallToolRequestParams, CallToolResponse, + CallToolResult, CancelTaskParams, CancelTaskRequest, CancelledNotification, + CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, + ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, CompleteResult, + CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, + DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, GetPromptRequest, + GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetTaskParams, GetTaskRequest, + GetTaskResult, InitializeRequest, InitializedNotification, InputRequest, + InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, + ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, + ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, + NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, + ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, + ReadResourceResult, Reference, RequestId, RequestMetaObject, RootsListChangedNotification, + ServerInfo, ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, + SetLevelRequest, SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, + SubscriptionFilter, SubscriptionsListenRequest, SubscriptionsListenRequestParams, + SubscriptionsListenResult, UnsubscribeRequest, UnsubscribeRequestParams, UpdateTaskParams, + UpdateTaskRequest, }, transport::DynamicTransportError, }; @@ -207,6 +212,25 @@ impl ServiceRole for RoleClient { _ => None, } } + + async fn invalidate_response_cache(peer: &Peer, notification: &Self::PeerNot) { + match notification { + ServerNotification::ResourceUpdatedNotification(notification) => { + peer.invalidate_resource_read_cache(¬ification.params.uri) + .await; + } + ServerNotification::ResourceListChangedNotification(_) => { + peer.invalidate_resource_list_cache().await; + } + ServerNotification::ToolListChangedNotification(_) => { + peer.invalidate_tool_cache().await; + } + ServerNotification::PromptListChangedNotification(_) => { + peer.invalidate_prompt_cache().await; + } + _ => {} + } + } } pub type ServerSink = Peer; @@ -821,6 +845,52 @@ where } } +const DISCOVER_CACHE_PREFIX: &str = "server/discover:"; +const TOOL_LIST_CACHE_PREFIX: &str = "tools/list:"; +const PROMPT_LIST_CACHE_PREFIX: &str = "prompts/list:"; +const RESOURCE_LIST_CACHE_PREFIX: &str = "resources/list:"; +const RESOURCE_TEMPLATE_LIST_CACHE_PREFIX: &str = "resources/templates/list:"; +const RESOURCE_READ_CACHE_PREFIX: &str = "resources/read:"; + +// Cache keys are built only from the request method plus the parameters that +// affect the result (SEP-2549). Request `_meta` (progress tokens, trace +// context, etc.) does not affect the result, so it is deliberately excluded to +// avoid fragmenting the cache across otherwise-identical requests. +fn discover_cache_key() -> String { + // `server/discover` carries no result-affecting parameters. + DISCOVER_CACHE_PREFIX.to_string() +} + +fn list_response_cache_key(prefix: &str, params: &Option) -> String { + // Only the pagination cursor affects which page is returned. + let cursor = params.as_ref().and_then(|params| params.cursor.as_deref()); + let cursor = + serde_json::to_string(&cursor).expect("serializing a pagination cursor cannot fail"); + format!("{prefix}{cursor}") +} + +fn resource_read_cache_key(params: &ReadResourceRequestParams) -> Option { + // MRTR retries depend on inputs that are not part of the cache key and MUST + // NOT be cached. + if params.input_responses.is_some() || params.request_state.is_some() { + return None; + } + // Only the URI affects the result. + Some(resource_read_cache_prefix_for_uri(¶ms.uri)) +} + +fn resource_read_cache_prefix_for_uri(uri: &str) -> String { + let uri = serde_json::to_string(uri).expect("serializing a resource URI cannot fail"); + format!("{RESOURCE_READ_CACHE_PREFIX}{uri}:") +} + +fn request_uses_cursor(params: &Option) -> bool { + params + .as_ref() + .and_then(|params| params.cursor.as_ref()) + .is_some() +} + macro_rules! method { ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { $(#[$meta])* @@ -1016,17 +1086,80 @@ impl Peer { /// The high-level client currently exposes this peer only after initialization; /// pre-initialization probing is planned as follow-up work. pub async fn discover(&self, meta: RequestMetaObject) -> Result { + let cache_key = discover_cache_key(); + if let Some(ServerResult::DiscoverResult(result)) = self.cached_response(&cache_key).await { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; let mut request = DiscoverRequest::new(DiscoverRequestParams {}); request.extensions.insert(meta); let result = self .send_request(ClientRequest::DiscoverRequest(request)) - .await?; + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if let Some(ServerResult::DiscoverResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; match result { - ServerResult::DiscoverResult(result) => Ok(result), + ServerResult::DiscoverResult(result) => { + self.cache_result( + Some(cache_key), + Some(result.ttl_ms), + Some(result.cache_scope), + generation, + ServerResult::DiscoverResult(result.clone()), + ) + .await; + Ok(result) + } _ => Err(ServiceError::UnexpectedResponse), } } + async fn cache_result( + &self, + cache_key: Option, + ttl_ms: Option, + cache_scope: Option, + generation: CacheGeneration, + result: ServerResult, + ) { + let Some(cache_key) = cache_key else { + return; + }; + self.cache_response_with_generation(cache_key, result, ttl_ms, cache_scope, generation) + .await; + } + + pub(crate) async fn invalidate_tool_cache(&self) { + self.invalidate_cached_responses(TOOL_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_prompt_cache(&self) { + self.invalidate_cached_responses(PROMPT_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_resource_list_cache(&self) { + self.invalidate_cached_responses(RESOURCE_LIST_CACHE_PREFIX) + .await; + self.invalidate_cached_responses(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX) + .await; + } + + pub(crate) async fn invalidate_resource_read_cache(&self, uri: &str) { + self.invalidate_cached_responses(&resource_read_cache_prefix_for_uri(uri)) + .await; + } + /// Send one `tools/call` request and return either a final result or an MRTR /// `InputRequiredResult` without driving any follow-up rounds. pub async fn call_tool_once( @@ -1118,15 +1251,45 @@ impl Peer { &self, params: ReadResourceRequestParams, ) -> Result { + let cache_key = resource_read_cache_key(¶ms); + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ReadResourceResult(result)) = self.cached_response(key).await + { + return Ok(ReadResourceResponse::Complete(result)); + } + + let generation = self.capture_response_cache_generation().await; let result = self .send_request(ClientRequest::ReadResourceRequest(ReadResourceRequest { method: Default::default(), params, extensions: Default::default(), })) - .await?; + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if let Some(key) = cache_key.as_deref() + && let Some(ServerResult::ReadResourceResult(result)) = + self.stale_cached_response(key).await + { + return Ok(ReadResourceResponse::Complete(result)); + } + return Err(error); + } + }; match result { - ServerResult::ReadResourceResult(result) => Ok(ReadResourceResponse::Complete(result)), + ServerResult::ReadResourceResult(result) => { + self.cache_result( + cache_key, + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ReadResourceResult(result.clone()), + ) + .await; + Ok(ReadResourceResponse::Complete(result)) + } ServerResult::InputRequiredResult(result) => { Ok(ReadResourceResponse::InputRequired(result)) } @@ -1143,10 +1306,6 @@ impl Peer { peer_req set_level SetLevelRequest(SetLevelRequestParams) ); method!(peer_req get_prompt GetPromptRequest(GetPromptRequestParams) => GetPromptResult); - method!(peer_req list_prompts ListPromptsRequest(PaginatedRequestParams)? => ListPromptsResult); - method!(peer_req list_resources ListResourcesRequest(PaginatedRequestParams)? => ListResourcesResult); - method!(peer_req list_resource_templates ListResourceTemplatesRequest(PaginatedRequestParams)? => ListResourceTemplatesResult); - method!(peer_req read_resource ReadResourceRequest(ReadResourceRequestParams) => ReadResourceResult); method!( #[deprecated( note = "resources/subscribe is legacy-only; use Peer::listen for protocol version 2026-07-28" @@ -1160,7 +1319,219 @@ impl Peer { peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams) ); method!(peer_req call_tool CallToolRequest(CallToolRequestParams) => CallToolResult); - method!(peer_req list_tools ListToolsRequest(PaginatedRequestParams)? => ListToolsResult); + + pub async fn list_prompts( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(PROMPT_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListPromptsResult(result)) = + self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::ListPromptsRequest(ListPromptsRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_prompt_cache().await; + return Err(error); + } + if let Some(ServerResult::ListPromptsResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::ListPromptsResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ListPromptsResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_resources( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(RESOURCE_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListResourcesResult(result)) = + self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::ListResourcesRequest(ListResourcesRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_cached_responses(RESOURCE_LIST_CACHE_PREFIX) + .await; + return Err(error); + } + if let Some(ServerResult::ListResourcesResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::ListResourcesResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ListResourcesResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_resource_templates( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListResourceTemplatesResult(result)) = + self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::ListResourceTemplatesRequest( + ListResourceTemplatesRequest { + method: Default::default(), + params, + extensions: Default::default(), + }, + )) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_cached_responses(RESOURCE_TEMPLATE_LIST_CACHE_PREFIX) + .await; + return Err(error); + } + if let Some(ServerResult::ListResourceTemplatesResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::ListResourceTemplatesResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ListResourceTemplatesResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn read_resource( + &self, + params: ReadResourceRequestParams, + ) -> Result { + match self.read_resource_once(params).await? { + ReadResourceResponse::Complete(result) => Ok(result), + ReadResourceResponse::InputRequired(_) => Err(ServiceError::UnexpectedResponse), + } + } + + pub async fn list_tools( + &self, + params: Option, + ) -> Result { + let cache_key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + if let Some(ServerResult::ListToolsResult(result)) = self.cached_response(&cache_key).await + { + return Ok(result); + } + let generation = self.capture_response_cache_generation().await; + let uses_cursor = request_uses_cursor(¶ms); + let result = self + .send_request(ClientRequest::ListToolsRequest(ListToolsRequest { + method: Default::default(), + params, + extensions: Default::default(), + })) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + if uses_cursor { + self.invalidate_tool_cache().await; + return Err(error); + } + if let Some(ServerResult::ListToolsResult(result)) = + self.stale_cached_response(&cache_key).await + { + return Ok(result); + } + return Err(error); + } + }; + match result { + ServerResult::ListToolsResult(result) => { + self.cache_result( + Some(cache_key), + result.ttl_ms, + result.cache_scope, + generation, + ServerResult::ListToolsResult(result.clone()), + ) + .await; + Ok(result) + } + _ => Err(ServiceError::UnexpectedResponse), + } + } method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); @@ -1636,3 +2007,157 @@ where )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn disconnected_peer() -> Peer { + let (peer, receiver) = + Peer::::new(Arc::new(AtomicU32RequestIdProvider::default()), None); + drop(receiver); + peer + } + + fn tools_result(ttl_ms: Option, cache_scope: Option) -> ListToolsResult { + let mut result = ListToolsResult::with_all_items(Vec::new()); + result.ttl_ms = ttl_ms; + result.cache_scope = cache_scope; + result + } + + #[tokio::test] + async fn fresh_cached_page_is_served_without_transport_io() { + let peer = disconnected_peer(); + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + let expected = tools_result(Some(5_000), Some(CacheScope::Public)); + peer.cache_response( + key, + ServerResult::ListToolsResult(expected.clone()), + expected.ttl_ms, + expected.cache_scope, + ) + .await; + + assert_eq!(peer.list_tools(params).await.unwrap(), expected); + } + + #[tokio::test] + async fn expired_entry_falls_through_to_the_transport() { + let peer = disconnected_peer(); + peer.set_response_cache_config( + ClientCacheConfig::default().with_serve_stale_on_error(false), + ) + .await; + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(1), Some(CacheScope::Public))), + Some(1), + Some(CacheScope::Public), + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + + assert!(matches!( + peer.list_tools(params).await, + Err(ServiceError::TransportClosed) + )); + } + + #[tokio::test] + async fn private_entries_are_isolated_between_authorization_partitions() { + let peer = disconnected_peer(); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, &None); + + peer.set_response_cache_config( + ClientCacheConfig::default().with_private_partition("auth-a"), + ) + .await; + peer.cache_response( + key.clone(), + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Private))), + Some(5_000), + Some(CacheScope::Private), + ) + .await; + assert!(peer.cached_response(&key).await.is_some()); + + // Switching to a different authorization context must not expose the + // first partition's private entry. + peer.set_response_cache_config( + ClientCacheConfig::default().with_private_partition("auth-b"), + ) + .await; + assert!(peer.cached_response(&key).await.is_none()); + } + + #[tokio::test] + async fn list_change_notification_discards_every_cached_page() { + let peer = disconnected_peer(); + for cursor in [None, Some("page-a".into()), Some("page-b".into())] { + let params = + cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + peer.cache_response( + key, + ServerResult::ListToolsResult(tools_result(Some(5_000), Some(CacheScope::Public))), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + } + + peer.invalidate_tool_cache().await; + + for cursor in [None, Some("page-a".into()), Some("page-b".into())] { + let params = + cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor))); + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + assert!(peer.cached_response(&key).await.is_none()); + } + } + + #[tokio::test] + async fn expired_entry_is_served_when_refetch_fails() { + let peer = disconnected_peer(); + let params = None::; + let key = list_response_cache_key(TOOL_LIST_CACHE_PREFIX, ¶ms); + let expected = tools_result(Some(1), Some(CacheScope::Public)); + peer.cache_response( + key, + ServerResult::ListToolsResult(expected.clone()), + Some(1), + Some(CacheScope::Public), + ) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + + assert_eq!(peer.list_tools(params).await.unwrap(), expected); + } + + #[tokio::test] + async fn discover_serves_a_fresh_cached_response_without_transport_io() { + let peer = disconnected_peer(); + let meta = RequestMetaObject::default(); + let key = discover_cache_key(); + let expected = DiscoverResult::new( + vec![ProtocolVersion::default()], + Default::default(), + crate::model::Implementation::from_build_env(), + ) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Public); + peer.cache_response( + key, + ServerResult::DiscoverResult(expected.clone()), + Some(5_000), + Some(CacheScope::Public), + ) + .await; + + assert_eq!(peer.discover(meta).await.unwrap(), expected); + } +} diff --git a/crates/rmcp/src/service/client/cache.rs b/crates/rmcp/src/service/client/cache.rs new file mode 100644 index 000000000..f7137ccd6 --- /dev/null +++ b/crates/rmcp/src/service/client/cache.rs @@ -0,0 +1,401 @@ +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; + +use super::RoleClient; +use crate::{ + model::CacheScope, + service::{Peer, ServiceRole}, +}; + +/// Maximum server-provided cache TTL honoured by the client response cache. +pub const MAX_CLIENT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Configuration for the built-in MCP client response cache. +/// +/// A cache is allocated per client [`Peer`]. Public responses may be reused +/// throughout that client connection. Private responses are additionally +/// partitioned by `private_partition`; changing the partition drops every +/// private entry while preserving public entries. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ClientCacheConfig { + /// Enables cache reads and writes. + pub enabled: bool, + /// TTL used when a backwards-compatible server omits `ttlMs`. + /// + /// The default is zero, which leaves such responses immediately stale. + pub default_ttl: Duration, + /// Upper bound applied to both server-provided and default TTLs. + pub max_ttl: Duration, + /// Stable opaque identity for the current authorization context. + /// + /// A single-principal client may leave this unset because each client owns + /// its own in-memory store. Gateways or clients that change principals on an + /// existing connection should set this value and update it whenever the + /// authorization context changes. + pub private_partition: Option, + /// Maximum number of responses retained by the in-memory cache. + /// + /// A value of zero disables the size limit. + pub max_entries: usize, + /// Serves an expired cached response when a re-fetch fails. + /// + /// SEP-2549 permits clients to serve stale responses if errors occur while + /// re-fetching (for example, network issues or server downtime). When this + /// is enabled the client retains expired entries so it can fall back to the + /// last known response instead of surfacing the transport or server error. + /// A successful re-fetch always overwrites the stale entry. + pub serve_stale_on_error: bool, +} + +impl Default for ClientCacheConfig { + fn default() -> Self { + Self { + enabled: true, + default_ttl: Duration::ZERO, + max_ttl: MAX_CLIENT_CACHE_TTL, + private_partition: None, + max_entries: 512, + serve_stale_on_error: true, + } + } +} + +impl ClientCacheConfig { + /// Returns a configuration that disables all cache reads and writes. + pub fn disabled() -> Self { + Self { + enabled: false, + ..Self::default() + } + } + + /// Sets the TTL used when a response omits `ttlMs`. + pub fn with_default_ttl(mut self, default_ttl: Duration) -> Self { + self.default_ttl = default_ttl; + self + } + + /// Sets the maximum TTL the client will honour. + pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self { + self.max_ttl = max_ttl; + self + } + + /// Sets the stable partition for private responses. + pub fn with_private_partition(mut self, partition: impl Into) -> Self { + self.private_partition = Some(partition.into()); + self + } + + /// Sets the maximum number of retained responses. + pub fn with_max_entries(mut self, max_entries: usize) -> Self { + self.max_entries = max_entries; + self + } + + /// Controls whether an expired response may be served when a re-fetch fails. + pub fn with_serve_stale_on_error(mut self, serve_stale_on_error: bool) -> Self { + self.serve_stale_on_error = serve_stale_on_error; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum CachePartition { + Public, + Private(Arc), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CacheKey { + logical_key: String, + partition: CachePartition, +} + +#[derive(Debug, Clone)] +struct CachedPeerResponse { + value: T, + expires_at: Instant, + inserted_at: Instant, + scope: CacheScope, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CacheGeneration(u64); + +#[derive(Debug)] +pub(crate) struct PeerResponseCacheState { + entries: HashMap>, + config: ClientCacheConfig, + generation: u64, +} + +impl Default for PeerResponseCacheState { + fn default() -> Self { + Self { + entries: HashMap::new(), + config: ClientCacheConfig::default(), + generation: 0, + } + } +} + +impl PeerResponseCacheState { + fn trim_to_limit(&mut self) { + while self.config.max_entries > 0 && self.entries.len() > self.config.max_entries { + let Some(oldest_key) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + else { + break; + }; + self.entries.remove(&oldest_key); + } + } +} + +pub(crate) type PeerResponseCache = Arc>>; + +impl Peer { + fn private_partition(config: &ClientCacheConfig) -> Arc { + Arc::from(config.private_partition.as_deref().unwrap_or("connection")) + } + + fn cache_key(logical_key: &str, partition: CachePartition) -> CacheKey { + CacheKey { + logical_key: logical_key.to_owned(), + partition, + } + } + + fn scoped_cache_key( + logical_key: &str, + scope: CacheScope, + config: &ClientCacheConfig, + ) -> CacheKey { + let partition = match scope { + CacheScope::Public => CachePartition::Public, + CacheScope::Private => CachePartition::Private(Self::private_partition(config)), + }; + Self::cache_key(logical_key, partition) + } + + /// Captures the cache generation before a request crosses the transport. + /// + /// Any configuration change, explicit clear, or notification invalidation + /// advances the generation. A response from an older generation is not + /// written back, preventing an in-flight stale response from undoing an + /// invalidation. + pub(crate) async fn capture_response_cache_generation(&self) -> CacheGeneration { + CacheGeneration(self.response_cache.read().await.generation) + } + + /// Returns a fresh cached response, preferring the current private partition + /// before the public partition. + /// + /// Expired entries are removed on access unless `serve_stale_on_error` is + /// enabled, in which case they are retained so a later re-fetch failure can + /// fall back to them via [`Peer::stale_cached_response`]. + pub(crate) async fn cached_response(&self, logical_key: &str) -> Option { + let now = Instant::now(); + let mut cache = self.response_cache.write().await; + if !cache.config.enabled { + return None; + } + let keep_stale = cache.config.serve_stale_on_error; + + let private_key = Self::cache_key( + logical_key, + CachePartition::Private(Self::private_partition(&cache.config)), + ); + let private_fresh = cache.entries.get(&private_key).and_then(|entry| { + (entry.expires_at > now && entry.scope == CacheScope::Private) + .then(|| entry.value.clone()) + }); + if let Some(value) = private_fresh { + return Some(value); + } + if !keep_stale { + cache.entries.remove(&private_key); + } + + let public_key = Self::cache_key(logical_key, CachePartition::Public); + let public_fresh = cache.entries.get(&public_key).and_then(|entry| { + (entry.expires_at > now && entry.scope == CacheScope::Public) + .then(|| entry.value.clone()) + }); + if let Some(value) = public_fresh { + return Some(value); + } + if !keep_stale { + cache.entries.remove(&public_key); + } + None + } + + /// Returns a cached response ignoring its TTL, for use as a fallback when a + /// re-fetch fails (SEP-2549 permits serving stale responses on error). + /// + /// Returns `None` when the cache is disabled or `serve_stale_on_error` is + /// turned off. The private partition is preferred over the public one. The + /// entry is left in place so repeated failures keep serving it until a + /// successful re-fetch overwrites it or a notification invalidates it. + pub(crate) async fn stale_cached_response(&self, logical_key: &str) -> Option { + let cache = self.response_cache.read().await; + if !cache.config.enabled || !cache.config.serve_stale_on_error { + return None; + } + + let private_key = Self::cache_key( + logical_key, + CachePartition::Private(Self::private_partition(&cache.config)), + ); + if let Some(entry) = cache.entries.get(&private_key) + && entry.scope == CacheScope::Private + { + return Some(entry.value.clone()); + } + + let public_key = Self::cache_key(logical_key, CachePartition::Public); + if let Some(entry) = cache.entries.get(&public_key) + && entry.scope == CacheScope::Public + { + return Some(entry.value.clone()); + } + None + } + + /// Stores a response when the configured effective TTL is positive. + /// + /// Missing `cacheScope` is treated as private. This is deliberately more + /// conservative than the model's backwards-compatible wire default and + /// prevents an older or malformed server response from becoming shareable. + pub(crate) async fn cache_response_with_generation( + &self, + logical_key: String, + value: R::PeerResp, + ttl_ms: Option, + cache_scope: Option, + generation: CacheGeneration, + ) { + let now = Instant::now(); + let mut cache = self.response_cache.write().await; + if !cache.config.enabled || generation.0 != cache.generation { + return; + } + + let requested_ttl = ttl_ms + .map(Duration::from_millis) + .unwrap_or(cache.config.default_ttl); + let ttl = requested_ttl.min(cache.config.max_ttl); + if ttl.is_zero() { + return; + } + let Some(expires_at) = now.checked_add(ttl) else { + return; + }; + let scope = cache_scope.unwrap_or(CacheScope::Private); + let target_key = Self::scoped_cache_key(&logical_key, scope, &cache.config); + let opposite_key = match scope { + CacheScope::Public => Self::cache_key( + &logical_key, + CachePartition::Private(Self::private_partition(&cache.config)), + ), + CacheScope::Private => Self::cache_key(&logical_key, CachePartition::Public), + }; + + if !cache.config.serve_stale_on_error { + cache.entries.retain(|_, entry| entry.expires_at > now); + } + cache.entries.remove(&opposite_key); + + if cache.config.max_entries > 0 + && !cache.entries.contains_key(&target_key) + && cache.entries.len() >= cache.config.max_entries + && let Some(oldest_key) = cache + .entries + .iter() + .min_by_key(|(_, entry)| entry.inserted_at) + .map(|(key, _)| key.clone()) + { + cache.entries.remove(&oldest_key); + } + + cache.entries.insert( + target_key, + CachedPeerResponse { + value, + expires_at, + inserted_at: now, + scope, + }, + ); + } + + #[cfg(test)] + pub(crate) async fn cache_response( + &self, + logical_key: String, + value: R::PeerResp, + ttl_ms: Option, + cache_scope: Option, + ) { + let generation = self.capture_response_cache_generation().await; + self.cache_response_with_generation(logical_key, value, ttl_ms, cache_scope, generation) + .await; + } + + pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) { + let mut cache = self.response_cache.write().await; + cache.generation = cache.generation.wrapping_add(1); + cache + .entries + .retain(|key, _| !key.logical_key.starts_with(prefix)); + } +} + +impl Peer { + /// Replaces the response-cache configuration. + /// + /// Changing the private partition invalidates private entries from the old + /// authorization context. Disabling the cache clears every entry. Any + /// configuration change also suppresses writes from requests that were + /// already in flight under the previous configuration. + pub async fn set_response_cache_config(&self, config: ClientCacheConfig) { + let mut cache = self.response_cache.write().await; + let config_changed = cache.config != config; + let partition_changed = cache.config.private_partition != config.private_partition; + let ttl_policy_changed = cache.config.default_ttl != config.default_ttl + || cache.config.max_ttl != config.max_ttl; + cache.config = config; + if config_changed { + cache.generation = cache.generation.wrapping_add(1); + } + if !cache.config.enabled || ttl_policy_changed { + cache.entries.clear(); + } else if partition_changed { + cache + .entries + .retain(|_, entry| entry.scope == CacheScope::Public); + } + cache.trim_to_limit(); + } + + /// Returns a snapshot of the active response-cache configuration. + pub async fn response_cache_config(&self) -> ClientCacheConfig { + self.response_cache.read().await.config.clone() + } + + /// Clears every cached client response without changing the configuration. + pub async fn clear_response_cache(&self) { + let mut cache = self.response_cache.write().await; + cache.generation = cache.generation.wrapping_add(1); + cache.entries.clear(); + } +} From 50dd8e2040c8d48b05bdb17bc28af388efbf5a50 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:01:45 +0900 Subject: [PATCH 262/333] fix: reap completed response send tasks (#1026) --- crates/rmcp/src/service.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 75f34d30c..18da427d8 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -1230,6 +1230,7 @@ where PeerMessage(RxJsonRpcMessage), ToSink(TxJsonRpcMessage), SendTaskResult(SendTaskResult), + ResponseSendTaskResult(Result<(), tokio::task::JoinError>), } let quit_reason = loop { @@ -1275,6 +1276,11 @@ where } } } + result = response_send_tasks.join_next(), if !response_send_tasks.is_empty() => { + Event::ResponseSendTaskResult( + result.expect("non-empty response send task set") + ) + } _ = serve_loop_ct.cancelled() => { tracing::info!("task cancelled"); break QuitReason::Cancelled @@ -1313,6 +1319,11 @@ where } } } + Event::ResponseSendTaskResult(result) => { + if let Err(error) = result { + tracing::error!(%error, "response send task failed"); + } + } // response and error Event::ToSink(m) => { if let Some(id) = match &m { From aad4d4e4c6dce27b7a558aa7aaed75fac0e3350c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:02:27 -0400 Subject: [PATCH 263/333] feat!: add distributed SSE event store (#1024) --- crates/rmcp/Cargo.toml | 10 + .../common/auth/streamable_http_client.rs | 4 +- .../src/transport/common/client_side_sse.rs | 49 +++ .../common/reqwest/streamable_http_client.rs | 10 +- .../src/transport/common/server_side_http.rs | 9 + .../rmcp/src/transport/common/unix_socket.rs | 11 +- .../src/transport/streamable_http_client.rs | 183 ++++++--- .../streamable_http_server/session.rs | 12 +- .../streamable_http_server/session/local.rs | 164 ++++++-- .../streamable_http_server/session/never.rs | 20 +- .../streamable_http_server/session/store.rs | 59 ++- .../transport/streamable_http_server/tower.rs | 228 +++++++---- .../tests/test_streamable_http_event_store.rs | 372 ++++++++++++++++++ .../test_streamable_http_stale_session.rs | 4 +- 14 files changed, 953 insertions(+), 182 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_event_store.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 60a4cb296..752b5c7ea 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -414,6 +414,16 @@ required-features = [ ] path = "tests/test_streamable_http_session_store.rs" +[[test]] +name = "test_streamable_http_event_store" +required-features = [ + "client", + "server", + "transport-streamable-http-client-reqwest", + "transport-streamable-http-server", +] +path = "tests/test_streamable_http_event_store.rs" + [[test]] name = "test_streamable_http_connection_reuse" required-features = [ diff --git a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs index f0a6211af..2069e6d31 100644 --- a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs @@ -31,7 +31,7 @@ where async fn get_stream( &self, uri: std::sync::Arc, - session_id: std::sync::Arc, + session_id: Option>, last_event_id: Option, mut auth_token: Option, custom_headers: HashMap, @@ -50,7 +50,7 @@ where async fn get_stream_with_max_sse_event_size( &self, uri: std::sync::Arc, - session_id: std::sync::Arc, + session_id: Option>, last_event_id: Option, mut auth_token: Option, custom_headers: HashMap, diff --git a/crates/rmcp/src/transport/common/client_side_sse.rs b/crates/rmcp/src/transport/common/client_side_sse.rs index ba21657f7..ce425c3a2 100644 --- a/crates/rmcp/src/transport/common/client_side_sse.rs +++ b/crates/rmcp/src/transport/common/client_side_sse.rs @@ -288,6 +288,7 @@ pin_project_lite::pin_project! { where R: SseStreamReconnect { retry_policy: Arc, + reconnect_only_after_event_id: bool, last_event_id: Option, server_retry_interval: Option, connector: R, @@ -304,6 +305,22 @@ impl SseAutoReconnectStream { ) -> Self { Self { retry_policy, + reconnect_only_after_event_id: false, + last_event_id: None, + server_retry_interval: None, + connector, + state: SseAutoReconnectStreamState::Connected { stream }, + } + } + + pub fn new_after_event_id( + stream: BoxedSseResponse, + connector: R, + retry_policy: Arc, + ) -> Self { + Self { + retry_policy, + reconnect_only_after_event_id: true, last_event_id: None, server_retry_interval: None, connector, @@ -317,6 +334,7 @@ impl SseAutoReconnectStream> { pub(crate) fn never_reconnect(stream: BoxedSseResponse, error_when_reconnect: E) -> Self { Self { retry_policy: Arc::new(NeverRetry), + reconnect_only_after_event_id: false, last_event_id: None, server_retry_interval: None, connector: NeverReconnect { @@ -409,6 +427,10 @@ where this.state.set(SseAutoReconnectStreamState::Terminated); return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); } + if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); + } this.connector .handle_stream_error(&e, this.last_event_id.as_deref()); let retrying = this @@ -420,6 +442,13 @@ where } } None => { + if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { + tracing::debug!( + "sse response ended before an event ID was received; cannot resume" + ); + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(None); + } // Per SEP-1699, a graceful stream close is // reconnectable. If the server sent a `retry` field // we MUST wait that long before reconnecting. @@ -686,4 +715,24 @@ mod tests { && attempts.load(Ordering::Relaxed) == 0 ); } + + #[tokio::test] + async fn response_without_event_id_does_not_reconnect() { + let attempts = Arc::new(AtomicUsize::new(0)); + let connector = CountingReconnect { + attempts: attempts.clone(), + }; + let stream = SseAutoReconnectStream::new_after_event_id( + futures::stream::empty().boxed(), + connector, + Arc::new(FixedInterval { + max_times: Some(1), + duration: Duration::ZERO, + }), + ); + let mut stream = std::pin::pin!(stream); + + assert!(stream.next().await.is_none()); + assert_eq!(attempts.load(Ordering::Relaxed), 0); + } } diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 7032e1a87..d2557dc0e 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -52,7 +52,7 @@ impl StreamableHttpClient for reqwest::Client { async fn get_stream( &self, uri: Arc, - session_id: Arc, + session_id: Option>, last_event_id: Option, auth_token: Option, custom_headers: HashMap, @@ -71,7 +71,7 @@ impl StreamableHttpClient for reqwest::Client { async fn get_stream_with_max_sse_event_size( &self, uri: Arc, - session_id: Arc, + session_id: Option>, last_event_id: Option, auth_token: Option, custom_headers: HashMap, @@ -79,8 +79,10 @@ impl StreamableHttpClient for reqwest::Client { ) -> Result>, StreamableHttpError> { let mut request_builder = self .get(uri.as_ref()) - .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", ")) - .header(HEADER_SESSION_ID, session_id.as_ref()); + .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", ")); + if let Some(session_id) = session_id { + request_builder = request_builder.header(HEADER_SESSION_ID, session_id.as_ref()); + } if let Some(last_event_id) = last_event_id { request_builder = request_builder.header(HEADER_LAST_EVENT_ID, last_event_id); } diff --git a/crates/rmcp/src/transport/common/server_side_http.rs b/crates/rmcp/src/transport/common/server_side_http.rs index 4969ff793..09df6d285 100644 --- a/crates/rmcp/src/transport/common/server_side_http.rs +++ b/crates/rmcp/src/transport/common/server_side_http.rs @@ -119,6 +119,15 @@ impl ServerSseMessage { retry: Some(retry), } } + + /// Create a retry hint without changing the client's last event ID. + pub fn retry(retry: Duration) -> Self { + Self { + event_id: None, + message: None, + retry: Some(retry), + } + } } pub(crate) fn sse_stream_response( diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs index 899548313..ef6555b7f 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -376,7 +376,7 @@ impl StreamableHttpClient for UnixSocketHttpClient { async fn get_stream( &self, uri: Arc, - session_id: Arc, + session_id: Option>, last_event_id: Option, auth_token: Option, custom_headers: HashMap, @@ -396,7 +396,7 @@ impl StreamableHttpClient for UnixSocketHttpClient { async fn get_stream_with_max_sse_event_size( &self, uri: Arc, - session_id: Arc, + session_id: Option>, last_event_id: Option, auth_token: Option, custom_headers: HashMap, @@ -410,8 +410,11 @@ impl StreamableHttpClient for UnixSocketHttpClient { .header( http::header::ACCEPT, format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), - ) - .header(HEADER_SESSION_ID, session_id.as_ref()); + ); + + if let Some(session_id) = session_id { + builder = builder.header(HEADER_SESSION_ID, session_id.as_ref()); + } if let Some(last_id) = last_event_id { builder = builder.header(HEADER_LAST_EVENT_ID, last_id); diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 432ff705c..27e09c653 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -348,10 +348,14 @@ pub trait StreamableHttpClient: Clone + Send + 'static { auth_header: Option, custom_headers: HashMap, ) -> impl Future>> + Send + '_; + /// Open an SSE stream, optionally scoped to a legacy session. + /// + /// `session_id` is `None` when resuming a stateless response using only + /// `last_event_id`. fn get_stream( &self, uri: Arc, - session_id: Arc, + session_id: Option>, last_event_id: Option, auth_header: Option, custom_headers: HashMap, @@ -375,7 +379,7 @@ pub trait StreamableHttpClient: Clone + Send + 'static { fn get_stream_with_max_sse_event_size( &self, uri: Arc, - session_id: Arc, + session_id: Option>, last_event_id: Option, auth_header: Option, custom_headers: HashMap, @@ -399,7 +403,7 @@ pub struct RetryConfig { struct StreamableHttpClientReconnect { pub client: C, - pub session_id: Arc, + pub session_id: Option>, pub uri: Arc, pub auth_header: Option, pub custom_headers: HashMap, @@ -550,37 +554,6 @@ impl StreamableHttpClientWorker { Ok(()) } - /// Convert a raw SSE stream into a JSON-RPC message stream without - /// reconnection logic. - fn raw_sse_to_jsonrpc( - stream: BoxedSseStream, - ) -> impl Stream>> + Send + 'static - { - stream.filter_map(|event| async { - match event { - Err(e) => Some(Err(StreamableHttpError::Sse(e))), - Ok(sse) => { - let is_message = - matches!(sse.event.as_deref(), None | Some("") | Some("message")); - if !is_message { - return None; - } - let data = sse.data?; - if data.trim().is_empty() { - return None; - } - match serde_json::from_str::(&data) { - Ok(msg) => Some(Ok(msg)), - Err(e) => { - tracing::debug!("failed to deserialize server message: {e}"); - None - } - } - } - } - }) - } - /// Convert an SSE stream into JSON-RPC messages with reconnect semantics. /// /// This is used for request-scoped SSE responses as well as the standalone @@ -590,7 +563,7 @@ impl StreamableHttpClientWorker { fn reconnecting_sse_to_jsonrpc( stream: BoxedSseStream, client: C, - session_id: Arc, + session_id: Option>, uri: Arc, auth_header: Option, custom_headers: HashMap, @@ -598,7 +571,7 @@ impl StreamableHttpClientWorker { retry_config: Arc, ) -> impl Stream>> + Send + 'static { - SseAutoReconnectStream::new( + SseAutoReconnectStream::new_after_event_id( stream, StreamableHttpClientReconnect { client, @@ -614,10 +587,8 @@ impl StreamableHttpClientWorker { /// Convert a POST response SSE stream into JSON-RPC messages. /// - /// Stateful sessions can resume via GET when the response stream closes - /// before the server sends the matching JSON-RPC response. Stateless - /// transports do not have enough state to resume, so they keep the raw - /// SSE-to-JSON-RPC mapping. + /// Request-scoped streams resume via GET once the server has supplied an + /// event ID. The session header remains optional for stateless transports. fn response_sse_to_jsonrpc( stream: BoxedSseStream, session_id: Option>, @@ -628,20 +599,17 @@ impl StreamableHttpClientWorker { max_sse_event_size: usize, retry_config: Arc, ) -> BoxStream<'static, Result>> { - match session_id { - Some(session_id) => Self::reconnecting_sse_to_jsonrpc( - stream, - client, - session_id, - uri, - auth_header, - custom_headers, - max_sse_event_size, - retry_config, - ) - .boxed(), - None => Self::raw_sse_to_jsonrpc(stream).boxed(), - } + Self::reconnecting_sse_to_jsonrpc( + stream, + client, + session_id, + uri, + auth_header, + custom_headers, + max_sse_event_size, + retry_config, + ) + .boxed() } async fn execute_sse_stream( @@ -709,7 +677,7 @@ impl StreamableHttpClientWorker { let result = match client .get_stream_with_max_sse_event_size( uri, - session_id.clone(), + Some(session_id.clone()), None, auth_header, protocol_headers.clone(), @@ -722,7 +690,7 @@ impl StreamableHttpClientWorker { stream, StreamableHttpClientReconnect { client, - session_id, + session_id: Some(session_id), uri: reconnect_uri, auth_header: reconnect_auth_header, custom_headers: protocol_headers, @@ -1559,7 +1527,7 @@ impl Worker for StreamableHttpClientWorker { /// async fn get_stream( /// &self, /// _uri: Arc, -/// _session_id: Arc, +/// _session_id: Option>, /// _last_event_id: Option, /// _auth_header: Option, /// _custom_headers: HashMap, @@ -1646,7 +1614,7 @@ impl StreamableHttpClientTransport { /// async fn get_stream( /// &self, /// _uri: Arc, - /// _session_id: Arc, + /// _session_id: Option>, /// _last_event_id: Option, /// _auth_header: Option, /// _custom_headers: HashMap, @@ -1778,11 +1746,110 @@ impl Default for StreamableHttpClientTransportConfig { #[cfg(test)] mod tests { + use std::sync::Mutex; + use serde_json::json; use super::*; use crate::model::{ListToolsResult, NumberOrString, ServerResult, Tool}; + type ReconnectAttempt = (Option, Option); + + #[derive(Clone, Default)] + struct StatelessReconnectClient { + reconnects: Arc>>, + } + + impl StreamableHttpClient for StatelessReconnectClient { + type Error = std::io::Error; + + async fn post_message( + &self, + _uri: Arc, + _message: ClientJsonRpcMessage, + _session_id: Option>, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + Err(StreamableHttpError::UnexpectedServerResponse( + "unexpected POST".into(), + )) + } + + async fn delete_session( + &self, + _uri: Arc, + _session_id: Arc, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + Ok(()) + } + + async fn get_stream( + &self, + _uri: Arc, + session_id: Option>, + last_event_id: Option, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + self.reconnects + .lock() + .expect("lock reconnects") + .push((session_id.map(|id| id.to_string()), last_event_id)); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + NumberOrString::Number(1), + ); + Ok(futures::stream::once(async move { + Ok(Sse { + event: None, + data: Some(serde_json::to_string(&response).expect("serialize response")), + id: Some("event-1".into()), + retry: None, + }) + }) + .boxed()) + } + } + + #[tokio::test] + async fn stateless_response_reconnects_with_last_event_id() { + let initial = futures::stream::iter([Ok(Sse { + event: None, + data: None, + id: Some("event-0".into()), + retry: Some(0), + })]) + .boxed(); + let client = StatelessReconnectClient::default(); + let reconnects = client.reconnects.clone(); + let stream = + StreamableHttpClientWorker::::response_sse_to_jsonrpc( + initial, + None, + client, + Arc::from("http://localhost/mcp"), + None, + HashMap::new(), + DEFAULT_MAX_SSE_EVENT_SIZE, + Arc::new(ExponentialBackoff { + max_times: Some(1), + base_duration: Duration::ZERO, + }), + ); + let mut stream = std::pin::pin!(stream); + + let message = stream.next().await.expect("replayed response").unwrap(); + + assert!(matches!(message, ServerJsonRpcMessage::Response(_))); + assert_eq!( + reconnects.lock().expect("lock reconnects").as_slice(), + &[(None, Some("event-0".into()))] + ); + } + fn tool(name: &'static str, annotation: serde_json::Value) -> Tool { let schema = json!({ "type": "object", diff --git a/crates/rmcp/src/transport/streamable_http_server/session.rs b/crates/rmcp/src/transport/streamable_http_server/session.rs index ab0ff3244..28bd98bc2 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session.rs @@ -20,6 +20,8 @@ //! Implement the [`SessionManager`] trait to back sessions with a database, //! Redis, or any other external store. +use std::sync::Arc; + use futures::Stream; pub use crate::transport::common::server_side_http::{ServerSseMessage, SessionId}; @@ -32,7 +34,10 @@ pub mod local; pub mod never; pub mod store; -pub use store::{SessionState, SessionStore, SessionStoreError}; +pub use store::{ + EventId, EventStore, EventStoreError, EventStream, SessionState, SessionStore, + SessionStoreError, StreamId, +}; /// Extension marker inserted into the `initialize` request extensions during a /// session restore replay. Handlers can check for its presence to distinguish a @@ -151,4 +156,9 @@ pub trait SessionManager: Send + Sync + 'static { ) -> impl Future, Self::Error>> + Send { futures::future::ready(Ok(RestoreOutcome::NotSupported)) } + + /// Return the shared event store used for resumable SSE streams. + fn event_store(&self) -> Option> { + None + } } diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 7e2893206..ca88c088a 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -1,6 +1,7 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, num::ParseIntError, + sync::Arc, time::{Duration, Instant}, }; @@ -32,6 +33,15 @@ use crate::{ pub struct LocalSessionManager { pub sessions: tokio::sync::RwLock>, pub session_config: SessionConfig, + event_store: Option>, +} + +impl LocalSessionManager { + /// Configure this session manager to use a shared event store. + pub fn with_event_store(mut self, event_store: Arc) -> Self { + self.event_store = Some(event_store); + self + } } #[derive(Debug, Error)] @@ -49,7 +59,11 @@ impl SessionManager for LocalSessionManager { type Transport = WorkerTransport; async fn create_session(&self) -> Result<(SessionId, Self::Transport), Self::Error> { let id = session_id(); - let (handle, worker) = create_local_session(id.clone(), self.session_config.clone()); + let (handle, worker) = create_local_session_with_event_store( + id.clone(), + self.session_config.clone(), + self.event_store.clone(), + ); self.sessions.write().await.insert(id.clone(), handle); Ok((id, WorkerTransport::spawn(worker))) } @@ -95,15 +109,7 @@ impl SessionManager for LocalSessionManager { let receiver = handle.establish_request_wise_channel().await?; let http_request_id = receiver.http_request_id; handle.push_message(message, http_request_id).await?; - - let priming = self.session_config.sse_retry.map(|retry| { - let event_id = match http_request_id { - Some(id) => format!("0/{id}"), - None => "0".into(), - }; - ServerSseMessage::priming(event_id, retry) - }); - Ok(futures::stream::iter(priming).chain(ReceiverStream::new(receiver.inner))) + Ok(ReceiverStream::new(receiver.inner)) } async fn create_standalone_stream( @@ -123,12 +129,19 @@ impl SessionManager for LocalSessionManager { id: &SessionId, last_event_id: String, ) -> Result + Send + 'static, Self::Error> { + if let Some(event_store) = &self.event_store { + let stream = event_store + .replay_events_after(&last_event_id) + .await + .map_err(SessionError::EventStore)?; + return Ok(stream.left_stream()); + } let sessions = self.sessions.read().await; let handle = sessions .get(id) .ok_or(LocalSessionManagerError::SessionNotFound(id.clone()))?; let receiver = handle.resume(last_event_id.parse()?).await?; - Ok(ReceiverStream::new(receiver.inner)) + Ok(ReceiverStream::new(receiver.inner).right_stream()) } async fn accept_message( @@ -153,10 +166,18 @@ impl SessionManager for LocalSessionManager { // A concurrent request already restored this session. return Ok(RestoreOutcome::AlreadyPresent); } - let (handle, worker) = create_local_session(id.clone(), self.session_config.clone()); + let (handle, worker) = create_local_session_with_event_store( + id.clone(), + self.session_config.clone(), + self.event_store.clone(), + ); sessions.insert(id, handle); Ok(RestoreOutcome::Restored(WorkerTransport::spawn(worker))) } + + fn event_store(&self) -> Option> { + self.event_store.clone() + } } /// `/request_id>` @@ -209,7 +230,9 @@ impl std::str::FromStr for EventId { } } -use super::{RestoreOutcome, ServerSseMessage, SessionManager}; +use super::{ + EventStore, EventStoreError, RestoreOutcome, ServerSseMessage, SessionManager, StreamId, +}; struct CachedTx { tx: Sender, @@ -217,6 +240,8 @@ struct CachedTx { http_request_id: Option, capacity: usize, starting_index: usize, + stream_id: StreamId, + event_store: Option>, } impl CachedTx { @@ -224,6 +249,8 @@ impl CachedTx { tx: Sender, http_request_id: Option, starting_index: usize, + stream_id: StreamId, + event_store: Option>, ) -> Self { Self { cache: VecDeque::with_capacity(tx.capacity()), @@ -231,10 +258,16 @@ impl CachedTx { tx, http_request_id, starting_index, + stream_id, + event_store, } } - fn new_common(tx: Sender) -> Self { - Self::new(tx, None, 0) + fn new_common( + tx: Sender, + session_id: &SessionId, + event_store: Option>, + ) -> Self { + Self::new(tx, None, 0, format!("{session_id}:common"), event_store) } fn next_event_id(&self) -> EventId { @@ -253,16 +286,31 @@ impl CachedTx { } } - async fn send(&mut self, message: ServerJsonRpcMessage) { - let event_id = self.next_event_id(); - let message = ServerSseMessage::new(event_id.to_string(), message); - self.cache_and_send(message).await; + async fn send(&mut self, message: ServerJsonRpcMessage) -> Result<(), SessionError> { + self.store_cache_and_send(ServerSseMessage::from_message(message)) + .await + } + + async fn send_priming(&mut self, retry: Duration) -> Result<(), SessionError> { + self.store_cache_and_send(ServerSseMessage::retry(retry)) + .await } - async fn send_priming(&mut self, retry: Duration) { - let event_id = self.next_event_id(); - let message = ServerSseMessage::priming(event_id.to_string(), retry); - self.cache_and_send(message).await; + async fn store_cache_and_send( + &mut self, + mut event: ServerSseMessage, + ) -> Result<(), SessionError> { + let event_id = if let Some(event_store) = &self.event_store { + event_store + .store_event(&self.stream_id, &event) + .await + .map_err(SessionError::EventStore)? + } else { + self.next_event_id().to_string() + }; + event.event_id = Some(event_id); + self.cache_and_send(event).await; + Ok(()) } async fn cache_and_send(&mut self, message: ServerSseMessage) { @@ -330,6 +378,7 @@ pub struct LocalSessionWorker { shadow_txs: Vec>, event_rx: Receiver, session_config: SessionConfig, + event_store: Option>, } impl LocalSessionWorker { @@ -353,6 +402,8 @@ pub enum SessionError { InvalidEventId, #[error("IO error: {0}")] Io(#[from] std::io::Error), + #[error("Event store error: {0}")] + EventStore(#[source] EventStoreError), } impl From for std::io::Error { @@ -450,12 +501,21 @@ impl LocalSessionWorker { ) -> Result { let http_request_id = self.next_http_request_id(); let (tx, rx) = tokio::sync::mpsc::channel(self.session_config.channel_capacity); - let starting_index = usize::from(self.session_config.sse_retry.is_some()); + let mut cached_tx = CachedTx::new( + tx, + Some(http_request_id), + 0, + uuid::Uuid::new_v4().to_string(), + self.event_store.clone(), + ); + if let Some(retry) = self.session_config.sse_retry { + cached_tx.send_priming(retry).await?; + } self.tx_router.insert( http_request_id, HttpRequestWise { resources: Default::default(), - tx: CachedTx::new(tx, Some(http_request_id), starting_index), + tx: cached_tx, completed_at: None, }, ); @@ -548,7 +608,7 @@ impl LocalSessionWorker { match outbound_channel { OutboundChannel::RequestWise { id, close } => { if let Some(request_wise) = self.tx_router.get_mut(&id) { - request_wise.tx.send(message).await; + request_wise.tx.send(message).await?; if close { if let Some(channel) = self.tx_router.remove(&id) { for resource in channel.resources { @@ -560,7 +620,7 @@ impl LocalSessionWorker { return Err(SessionError::ChannelClosed(Some(id))); } } - OutboundChannel::Common => self.common.send(message).await, + OutboundChannel::Common => self.common.send(message).await?, } Ok(()) } @@ -593,10 +653,20 @@ impl LocalSessionWorker { inner: rx, }) } - None => self.resume_or_shadow_common(last_event_id.index).await, + None => { + self.resume_or_shadow_common(Some(last_event_id.index)) + .await + } } } + async fn establish_common_channel( + &mut self, + ) -> Result { + let last_event_index = self.event_store.is_none().then_some(0); + self.resume_or_shadow_common(last_event_index).await + } + /// Resume the common channel, or create a shadow stream if the primary is /// still active. /// @@ -611,7 +681,7 @@ impl LocalSessionWorker { /// killing each other by repeatedly replacing the common channel sender. async fn resume_or_shadow_common( &mut self, - last_event_index: usize, + last_event_index: Option, ) -> Result { let is_replacing_dead_primary = self.common.tx.is_closed(); let capacity = if is_replacing_dead_primary { @@ -624,9 +694,9 @@ impl LocalSessionWorker { // Primary common channel is dead — replace it. tracing::debug!("Replacing dead common channel with new primary"); self.common.tx = tx; - // Replay cached messages from where the client left off so - // server-initiated requests and notifications are not lost. - self.common.sync(last_event_index).await?; + if let Some(last_event_index) = last_event_index { + self.common.sync(last_event_index).await?; + } } else { // Primary common channel is still active. Create a shadow stream // that stays alive via SSE keep-alive but doesn't receive @@ -668,7 +738,7 @@ impl LocalSessionWorker { // Send priming event if retry interval is specified if let Some(interval) = retry_interval { - request_wise.tx.send_priming(interval).await; + request_wise.tx.send_priming(interval).await?; } // Close the stream by dropping the sender @@ -685,7 +755,7 @@ impl LocalSessionWorker { None => { // Send priming event if retry interval is specified if let Some(interval) = retry_interval { - self.common.send_priming(interval).await; + self.common.send_priming(interval).await?; } // Close the stream by dropping the sender @@ -732,6 +802,9 @@ pub enum SessionEvent { retry_interval: Option, responder: oneshot::Sender>, }, + EstablishCommonChannel { + responder: oneshot::Sender>, + }, } #[derive(Debug, Clone)] @@ -820,13 +893,7 @@ impl LocalSessionHandle { ) -> Result { let (tx, rx) = tokio::sync::oneshot::channel(); self.event_tx - .send(SessionEvent::Resume { - last_event_id: EventId { - http_request_id: None, - index: 0, - }, - responder: tx, - }) + .send(SessionEvent::EstablishCommonChannel { responder: tx }) .await .map_err(|_| SessionError::SessionServiceTerminated)?; rx.await @@ -1085,6 +1152,10 @@ impl Worker for LocalSessionWorker { let handle_result = self.establish_request_wise_channel().await; let _ = responder.send(handle_result); } + InnerEvent::FromHttpService(SessionEvent::EstablishCommonChannel { responder }) => { + let handle_result = self.establish_common_channel().await; + let _ = responder.send(handle_result); + } InnerEvent::FromHttpService(SessionEvent::CloseRequestWiseChannel { id, responder, @@ -1179,11 +1250,19 @@ impl Default for SessionConfig { pub fn create_local_session( id: impl Into, config: SessionConfig, +) -> (LocalSessionHandle, LocalSessionWorker) { + create_local_session_with_event_store(id, config, None) +} + +fn create_local_session_with_event_store( + id: impl Into, + config: SessionConfig, + event_store: Option>, ) -> (LocalSessionHandle, LocalSessionWorker) { let id = id.into(); let (event_tx, event_rx) = tokio::sync::mpsc::channel(config.channel_capacity); let (common_tx, _) = tokio::sync::mpsc::channel(config.channel_capacity); - let common = CachedTx::new_common(common_tx); + let common = CachedTx::new_common(common_tx, &id, event_store.clone()); tracing::info!(session_id = ?id, "create new session"); let handle = LocalSessionHandle { event_tx, @@ -1198,6 +1277,7 @@ pub fn create_local_session( shadow_txs: Vec::new(), event_rx, session_config: config.clone(), + event_store, }; (handle, session_worker) } diff --git a/crates/rmcp/src/transport/streamable_http_server/session/never.rs b/crates/rmcp/src/transport/streamable_http_server/session/never.rs index a2f72d820..d83031c37 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/never.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/never.rs @@ -1,7 +1,9 @@ +use std::sync::Arc; + use futures::Stream; use thiserror::Error; -use super::{ServerSseMessage, SessionId, SessionManager}; +use super::{EventStore, ServerSseMessage, SessionId, SessionManager}; use crate::{ RoleServer, model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, @@ -14,7 +16,17 @@ use crate::{ pub struct ErrorSessionManagementNotSupported; #[derive(Debug, Clone, Default)] #[non_exhaustive] -pub struct NeverSessionManager {} +pub struct NeverSessionManager { + event_store: Option>, +} + +impl NeverSessionManager { + /// Configure resumable SSE storage without enabling sessions. + pub fn with_event_store(mut self, event_store: Arc) -> Self { + self.event_store = Some(event_store); + self + } +} #[non_exhaustive] pub enum NeverTransport {} impl Transport for NeverTransport { @@ -107,4 +119,8 @@ impl SessionManager for NeverSessionManager { ) -> impl Future> + Send { futures::future::ready(Err(ErrorSessionManagementNotSupported)) } + + fn event_store(&self) -> Option> { + self.event_store.clone() + } } diff --git a/crates/rmcp/src/transport/streamable_http_server/session/store.rs b/crates/rmcp/src/transport/streamable_http_server/session/store.rs index e9a6de2d8..8b656201c 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/store.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/store.rs @@ -1,4 +1,61 @@ -use crate::model::InitializeRequestParams; +use std::pin::Pin; + +use futures::Stream; + +use crate::{ + model::InitializeRequestParams, transport::common::server_side_http::ServerSseMessage, +}; + +/// An opaque identifier for a persisted SSE event. +pub type EventId = String; + +/// An opaque identifier for an SSE stream. +pub type StreamId = String; + +/// A stream of persisted SSE events in delivery order. +pub type EventStream = Pin + Send + Sync + 'static>>; + +/// Type alias for boxed event store errors. +pub type EventStoreError = Box; + +/// Persistent storage for resumable Streamable HTTP events. +/// +/// Implementations typically use a database or distributed log shared by all +/// server instances. Event IDs must be globally unique across all streams, and +/// events must be committed before [`EventStore::store_event`] returns so the +/// returned ID is safe to send to a client. +#[async_trait::async_trait] +pub trait EventStore: Send + Sync + 'static { + /// Persist an event and return the opaque ID clients should receive. + /// + /// The store assigns a globally unique ID and must retain its association + /// with `stream_id` so a later replay only returns events from that stream. + async fn store_event( + &self, + stream_id: &str, + event: &ServerSseMessage, + ) -> Result; + + /// Return events strictly after `last_event_id` in delivery order. + /// + /// Implementations must locate the stream from the globally unique event + /// ID and yield only later events from that stream, with their originally + /// assigned event IDs. + /// + /// A finite stream enables reconnect-and-poll behavior. Implementations + /// backed by a distributed log may keep the stream open to deliver new + /// events as they are appended by any server instance. + async fn replay_events_after( + &self, + last_event_id: &str, + ) -> Result; +} + +impl std::fmt::Debug for dyn EventStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} /// State persisted to an external store for cross-instance session recovery. /// diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 16fa1eccb..f66235ce8 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -19,7 +19,8 @@ use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; use super::session::{ - RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker, SessionState, SessionStore, + EventStore, EventStoreError, RestoreOutcome, SessionId, SessionManager, SessionRestoreMarker, + SessionState, SessionStore, }; use crate::{ RoleServer, @@ -49,6 +50,7 @@ use crate::{ /// Default maximum POST request body size (4 MiB). pub(crate) const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024; +const STATELESS_STREAM_CHANNEL_CAPACITY: usize = 16; #[non_exhaustive] #[derive(Debug, Clone)] @@ -321,6 +323,21 @@ fn method_not_allowed_response() -> BoxResponse { .expect("valid response") } +async fn persist_and_forward_event( + event_store: &dyn EventStore, + stream_id: &str, + mut event: ServerSseMessage, + output: &mut Option>, +) -> Result<(), EventStoreError> { + event.event_id = Some(event_store.store_event(stream_id, &event).await?); + if let Some(sender) = output { + if sender.send(event).await.is_err() { + *output = None; + } + } + Ok(()) +} + fn invalid_request_jsonrpc_response( id: Option, message: impl Into>, @@ -920,6 +937,98 @@ where (self.service_factory)() } + fn persisted_stateless_stream( + &self, + first: Option, + mut receiver: tokio::sync::mpsc::Receiver, + request_ct: CancellationToken, + event_store: Arc, + ) -> ReceiverStream { + let (sender, output) = tokio::sync::mpsc::channel(STATELESS_STREAM_CHANNEL_CAPACITY); + let stream_id = uuid::Uuid::new_v4().to_string(); + let retry = self.config.sse_retry; + let server_ct = self.config.cancellation_token.child_token(); + + tokio::spawn(async move { + let mut sender = Some(sender); + if let Some(retry) = retry { + if let Err(error) = persist_and_forward_event( + event_store.as_ref(), + &stream_id, + ServerSseMessage::retry(retry), + &mut sender, + ) + .await + { + tracing::error!(%stream_id, %error, "failed to persist SSE priming event"); + request_ct.cancel(); + return; + } + } + + let mut first = first; + loop { + let message = if let Some(message) = first.take() { + Some(message) + } else { + tokio::select! { + message = receiver.recv() => message, + _ = server_ct.cancelled() => { + request_ct.cancel(); + None + } + } + }; + let Some(message) = message else { + break; + }; + tracing::trace!(?message); + if let Err(error) = persist_and_forward_event( + event_store.as_ref(), + &stream_id, + ServerSseMessage::from_message(message), + &mut sender, + ) + .await + { + tracing::error!(%stream_id, %error, "failed to persist SSE event"); + request_ct.cancel(); + break; + } + } + }); + + ReceiverStream::new(output) + } + + fn stateless_sse_response( + &self, + first: Option, + receiver: tokio::sync::mpsc::Receiver, + request_ct: CancellationToken, + ) -> BoxResponse { + if let Some(event_store) = self.session_manager.event_store() { + let stream = self.persisted_stateless_stream(first, receiver, request_ct, event_store); + sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + ) + } else { + let stream = futures::stream::iter(first) + .chain(ReceiverStream::new(receiver)) + .map(|message| { + tracing::trace!(?message); + ServerSseMessage::from_message(message) + }); + sse_stream_response( + CancelOnDisconnect::new(stream, request_ct), + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + ) + } + } + // The HTTP status must be known before opening an SSE stream. async fn serve_negotiated_request_directly( &self, @@ -979,19 +1088,7 @@ where return jsonrpc_message_response(first, true); } - // The handler may still be streaming, so guard the response: dropping it - // (client disconnect) must cancel the handler. - let stream = futures::stream::once(async move { first }) - .chain(ReceiverStream::new(receiver)) - .map(|message| { - tracing::trace!(?message); - ServerSseMessage::from_message(message) - }); - Ok(sse_stream_response( - CancelOnDisconnect::new(stream, request_ct), - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )) + Ok(self.stateless_sse_response(Some(first), receiver, request_ct)) } /// Returns the cached input schema for `name`, constructing a service once @@ -1235,15 +1332,18 @@ where return response; } let method = request.method().clone(); - let allowed_methods = match self.config.legacy_session_mode { - true => "GET, POST, DELETE", - false => "POST", + let supports_stateless_replay = self.session_manager.event_store().is_some(); + let allowed_methods = match (self.config.legacy_session_mode, supports_stateless_replay) { + (true, _) => "GET, POST, DELETE", + (false, true) => "GET, POST", + (false, false) => "POST", }; - let result = match (method, self.config.legacy_session_mode) { - (Method::POST, _) => self.handle_post(request).await, - // if legacy session mode is disabled, we don't support GET or DELETE because there is no session - (Method::GET, true) => self.handle_get(request).await, - (Method::DELETE, true) => self.handle_delete(request).await, + let result = match method { + Method::POST => self.handle_post(request).await, + Method::GET if self.config.legacy_session_mode || supports_stateless_replay => { + self.handle_get(request).await + } + Method::DELETE if self.config.legacy_session_mode => self.handle_delete(request).await, _ => { // Handle other methods or return an error let response = Response::builder() @@ -1264,9 +1364,6 @@ where B: Body + Send + 'static, B::Error: Display, { - if !is_legacy_request(None, request.headers())? { - return Ok(method_not_allowed_response()); - } // check accept header if !request .headers() @@ -1284,6 +1381,32 @@ where ) .expect("valid response")); } + let request_uses_legacy_protocol = is_legacy_request(None, request.headers())?; + let legacy_request = self.config.legacy_session_mode && request_uses_legacy_protocol; + if !legacy_request { + let Some(last_event_id) = request + .headers() + .get(HEADER_LAST_EVENT_ID) + .and_then(|value| value.to_str().ok()) + else { + return Ok(method_not_allowed_response()); + }; + let Some(event_store) = self.session_manager.event_store() else { + return Ok(method_not_allowed_response()); + }; + let stream = match event_store.replay_events_after(last_event_id).await { + Ok(stream) => stream, + Err(error) => { + tracing::warn!(%error, "stateless SSE resume failed, returning empty stream"); + Box::pin(futures::stream::empty()) + } + }; + return Ok(sse_stream_response( + stream, + self.config.sse_keep_alive, + self.config.cancellation_token.child_token(), + )); + } // check session id let session_id = request .headers() @@ -1361,7 +1484,11 @@ where .await .map_err(internal_error_response("create standalone stream"))?; let stream = if let Some(retry) = self.config.sse_retry { - let priming = ServerSseMessage::priming("0", retry); + let priming = if self.session_manager.event_store().is_some() { + ServerSseMessage::retry(retry) + } else { + ServerSseMessage::priming("0", retry) + }; futures::stream::once(async move { priming }) .chain(stream) .left_stream() @@ -1658,10 +1785,9 @@ where request.request.extensions_mut().insert(part); let (transport, mut receiver) = OneshotTransport::::new(ClientJsonRpcMessage::Request(request)); - // Give this stateless request its own cancellation token so a - // client disconnect can cancel the in-flight handler (#857). A - // stateless request is one-shot (no session, no resumption), so a - // dropped response is terminal and safe to cancel. + // Give this stateless request its own cancellation token so an + // unpersisted response can cancel the in-flight handler on + // disconnect (#857). let request_ct = CancellationToken::new(); let service = serve_directly_with_ct(service, transport, peer_info, request_ct.clone()); @@ -1710,35 +1836,10 @@ where .body(Full::new(Bytes::from(body)).boxed()) .expect("valid response")) } else { - // The handler emitted an intermediate message and is still - // running, so guard the streamed sequence too: dropping it - // (client disconnect) must cancel the handler. - let first = futures::stream::once(async move { - ServerSseMessage::from_message(message) - }); - let remaining = ReceiverStream::new(receiver).map(|message| { - tracing::trace!(?message); - ServerSseMessage::from_message(message) - }); - Ok(sse_stream_response( - CancelOnDisconnect::new(first.chain(remaining), request_ct), - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )) + Ok(self.stateless_sse_response(Some(message), receiver, request_ct)) } } else { - // SSE mode (default): cancel the handler if the client - // disconnects (drops the response stream) before it completes. - let stream = ReceiverStream::new(receiver).map(|message| { - tracing::trace!(?message); - ServerSseMessage::from_message(message) - }); - let stream = CancelOnDisconnect::new(stream, request_ct); - Ok(sse_stream_response( - stream, - self.config.sse_keep_alive, - self.config.cancellation_token.child_token(), - )) + Ok(self.stateless_sse_response(None, receiver, request_ct)) } } ClientJsonRpcMessage::Notification(_notification) => { @@ -1821,16 +1922,11 @@ where } pin_project! { - /// Wraps a stateless SSE response stream so a client disconnect cancels the - /// in-flight request. + /// Cancels an unpersisted stateless request when its response is dropped. /// - /// A stateless streamable-HTTP request is one-shot: it has no session and no - /// resumption, so a dropped response stream means the client is gone for - /// good. When the stream is dropped *before* it ends naturally, the request's - /// cancellation token is fired, which stops the dedicated `serve_directly` - /// loop and cancels the handler's `RequestContext::ct` (see #857). If the - /// stream ends naturally (the request completed), the guard is disarmed so - /// normal completion cancels nothing. + /// Persisted requests keep running so another connection can resume them. + /// Without an event store, dropping the stream fires the request's + /// cancellation token. Natural completion disarms the guard. struct CancelOnDisconnect { #[pin] inner: S, diff --git a/crates/rmcp/tests/test_streamable_http_event_store.rs b/crates/rmcp/tests/test_streamable_http_event_store.rs new file mode 100644 index 000000000..cc88f1077 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_event_store.rs @@ -0,0 +1,372 @@ +#![cfg(all( + feature = "client", + feature = "server", + feature = "transport-streamable-http-client-reqwest", + feature = "transport-streamable-http-server", + not(feature = "local") +))] + +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use futures::StreamExt; +use rmcp::{ + ErrorData, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + ProgressNotificationParam, ServerCapabilities, ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::{ + EventStore, EventStoreError, EventStream, ServerSseMessage, SessionState, SessionStore, + SessionStoreError, local::LocalSessionManager, never::NeverSessionManager, + }, + }, +}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Default)] +struct InMemorySessionStore(Arc>>); + +#[async_trait::async_trait] +impl SessionStore for InMemorySessionStore { + async fn load(&self, session_id: &str) -> Result, SessionStoreError> { + Ok(self.0.read().await.get(session_id).cloned()) + } + + async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> { + self.0 + .write() + .await + .insert(session_id.to_owned(), state.clone()); + Ok(()) + } + + async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> { + self.0.write().await.remove(session_id); + Ok(()) + } +} + +#[derive(Clone)] +struct StoredEvent { + stream_id: String, + event: ServerSseMessage, +} + +#[derive(Clone, Default)] +struct InMemoryEventStore { + events: Arc>>, + next_id: Arc, +} + +#[async_trait::async_trait] +impl EventStore for InMemoryEventStore { + async fn store_event( + &self, + stream_id: &str, + event: &ServerSseMessage, + ) -> Result { + let event_id = format!("event-{}", self.next_id.fetch_add(1, Ordering::Relaxed)); + let mut event = event.clone(); + event.event_id = Some(event_id.clone()); + self.events.write().await.push(StoredEvent { + stream_id: stream_id.to_owned(), + event, + }); + Ok(event_id) + } + + async fn replay_events_after( + &self, + last_event_id: &str, + ) -> Result { + let events = self.events.read().await; + let last_index = events + .iter() + .position(|stored| stored.event.event_id.as_deref() == Some(last_event_id)) + .ok_or_else(|| std::io::Error::other("event not found"))?; + let stream_id = events[last_index].stream_id.clone(); + let replay = events + .iter() + .skip(last_index + 1) + .filter(|stored| stored.stream_id == stream_id) + .map(|stored| stored.event.clone()) + .collect::>(); + Ok(Box::pin(futures::stream::iter(replay))) + } +} + +#[derive(Clone)] +struct ProgressServer; + +impl ServerHandler for ProgressServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + if request.name == "progress" || request.name == "slow-progress" { + let progress_token = context + .meta + .get_progress_token() + .expect("request includes progressToken"); + context + .peer + .notify_progress( + ProgressNotificationParam::new(progress_token, 50.0) + .with_total(100.0) + .with_message("working"), + ) + .await + .expect("progress notification is delivered"); + } + if request.name == "slow-progress" { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) + } +} + +async fn spawn_server( + session_store: Arc, + event_store: Arc, + cancellation_token: &CancellationToken, + legacy_session_mode: bool, +) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { + let config = { + let mut config = StreamableHttpServerConfig::default(); + config.sse_keep_alive = None; + config.legacy_session_mode = legacy_session_mode; + config.cancellation_token = cancellation_token.child_token(); + config.session_store = Some(session_store); + config + }; + let router = if legacy_session_mode { + let session_manager = + Arc::new(LocalSessionManager::default().with_event_store(event_store)); + let service = StreamableHttpService::new(|| Ok(ProgressServer), session_manager, config); + axum::Router::new().nest_service("/mcp", service) + } else { + let session_manager = + Arc::new(NeverSessionManager::default().with_event_store(event_store)); + let service = StreamableHttpService::new(|| Ok(ProgressServer), session_manager, config); + axum::Router::new().nest_service("/mcp", service) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let handle = tokio::spawn({ + let cancellation_token = cancellation_token.clone(); + async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { cancellation_token.cancelled_owned().await }) + .await; + } + }); + Ok((format!("http://{address}/mcp"), handle)) +} + +fn event_id_containing<'a>(body: &'a str, needle: &str) -> Option<&'a str> { + body.split("\n\n") + .find(|event| event.contains(needle))? + .lines() + .find_map(|line| line.strip_prefix("id: ")) +} + +#[tokio::test] +async fn restored_instance_replays_events_from_shared_store() -> anyhow::Result<()> { + let session_store: Arc = Arc::new(InMemorySessionStore::default()); + let event_store: Arc = Arc::new(InMemoryEventStore::default()); + let http = reqwest::Client::new(); + + let cancellation_a = CancellationToken::new(); + let (url_a, server_a) = spawn_server( + session_store.clone(), + event_store.clone(), + &cancellation_a, + true, + ) + .await?; + let initialize = http + .post(&url_a) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#) + .send() + .await?; + let session_id = initialize + .headers() + .get("mcp-session-id") + .expect("initialize returns a session ID") + .to_str()? + .to_owned(); + let _ = initialize.text().await?; + + let initialized_status = http + .post(&url_a) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Session-Id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await? + .status(); + assert_eq!(initialized_status, reqwest::StatusCode::ACCEPTED); + + let original_body = http + .post(&url_a) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Session-Id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-1"}}}"#) + .send() + .await? + .text() + .await?; + let progress_event_id = event_id_containing(&original_body, "notifications/progress") + .expect("progress event has a persisted event ID") + .to_owned(); + + cancellation_a.cancel(); + server_a.await?; + + let cancellation_b = CancellationToken::new(); + let (url_b, server_b) = spawn_server(session_store, event_store, &cancellation_b, true).await?; + let replay = http + .get(&url_b) + .header("Accept", "text/event-stream") + .header("Mcp-Session-Id", &session_id) + .header("Mcp-Protocol-Version", "2025-06-18") + .header("Last-Event-ID", progress_event_id) + .send() + .await?; + assert_eq!(replay.status(), reqwest::StatusCode::OK); + let replay_body = replay.text().await?; + assert!( + replay_body.contains(r#""id":2"#), + "instance B should replay the final response stored by instance A: {replay_body}" + ); + + cancellation_b.cancel(); + server_b.await?; + Ok(()) +} + +#[tokio::test] +async fn stateless_instance_replays_events_from_shared_store() -> anyhow::Result<()> { + let session_store: Arc = Arc::new(InMemorySessionStore::default()); + let event_store = Arc::new(InMemoryEventStore::default()); + let http = reqwest::Client::new(); + + let cancellation_a = CancellationToken::new(); + let (url_a, server_a) = spawn_server( + session_store.clone(), + event_store.clone(), + &cancellation_a, + false, + ) + .await?; + let original = http + .post(&url_a) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", "slow-progress") + .body( + r#"{ + "jsonrpc":"2.0", + "id":2, + "method":"tools/call", + "params":{ + "name":"slow-progress", + "arguments":{}, + "_meta":{ + "progressToken":"progress-1", + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"}, + "io.modelcontextprotocol/clientCapabilities":{} + } + } + }"#, + ) + .send() + .await?; + assert_eq!(original.status(), reqwest::StatusCode::OK); + assert!( + original.headers().get("mcp-session-id").is_none(), + "stateless response must not create a session" + ); + let mut body = original.bytes_stream(); + let mut received = String::new(); + let progress_event_id = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let chunk = body + .next() + .await + .expect("response remains open until progress arrives")?; + received.push_str(&String::from_utf8_lossy(&chunk)); + if let Some(event_id) = event_id_containing(&received, "notifications/progress") { + return Ok::<_, reqwest::Error>(event_id.to_owned()); + } + } + }) + .await??; + drop(body); + + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let stored_response = event_store.events.read().await.iter().any(|stored| { + stored.event.message.as_ref().is_some_and(|message| { + matches!( + message.as_ref(), + rmcp::model::ServerJsonRpcMessage::Response(_) + ) + }) + }); + if stored_response { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await?; + + cancellation_a.cancel(); + server_a.await?; + + let cancellation_b = CancellationToken::new(); + let (url_b, server_b) = + spawn_server(session_store, event_store, &cancellation_b, false).await?; + let replay = http + .get(&url_b) + .header("Accept", "text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Last-Event-ID", progress_event_id) + .send() + .await?; + assert_eq!(replay.status(), reqwest::StatusCode::OK); + let replay_body = replay.text().await?; + assert!( + replay_body.contains(r#""id":2"#), + "instance B should replay the stateless response stored by instance A: {replay_body}" + ); + + cancellation_b.cancel(); + server_b.await?; + Ok(()) +} diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index 137460f7b..be1ac0269 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -160,7 +160,7 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient { async fn get_stream( &self, _uri: Arc, - session_id: Arc, + session_id: Option>, _last_event_id: Option, _auth_header: Option, _custom_headers: HashMap, @@ -168,7 +168,7 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient { futures::stream::BoxStream<'static, Result>, StreamableHttpError, > { - if session_id.as_ref() == "session-1" { + if session_id.as_deref() == Some("session-1") { let cancel = self.stale_stream_cancelled.clone(); Ok(Box::pin(stream::once(async move { cancel.cancelled_owned().await; From 397d416fdc64edeb1bf2fe5be3ef2b14f6155d3c Mon Sep 17 00:00:00 2001 From: camillelawrence Date: Thu, 23 Jul 2026 13:12:20 -0400 Subject: [PATCH 264/333] feat: route SEP-2260 associated server requests to the originating SSE stream (#1029) * feat: implement SEP-2260 require server requests to associate with client requests * feat(service): attach originating request id to in-handler outbound requests (SEP-2260) Reuses the ORIGINATING_REQUEST task-local from #1027; the marker rides the request's non-serialized Extensions so the streamable HTTP server can route associated requests to the originating SSE stream. * feat(transport): route associated server requests to originating SSE stream (SEP-2260) * test: end-to-end SEP-2260 stream routing over streamable HTTP * test: register SEP-2260 routing test with required-features; drop dead list_tools * docs: document SEP-2260 association requirement and stream routing * docs: fix redundant explicit link on OriginatingRequestId * docs: note marker is role-agnostic; add reason to deprecated expect Review follow-ups: the OriginatingRequestId rustdoc now states the marker is attached for both roles with only the server transport reading it, and the test's deprecation suppression carries a reason per house style. * docs: condense inline comments to repo convention Trims multi-line inline comments added in this branch down to the terse one-to-two-line style used elsewhere in the codebase, keeping only the non-obvious rationale (spec references, fallback invariant, version choice). * docs: condense SEP-2260 rustdoc sections Tightens the duplicated per-method association section from eleven lines to five, and trims the marker and SessionManager docs to the same information in fewer words. * docs: single-source SEP-2260 caller requirements on OriginatingRequestId * feat(service): reject unassociated server-to-client requests on the client (SEP-2260) Implements the client receive-side of SEP-2260: restricted server requests (sampling/createMessage, roots/list, elicitation/create) received while the client has no outbound request in flight are answered with -32602 invalid params instead of being dispatched to the handler. Gated on negotiated protocol >= 2026-07-28; ping is exempt. With a request in flight we cannot tell which one the server request belongs to (SEP-2260 defines no wire field), so this is a deliberate under-approximation of the spec's SHOULD; exact stream-based enforcement for streamable HTTP needs receive-side provenance plumbing and is left as a follow-up. --------- Co-authored-by: Alex Hancock --- crates/rmcp/Cargo.toml | 5 + crates/rmcp/src/service.rs | 130 ++++++++- crates/rmcp/src/service/client.rs | 29 ++ crates/rmcp/src/service/server.rs | 69 ++++- crates/rmcp/src/task_manager.rs | 80 ++++- .../streamable_http_server/session.rs | 10 + .../streamable_http_server/session/local.rs | 98 ++++++- .../test_sep_2260_request_association.rs | 273 ++++++++++++++++++ .../tests/test_sep_2260_stream_routing.rs | 165 +++++++++++ 9 files changed, 853 insertions(+), 6 deletions(-) create mode 100644 crates/rmcp/tests/test_sep_2260_request_association.rs create mode 100644 crates/rmcp/tests/test_sep_2260_stream_routing.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 752b5c7ea..737682785 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -445,3 +445,8 @@ required-features = [ "reqwest", ] path = "tests/test_streamable_http_disconnect_cancel.rs" + +[[test]] +name = "test_sep_2260_stream_routing" +required-features = ["server", "elicitation", "transport-streamable-http-server", "reqwest"] +path = "tests/test_sep_2260_stream_routing.rs" diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 18da427d8..217464ff6 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -149,6 +149,29 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { ) -> impl Future + MaybeSendFuture { async {} } + + #[doc(hidden)] + fn enforce_request_association( + _request: &Self::Req, + _peer_info: Option<&Self::PeerInfo>, + _in_request_handler_scope: bool, + ) -> Result<(), ServiceError> { + Ok(()) + } + + /// Receive-side counterpart of [`Self::enforce_request_association`]: + /// SEP-2260 says clients receiving a server-to-client request with no + /// associated outbound request should reject it with invalid params. An + /// error return is sent back to the peer instead of dispatching to the + /// handler. + #[doc(hidden)] + fn enforce_peer_request_association( + _peer_request: &Self::PeerReq, + _peer_info: Option<&Self::PeerInfo>, + _has_pending_outbound_request: bool, + ) -> Result<(), McpError> { + Ok(()) + } } pub(crate) fn uses_legacy_lifecycle( @@ -159,6 +182,33 @@ pub(crate) fn uses_legacy_lifecycle( && protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28) } +tokio::task_local! { + pub(crate) static ORIGINATING_REQUEST: RequestId; +} + +pub(crate) fn in_request_handler_scope() -> bool { + ORIGINATING_REQUEST.try_with(|_| ()).is_ok() +} + +/// Marker in an outbound request's non-serialized [`Extensions`] identifying +/// the in-flight peer request it was issued from (SEP-2260). Attached for both +/// roles whenever a request is sent from within a request handler; the +/// streamable HTTP server reads it to deliver server-initiated requests on the +/// originating request's SSE stream. Never on the wire (SEP-2260 defines no +/// wire field), so session managers that serialize messages between processes +/// lose it and such requests fall back to the standalone stream with a warning. +/// +/// # Caller requirements +/// +/// From protocol version `2026-07-28`, server-to-client sampling, roots, and +/// elicitation requests must be issued while handling a client request; +/// outside a handler they return an `invalid_request` error. The association +/// is task-local and does not cross `tokio::spawn`, so use the task manager +/// for long-running work. +#[derive(Debug, Clone, PartialEq, Eq)] +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct OriginatingRequestId(pub RequestId); + pub type TxJsonRpcMessage = JsonRpcMessage<::Req, ::Resp, ::Not>; pub type RxJsonRpcMessage = JsonRpcMessage< @@ -725,6 +775,16 @@ impl Peer { options: PeerRequestOptions, subscription_sender: Option>, ) -> Result, ServiceError> { + R::enforce_request_association( + &request, + self.peer_info().as_deref(), + in_request_handler_scope(), + )?; + if let Ok(originating) = ORIGINATING_REQUEST.try_with(|id| id.clone()) { + request + .extensions_mut() + .insert(OriginatingRequestId(originating)); + } let id = self.request_id_provider.next_request_id(); let progress_token = self.progress_token_provider.next_progress_token(); if let Some(metadata) = self.client_request_metadata.get() { @@ -1389,6 +1449,24 @@ where .. })) => { tracing::debug!(%id, ?request, "received request"); + if let Err(error) = R::enforce_peer_request_association( + &request, + peer.peer_info().as_deref(), + !local_responder_pool.is_empty(), + ) { + tracing::warn!(%id, message = %error.message, "rejected peer request"); + // send directly: the sink proxy path would drop the + // error since the request was never registered in + // local_ct_pool + let send = transport.send(JsonRpcMessage::error(error, Some(id))); + let current_span = tracing::Span::current(); + response_send_tasks.spawn(async move { + if let Err(error) = send.await { + tracing::error!(%error, "fail to send rejection error"); + } + }.instrument(current_span)); + continue; + } { let service = shared_service.clone(); let sink = sink_proxy_tx.clone(); @@ -1409,9 +1487,10 @@ where extensions, }; let current_span = tracing::Span::current(); + let handler_id = id.clone(); spawn_service_task(async move { - let result = service - .handle_request(request, context) + let result = ORIGINATING_REQUEST + .scope(handler_id, service.handle_request(request, context)) .await; let response = match result { Ok(result) => { @@ -1608,3 +1687,50 @@ where dg: ct.drop_guard(), } } + +#[cfg(all(test, feature = "server"))] +mod sep2260_marker_tests { + use std::sync::Arc; + + use super::*; + use crate::model::{PingRequest, RequestId, ServerRequest}; + + fn ping() -> ServerRequest { + ServerRequest::PingRequest(PingRequest { + method: Default::default(), + extensions: Default::default(), + }) + } + + async fn send_and_capture(scope: Option) -> ::Req { + // peer_info None keeps enforcement non-strict; only the sink message matters. + let (peer, mut rx) = + Peer::::new(Arc::new(AtomicU32RequestIdProvider::default()), None); + let send = peer.send_request_with_option(ping(), PeerRequestOptions::no_options()); + let _handle = match scope { + Some(id) => ORIGINATING_REQUEST.scope(id, send).await.unwrap(), + None => send.await.unwrap(), + }; + let PeerSinkMessage::Request { request, .. } = rx.recv().await.expect("sink message") + else { + panic!("expected a request sink message"); + }; + request + } + + #[tokio::test] + async fn outbound_request_carries_originating_id_when_in_scope() { + let request = send_and_capture(Some(RequestId::Number(7))).await; + let marker = request + .extensions() + .get::() + .expect("marker attached"); + assert_eq!(marker.0, RequestId::Number(7)); + } + + #[tokio::test] + async fn outbound_request_has_no_marker_outside_scope() { + let request = send_and_capture(None).await; + assert!(request.extensions().get::().is_none()); + } +} diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 166207b77..40d410d5b 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -213,6 +213,35 @@ impl ServiceRole for RoleClient { } } + // SEP-2260: with no outbound request in flight there is nothing the + // server request could be associated with, so reject it. With one in + // flight we cannot tell which request it belongs to (no wire field), so + // we accept — an under-approximation of the spec's SHOULD. + fn enforce_peer_request_association( + peer_request: &Self::PeerReq, + peer_info: Option<&Self::PeerInfo>, + has_pending_outbound_request: bool, + ) -> Result<(), ErrorData> { + let restricted = matches!( + peer_request, + ServerRequest::CreateMessageRequest(_) + | ServerRequest::ListRootsRequest(_) + | ServerRequest::ElicitRequest(_) + ); + if !restricted { + return Ok(()); + } + let strict = + peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28); + if strict && !has_pending_outbound_request { + return Err(ErrorData::invalid_params( + "SEP-2260: server-to-client requests must be associated with an in-flight client request", + None, + )); + } + Ok(()) + } + async fn invalidate_response_cache(peer: &Peer, notification: &Self::PeerNot) { match notification { ServerNotification::ResourceUpdatedNotification(notification) => { diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index f45299082..2b84a9431 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -49,6 +49,31 @@ impl ServiceRole for RoleServer { _ => None, } } + + fn enforce_request_association( + request: &Self::Req, + peer_info: Option<&Self::PeerInfo>, + in_request_handler_scope: bool, + ) -> Result<(), ServiceError> { + let restricted = matches!( + request, + ServerRequest::CreateMessageRequest(_) + | ServerRequest::ListRootsRequest(_) + | ServerRequest::ElicitRequest(_) + ); + if !restricted { + return Ok(()); + } + let strict = + peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28); + if strict && !in_request_handler_scope { + return Err(ServiceError::McpError(ErrorData::invalid_request( + "SEP-2260: server-to-client requests must be associated with an originating client request", + None, + ))); + } + Ok(()) + } } /// It represents the error that may occur when serving the server. @@ -744,6 +769,10 @@ impl Peer { } } + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. #[deprecated( since = "1.8.0", note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" @@ -778,6 +807,10 @@ impl Peer { } } method!( + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. #[deprecated( since = "1.8.0", note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" @@ -785,9 +818,21 @@ impl Peer { peer_req list_roots ListRootsRequest() => ListRootsResult ); #[cfg(feature = "elicitation")] - method!(peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult); + method!( + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult + ); #[cfg(feature = "elicitation")] - method!(peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult); + method!( + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. + peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult + ); method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); @@ -1014,6 +1059,11 @@ impl Peer { /// # Ok(()) /// # } /// ``` + /// + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. #[cfg(all(feature = "schemars", feature = "elicitation"))] pub async fn elicit(&self, message: impl Into) -> Result, ElicitationError> where @@ -1075,6 +1125,11 @@ impl Peer { /// # Ok(()) /// # } /// ``` + /// + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. #[cfg(all(feature = "schemars", feature = "elicitation"))] pub async fn elicit_with_timeout( &self, @@ -1170,6 +1225,11 @@ impl Peer { /// Ok(()) /// } /// ``` + /// + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. #[cfg(feature = "elicitation")] pub async fn elicit_url( &self, @@ -1221,6 +1281,11 @@ impl Peer { /// Ok(()) /// } /// ``` + /// + /// # SEP-2260: request association + /// + /// From protocol version `2026-07-28` this must be issued while handling a + /// client request; see [`OriginatingRequestId`]. #[cfg(feature = "elicitation")] pub async fn elicit_url_with_timeout( &self, diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index df1c389a3..0e8339c0a 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -349,8 +349,11 @@ impl TaskManager { let future = make_future(context); let inner = self.inner.clone(); let id_for_task = task_id.clone(); + let originating_request = crate::service::ORIGINATING_REQUEST + .try_with(|id| id.clone()) + .ok(); let handle = tokio::spawn(async move { - let result = future.await; + let result = run_task_operation(originating_request, future).await; let mut inner = inner.lock().expect("task manager lock poisoned"); if let Some(entry) = inner.tasks.get_mut(&id_for_task) { if entry.terminal.is_none() { @@ -538,6 +541,16 @@ fn unknown_task(task_id: &str) -> McpError { McpError::invalid_params(format!("unknown task: {task_id}"), None) } +async fn run_task_operation( + originating_request: Option, + future: TaskFuture, +) -> Result { + match originating_request { + Some(id) => crate::service::ORIGINATING_REQUEST.scope(id, future).await, + None => future.await, + } +} + fn result_to_object(result: &CallToolResult) -> JsonObject { match serde_json::to_value(result) { Ok(serde_json::Value::Object(map)) => map, @@ -917,4 +930,69 @@ mod tests { } panic!("task did not complete after input response"); } + + #[tokio::test] + async fn task_operation_reestablishes_request_association_scope() { + use crate::{ + model::RequestId, + service::{ORIGINATING_REQUEST, in_request_handler_scope}, + }; + + let manager = TaskManager::new(); + let observed = Arc::new(Mutex::new(None::)); + let observed_in_task = observed.clone(); + + ORIGINATING_REQUEST + .scope(RequestId::Number(7), async { + manager.spawn(TaskOptions::default(), move |_ctx| { + let observed_in_task = observed_in_task.clone(); + Box::pin(async move { + *observed_in_task.lock().unwrap() = Some(in_request_handler_scope()); + Ok(ok_result("done")) + }) + }) + }) + .await; + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if let Some(scoped) = *observed.lock().unwrap() { + assert!( + scoped, + "task operation must run inside the originating request's association scope" + ); + return; + } + } + panic!("task operation did not run"); + } + + #[tokio::test] + async fn task_operation_without_originating_request_is_unscoped() { + use crate::service::in_request_handler_scope; + + let manager = TaskManager::new(); + let observed = Arc::new(Mutex::new(None::)); + let observed_in_task = observed.clone(); + + manager.spawn(TaskOptions::default(), move |_ctx| { + let observed_in_task = observed_in_task.clone(); + Box::pin(async move { + *observed_in_task.lock().unwrap() = Some(in_request_handler_scope()); + Ok(ok_result("done")) + }) + }); + + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if let Some(scoped) = *observed.lock().unwrap() { + assert!( + !scoped, + "task operation started without an originating request must remain unscoped" + ); + return; + } + } + panic!("task operation did not run"); + } } diff --git a/crates/rmcp/src/transport/streamable_http_server/session.rs b/crates/rmcp/src/transport/streamable_http_server/session.rs index 28bd98bc2..ea2e85b24 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session.rs @@ -77,6 +77,16 @@ pub enum RestoreOutcome { /// trait for every HTTP request that carries (or should carry) a session ID. /// /// See the [module-level docs](self) for background on sessions. +/// +/// # SEP-2260 request association +/// +/// Server-initiated requests issued while handling a client request carry an +/// [`OriginatingRequestId`](crate::service::OriginatingRequestId) marker in +/// their non-serialized `Extensions`; the bundled local session manager uses +/// it to deliver such requests on the originating request's SSE stream. +/// Implementations that serialize messages between processes lose the marker +/// and fall back to the standalone stream, violating SEP-2260 for 2026-07-28+ +/// clients; they need their own association mechanism. pub trait SessionManager: Send + Sync + 'static { type Error: std::error::Error + Send + 'static; type Transport: crate::transport::Transport; diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index ca88c088a..dc8a70b87 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -527,7 +527,36 @@ impl LocalSessionWorker { } fn resolve_outbound_channel(&self, message: &ServerJsonRpcMessage) -> OutboundChannel { match &message { - ServerJsonRpcMessage::Request(_) => OutboundChannel::Common, + // SEP-2260: requests carrying an OriginatingRequestId marker ride the + // originating request's SSE stream, never the standalone GET stream. + ServerJsonRpcMessage::Request(json_rpc_request) => { + use crate::model::GetExtensions; + let originating = json_rpc_request + .request + .extensions() + .get::(); + match originating { + Some(originating) => match self + .resource_router + .get(&ResourceKey::McpRequestId(originating.0.clone())) + { + Some(id) => OutboundChannel::RequestWise { + id: *id, + close: false, + }, + None => { + tracing::warn!( + originating_request_id = %originating.0, + "associated server request could not be routed to its \ + originating stream (request completed or association \ + lost); falling back to standalone stream" + ); + OutboundChannel::Common + } + }, + None => OutboundChannel::Common, + } + } ServerJsonRpcMessage::Notification(JsonRpcNotification { notification: ServerNotification::ProgressNotification(Notification { @@ -1281,3 +1310,70 @@ fn create_local_session_with_event_store( }; (handle, session_worker) } + +#[cfg(test)] +mod sep2260_routing_tests { + use super::*; + use crate::service::OriginatingRequestId; + + fn roots_request(originating: Option) -> ServerJsonRpcMessage { + #[expect( + deprecated, + reason = "roots is SEP-2577-deprecated; any restricted request works here" + )] + let mut request = crate::model::ListRootsRequest { + method: Default::default(), + extensions: Default::default(), + }; + if let Some(id) = originating { + request.extensions.insert(OriginatingRequestId(id)); + } + ServerJsonRpcMessage::request( + crate::model::ServerRequest::ListRootsRequest(request), + RequestId::Number(1000), + ) + } + + #[tokio::test] + async fn associated_server_request_routes_to_originating_stream() { + let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); + let receiver = worker.establish_request_wise_channel().await.unwrap(); + let http_request_id = receiver.http_request_id.unwrap(); + let originating_id = RequestId::Number(7); + worker.register_resource( + ResourceKey::McpRequestId(originating_id.clone()), + http_request_id, + ); + + let channel = worker.resolve_outbound_channel(&roots_request(Some(originating_id))); + assert!( + matches!(channel, OutboundChannel::RequestWise { id, close: false } if id == http_request_id) + ); + } + + #[tokio::test] + async fn unassociated_server_request_routes_to_common_stream() { + let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); + let _receiver = worker.establish_request_wise_channel().await.unwrap(); + let channel = worker.resolve_outbound_channel(&roots_request(None)); + assert!(matches!(channel, OutboundChannel::Common)); + } + + #[tokio::test] + async fn associated_request_for_completed_request_falls_back_to_common() { + // Originating request already unregistered (completion race / re-scoped + // task op): must fall back to Common, never a closed request-wise channel. + let (_handle, mut worker) = create_local_session("test-session", SessionConfig::default()); + let receiver = worker.establish_request_wise_channel().await.unwrap(); + let http_request_id = receiver.http_request_id.unwrap(); + let originating_id = RequestId::Number(7); + worker.register_resource( + ResourceKey::McpRequestId(originating_id.clone()), + http_request_id, + ); + worker.unregister_resource(&ResourceKey::McpRequestId(originating_id.clone())); + + let channel = worker.resolve_outbound_channel(&roots_request(Some(originating_id))); + assert!(matches!(channel, OutboundChannel::Common)); + } +} diff --git a/crates/rmcp/tests/test_sep_2260_request_association.rs b/crates/rmcp/tests/test_sep_2260_request_association.rs new file mode 100644 index 000000000..d4e20e1e9 --- /dev/null +++ b/crates/rmcp/tests/test_sep_2260_request_association.rs @@ -0,0 +1,273 @@ +#![cfg(all(feature = "server", feature = "client", not(feature = "local")))] +#![allow(deprecated)] + +use std::sync::{Arc, Mutex}; + +use rmcp::{ + ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError, ServiceExt, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, + CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, ProtocolVersion, + SamplingMessage, ServerCapabilities, ServerInfo, ServerRequest, + }, + service::RequestContext, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, Lines, ReadHalf, WriteHalf}, + sync::oneshot, +}; + +#[derive(Clone)] +struct SamplingServer { + outside: Arc>>>>, +} + +impl ServerHandler for SamplingServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let peer = context.peer.clone(); + let slot = self.outside.clone(); + + let use_generic = request.name == "sample_generic"; + tokio::spawn(async move { + let outside = if use_generic { + peer.send_request(ServerRequest::CreateMessageRequest( + CreateMessageRequest::new(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("standalone-generic")], + 16, + )), + )) + .await + .map(|_| ()) + } else { + peer.create_message(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("standalone")], + 16, + )) + .await + .map(|_| ()) + }; + if let Some(tx) = slot.lock().unwrap().take() { + let _ = tx.send(outside); + } + }); + + let nested = context + .peer + .create_message(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("nested")], + 16, + )) + .await; + nested.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; + Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) + } +} + +#[derive(Clone)] +struct SamplingClient; + +impl ClientHandler for SamplingClient { + async fn create_message( + &self, + _params: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text("pong"), + "test-model".to_string(), + ) + .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) + } + + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } +} + +#[tokio::test] +async fn nested_sampling_allowed_standalone_rejected() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let (tx, rx) = oneshot::channel(); + let server = SamplingServer { + outside: Arc::new(Mutex::new(Some(tx))), + }; + let server_handle = tokio::spawn(async move { + let running = server.serve(server_transport).await?; + running.waiting().await?; + anyhow::Ok(()) + }); + + let client = SamplingClient.serve(client_transport).await?; + + let result = client + .peer() + .call_tool(CallToolRequestParams::new("sample")) + .await?; + assert_eq!( + result.content.first().unwrap().as_text().unwrap().text, + "ok" + ); + + let outside = rx.await?; + assert!(matches!(outside, Err(ServiceError::McpError(_)))); + + client.cancel().await?; + let _ = server_handle.await?; + Ok(()) +} + +#[tokio::test] +async fn generic_send_request_bypass_rejected() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let (tx, rx) = oneshot::channel(); + let server = SamplingServer { + outside: Arc::new(Mutex::new(Some(tx))), + }; + let server_handle = tokio::spawn(async move { + let running = server.serve(server_transport).await?; + running.waiting().await?; + anyhow::Ok(()) + }); + + let client = SamplingClient.serve(client_transport).await?; + + let result = client + .peer() + .call_tool(CallToolRequestParams::new("sample_generic")) + .await?; + assert_eq!( + result.content.first().unwrap().as_text().unwrap().text, + "ok" + ); + + let outside = rx.await?; + assert!( + matches!(outside, Err(ServiceError::McpError(_))), + "generic send_request must not bypass SEP-2260 enforcement" + ); + + client.cancel().await?; + let _ = server_handle.await?; + Ok(()) +} + +// A compliant rmcp server cannot produce an unassociated server-to-client +// request at >= 2026-07-28 (send-side enforcement blocks it), so the client's +// receive-side enforcement is exercised with a raw JSON-RPC server. +type RawServer = ( + Lines>>, + WriteHalf, +); + +async fn raw_initialize(io: DuplexStream, protocol_version: &str) -> anyhow::Result { + let (read, mut write) = tokio::io::split(io); + let mut lines = BufReader::new(read).lines(); + let init: Value = serde_json::from_str(&lines.next_line().await?.expect("initialize request"))?; + assert_eq!(init["method"], "initialize"); + let response = json!({ + "jsonrpc": "2.0", + "id": init["id"], + "result": { + "protocolVersion": protocol_version, + "capabilities": {}, + "serverInfo": { "name": "raw-server", "version": "0.0.0" } + } + }); + write.write_all(format!("{response}\n").as_bytes()).await?; + let initialized: Value = + serde_json::from_str(&lines.next_line().await?.expect("initialized notification"))?; + assert_eq!(initialized["method"], "notifications/initialized"); + Ok((lines, write)) +} + +async fn raw_request(server: &mut RawServer, request: Value) -> anyhow::Result { + let (lines, write) = server; + write.write_all(format!("{request}\n").as_bytes()).await?; + Ok(serde_json::from_str( + &lines.next_line().await?.expect("response"), + )?) +} + +fn raw_sampling_request(id: u32) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "sampling/createMessage", + "params": { + "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }], + "maxTokens": 16 + } + }) +} + +#[tokio::test] +async fn unassociated_server_request_rejected_with_invalid_params() -> anyhow::Result<()> { + let (client_io, server_io) = tokio::io::duplex(4096); + let raw = tokio::spawn(async move { + let mut server = raw_initialize(server_io, "2026-07-28").await?; + raw_request(&mut server, raw_sampling_request(100)).await + }); + + let client = SamplingClient.serve(client_io).await?; + let response = raw.await??; + assert_eq!( + response["error"]["code"], -32602, + "SEP-2260: unassociated server-to-client request must be rejected with invalid params, got {response}" + ); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn unassociated_server_request_allowed_on_legacy_protocol() -> anyhow::Result<()> { + let (client_io, server_io) = tokio::io::duplex(4096); + let raw = tokio::spawn(async move { + let mut server = raw_initialize(server_io, "2025-11-25").await?; + raw_request(&mut server, raw_sampling_request(100)).await + }); + + let client = SamplingClient.serve(client_io).await?; + let response = raw.await??; + assert_eq!( + response["result"]["model"], "test-model", + "pre-2026-07-28 peers keep the permissive behavior, got {response}" + ); + + client.cancel().await?; + Ok(()) +} + +#[tokio::test] +async fn unassociated_ping_allowed() -> anyhow::Result<()> { + let (client_io, server_io) = tokio::io::duplex(4096); + let raw = tokio::spawn(async move { + let mut server = raw_initialize(server_io, "2026-07-28").await?; + raw_request( + &mut server, + json!({ "jsonrpc": "2.0", "id": 101, "method": "ping" }), + ) + .await + }); + + let client = SamplingClient.serve(client_io).await?; + let response = raw.await??; + assert!( + response.get("error").is_none(), + "SEP-2260 excepts ping from request association, got {response}" + ); + + client.cancel().await?; + Ok(()) +} diff --git a/crates/rmcp/tests/test_sep_2260_stream_routing.rs b/crates/rmcp/tests/test_sep_2260_stream_routing.rs new file mode 100644 index 000000000..824ef0c78 --- /dev/null +++ b/crates/rmcp/tests/test_sep_2260_stream_routing.rs @@ -0,0 +1,165 @@ +//! SEP-2260 end-to-end: in-handler server→client requests ride the originating +//! POST's SSE stream, never the standalone GET stream. +#![cfg(not(feature = "local"))] + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use futures::StreamExt; +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ElicitRequestParams, + ElicitationSchema, ServerCapabilities, ServerInfo, + }, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use serde_json::json; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +struct ElicitingServer; + +impl ServerHandler for ElicitingServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + // Never answered: the test only checks the request is emitted on the right stream. + let _ = context + .peer + .create_elicitation(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "need input".to_string(), + requested_schema: ElicitationSchema::new(BTreeMap::new()), + }) + .await; + Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into()) + } +} + +async fn start_server(ct: CancellationToken) -> String { + let service = StreamableHttpService::new( + move || Ok(ElicitingServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!( + "http://127.0.0.1:{}/mcp", + listener.local_addr().unwrap().port() + ); + let ct = ct.clone(); + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled().await }) + .await + .unwrap(); + }); + tokio::time::sleep(Duration::from_millis(100)).await; + url +} + +/// Read an SSE byte stream until `needle` appears or timeout. +async fn sse_contains(resp: reqwest::Response, needle: &str, timeout: Duration) -> bool { + let mut stream = resp.bytes_stream(); + tokio::time::timeout(timeout, async { + let mut buffer = String::new(); + while let Some(Ok(chunk)) = stream.next().await { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + if buffer.contains(needle) { + return true; + } + } + false + }) + .await + .unwrap_or(false) +} + +#[tokio::test] +async fn elicitation_rides_originating_post_stream_not_standalone_get() { + let ct = CancellationToken::new(); + let url = start_server(ct.clone()).await; + let client = reqwest::Client::new(); + + // 2025-11-25 is the latest session-carrying version; SEP-2567 serves + // 2026-07-28+ statelessly, with no standalone GET stream to test against. + let resp = client + .post(&url) + .header("Accept", "text/event-stream, application/json") + .header("Content-Type", "application/json") + .json(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": { "elicitation": {} }, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + })) + .send() + .await + .unwrap(); + assert!(resp.status().is_success()); + let session_id = resp + .headers() + .get("Mcp-Session-Id") + .expect("session id") + .to_str() + .unwrap() + .to_string(); + + client + .post(&url) + .header("Accept", "text/event-stream, application/json") + .header("Content-Type", "application/json") + .header("Mcp-Session-Id", &session_id) + .json(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) + .send() + .await + .unwrap(); + + // Standalone GET stream — must NEVER carry the elicitation request. + let get_stream = client + .get(&url) + .header("Accept", "text/event-stream") + .header("Mcp-Session-Id", &session_id) + .send() + .await + .unwrap(); + assert_eq!(get_stream.status(), 200); + + // The in-handler elicitation must appear on this tools/call SSE stream. + let post_stream = client + .post(&url) + .header("Accept", "text/event-stream, application/json") + .header("Content-Type", "application/json") + .header("Mcp-Session-Id", &session_id) + .json(&json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "ask", "arguments": {} } + })) + .send() + .await + .unwrap(); + assert!(post_stream.status().is_success()); + + assert!( + sse_contains(post_stream, "elicitation/create", Duration::from_secs(5)).await, + "elicitation request must be delivered on the originating POST SSE stream" + ); + assert!( + !sse_contains(get_stream, "elicitation/create", Duration::from_millis(500)).await, + "standalone GET stream must not carry the elicitation request" + ); + + ct.cancel(); +} From 5554a41d67125552ca145992fcb3fbd41ba033ef Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 23 Jul 2026 13:55:40 -0400 Subject: [PATCH 265/333] docs: update for 2026-07-28 version (#1032) --- README.md | 227 +++++++- crates/rmcp/README.md | 2 +- docs/OAUTH_SUPPORT.md | 4 +- docs/readme/README.zh-cn.md | 1026 ----------------------------------- 4 files changed, 211 insertions(+), 1048 deletions(-) delete mode 100644 docs/readme/README.zh-cn.md diff --git a/README.md b/README.md index 57f69c5bb..0863fc634 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ - - # RMCP [![Crates.io Version](https://img.shields.io/crates/v/rmcp)](https://crates.io/crates/rmcp) [![docs.rs](https://img.shields.io/docsrs/rmcp)](https://docs.rs/rmcp/latest/rmcp) @@ -17,7 +13,13 @@ This repository contains the following crates: - [rmcp](crates/rmcp): The core crate providing the RMCP protocol implementation - see [rmcp](crates/rmcp/README.md) - [rmcp-macros](crates/rmcp-macros): A procedural macro crate for generating RMCP tool implementations - see [rmcp-macros](crates/rmcp-macros/README.md) -For the full MCP specification, see [modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2025-11-25). +This SDK tracks the MCP **`2026-07-28`** draft (the current development spec) +while remaining fully compatible with the stable **`2025-11-25`** release and +earlier versions. New `2026-07-28` features — server discovery & negotiation, +transport-neutral subscriptions, long-running tasks, response caching, +multi-round-trip requests, and standard HTTP routing headers — are documented +below alongside the stable feature set. For the full MCP specification, see +[modelcontextprotocol.io](https://modelcontextprotocol.io/specification/draft). ## Table of Contents @@ -31,8 +33,11 @@ For the full MCP specification, see [modelcontextprotocol.io](https://modelconte - [Completions](#completions) - [Notifications](#notifications) - [Subscriptions](#subscriptions) +- [Multi-Round-Trip Requests](#multi-round-trip-requests) - [Tasks](#tasks-long-running-tool-invocations) - [Caching](#caching) +- [Standard HTTP Headers](#standard-http-headers) +- [Stateless Streamable HTTP](#stateless-streamable-http) - [Examples](#examples) - [OAuth Support](#oauth-support) - [Related Resources](#related-resources) @@ -43,10 +48,16 @@ For the full MCP specification, see [modelcontextprotocol.io](https://modelconte ### Import the crate -```toml -rmcp = { version = "0.16.0", features = ["server"] } -## or dev channel -rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main" } +Add the latest published version with cargo: + +```sh +cargo add rmcp --features server +``` + +Or use the dev channel: + +```sh +cargo add rmcp --features server --git https://github.com/modelcontextprotocol/rust-sdk --branch main ``` ### Third Dependencies @@ -174,7 +185,7 @@ let quit_reason = server.cancel().await?; Tools let servers expose callable functions to clients. Each tool has a name, description, and a JSON Schema for its parameters. Clients discover tools via `list_tools` and invoke them via `call_tool`. -**MCP Spec:** [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) +**MCP Spec:** [Tools](https://modelcontextprotocol.io/specification/draft/server/tools) ### Server-side @@ -210,6 +221,11 @@ async fn main() -> anyhow::Result<()> { The generated tool `inputSchema` and `outputSchema` are derived from the fields of `T`. The type name and documentation on `T` are ignored; only field names, field types, and field documentation are used. +> **`2026-07-28` (SEP-2106):** `outputSchema` may now be any JSON Schema type +> (not just `object`), and a tool result's `structuredContent` may be any JSON +> value (string, array, number, …) rather than only an object. Existing +> object-typed tools are unaffected. + When you need custom server metadata or multiple capabilities (tools + prompts), use explicit `#[tool_handler]`: ```rust,ignore @@ -258,7 +274,7 @@ let result = client.call_tool(CallToolRequestParams::new("add")).await?; Resources let servers expose data (files, database records, API responses) that clients can read. Each resource is identified by a URI and returns content as text or binary (base64-encoded) data. Resource templates allow servers to declare URI patterns with dynamic parameters. -**MCP Spec:** [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) +**MCP Spec:** [Resources](https://modelcontextprotocol.io/specification/draft/server/resources) ### Server-side @@ -393,7 +409,7 @@ impl ClientHandler for MyClient { Prompts are reusable message templates that servers expose to clients. They accept typed arguments and return conversation messages. The `#[prompt]` macro handles argument validation and routing automatically. -**MCP Spec:** [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) +**MCP Spec:** [Prompts](https://modelcontextprotocol.io/specification/draft/server/prompts) ### Server-side @@ -507,7 +523,7 @@ context.peer.notify_prompt_list_changed().await?; Sampling flips the usual direction: the server asks the client to run an LLM completion. The server sends a `create_message` request, the client processes it through its LLM, and returns the result. -**MCP Spec:** [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) +**MCP Spec:** [Sampling](https://modelcontextprotocol.io/specification/draft/client/sampling) ### Server-side (requesting sampling) @@ -579,7 +595,7 @@ impl ClientHandler for MyClient { Roots tell servers which directories or projects the client is working in. A root is a URI (typically `file://`) pointing to a workspace or repository. Servers can query roots to know where to look for files and how to scope their work. -**MCP Spec:** [Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) +**MCP Spec:** [Roots](https://modelcontextprotocol.io/specification/draft/client/roots) ### Server-side @@ -644,7 +660,7 @@ client.notify_roots_list_changed().await?; Servers can send structured log messages to clients. The client sets a minimum severity level, and the server sends messages through the peer notification interface. -**MCP Spec:** [Logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) +**MCP Spec:** [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging) ### Server-side @@ -717,7 +733,7 @@ client.set_level(SetLevelRequestParams::new(LoggingLevel::Warning)).await?; Completions give auto-completion suggestions for prompt or resource template arguments. As a user fills in arguments, the client can ask the server for suggestions based on what's already been entered. -**MCP Spec:** [Completions](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) +**MCP Spec:** [Completions](https://modelcontextprotocol.io/specification/draft/server/utilities/completion) ### Server-side @@ -799,7 +815,7 @@ let result = client.complete(CompleteRequestParams::new( Notifications are fire-and-forget messages -- no response is expected. They cover progress updates, cancellation, and lifecycle events. Both sides can send and receive them. -**MCP Spec:** [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/notifications) +**MCP Spec:** [Notifications](https://modelcontextprotocol.io/specification/draft/basic#notifications) ### Progress notifications @@ -970,6 +986,81 @@ and [client](examples/clients/src/subscriptions_streamhttp.rs) examples. --- +## Multi-Round-Trip Requests + +Protocol `2026-07-28` adds Multi-Round-Trip Requests (MRTR, SEP-2322): a server +can answer a `tools/call`, `prompts/get`, or `resources/read` with an +`InputRequiredResult` instead of a final result, asking the client to fulfill +one or more embedded server requests (elicitation, sampling, or roots) and then +retry. The exchange is stateless — the server carries its progress in an opaque +`requestState` that the client echoes back verbatim. + +**MCP Spec:** [Multiple Round-Trip Requests](https://modelcontextprotocol.io/specification/draft/server/tools#multiple-round-trip-requests) + +### Server-side + +Return an `InputRequiredResult` via the outcome enum for the method +(`CallToolResponse`, `GetPromptResponse`, or `ReadResourceResponse`). The SDK +only forwards it to peers that negotiated `2026-07-28` or newer — older peers +get a protocol error instead. + +```rust, ignore +async fn call_tool(&self, request: CallToolRequestParams, _ctx: RequestContext) + -> Result +{ + match request.request_state { + // First round: ask the client for input, seal progress into requestState. + None => { + let mut input_requests = InputRequests::new(); + input_requests.insert("city".into(), InputRequest::Elicitation(elicit_city())); + let sealed = self.codec.seal_json(&json!({ "awaiting": "city" }))?; + Ok(InputRequiredResult::new(Some(input_requests), Some(sealed)).into()) + } + // Retry round: verify the echoed state, read the responses, finish. + Some(sealed) => { + let _state = self.codec.open_json(&sealed) + .map_err(|_| ErrorData::invalid_params("tampered request state", None))?; + let city = request.input_responses.as_ref() + .and_then(|r| r.get("city")); + Ok(CallToolResult::success(vec![ContentBlock::text("It is sunny.")]).into()) + } + } +} +``` + +> **`requestState` is untrusted.** The client echoes it back verbatim, so a +> stateless server that stores meaningful data in it MUST verify integrity +> first. Enable the `request-state` feature and use `RequestStateCodec` to seal +> and open it (HMAC-tagged), or keep state server-side and use `requestState` +> only as an opaque handle. + +### Client-side + +The high-level `call_tool`, `get_prompt`, and `read_resource` helpers drive MRTR +automatically: they fulfill each embedded request through the local +`ClientHandler` and retry, up to `DEFAULT_MRTR_MAX_ROUNDS` (10). + +```rust, ignore +// Auto mode: the SDK fulfills embedded requests and retries for you. +let result = client.call_tool(CallToolRequestParams::new("weather")).await?; + +// Choose a custom round cap. +let result = client + .call_tool_with_mrtr_max_rounds(CallToolRequestParams::new("weather"), 3) + .await?; + +// Manual mode: get the intermediate InputRequiredResult and drive rounds yourself. +match client.call_tool_once(CallToolRequestParams::new("weather")).await? { + CallToolResponse::InputRequired(input_required) => { /* fulfill + retry */ } + CallToolResponse::Complete(result) => { /* done */ } + _ => {} +} +``` + +**Example:** [`examples/servers/src/mrtr.rs`](examples/servers/src/mrtr.rs) (end-to-end server + client) + +--- + ## Tasks (long-running tool invocations) `rmcp` implements the [MCP Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) @@ -1050,6 +1141,104 @@ peer.clear_response_cache().await; > last cached response (even if expired) as `Ok(..)` instead of an error. Set > `with_serve_stale_on_error(false)` if callers must observe fetch failures. +## Standard HTTP Headers + +Protocol `2026-07-28` standardizes a set of Streamable HTTP request headers +(SEP-2243) so proxies and gateways can route MCP traffic without parsing the +JSON body: `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*`. `rmcp` emits and +validates these automatically once a connection negotiates `2026-07-28` or +newer — no call-site changes are required, and older negotiated versions are +untouched. + +**MCP Spec:** [Header standardization](https://modelcontextprotocol.io/specification/draft/basic/transports#header) + +- `Mcp-Method` — the JSON-RPC method (e.g. `tools/call`). +- `Mcp-Name` — the target name, sourced from `params.name` (`tools/call`, + `prompts/get`), `params.uri` (`resources/*`), or `params.taskId` (`tasks/*`). +- `Mcp-Param-*` — selected `tools/call` arguments, promoted from the tool's + input schema. + +To promote a tool argument into a routing header, annotate the top-level schema +property with `x-mcp-header`: + +```rust, ignore +// A `region` argument surfaces as the `Mcp-Param-Region` request header. +let schema = serde_json::json!({ + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } +}); +``` + +Annotations must be non-empty RFC 9110 tokens, case-insensitively unique, and +applied only to top-level primitive (`string`/`integer`/`boolean`) properties. +Values that cannot travel as a bare header (leading/trailing whitespace, +control/non-ASCII characters) are transparently Base64-wrapped as +`=?base64??=`. + +--- + +## Stateless Streamable HTTP + +Per SEP-2567, `rmcp` serves the `2026-07-28` draft statelessly **automatically**: +no `Mcp-Session-Id`, no standalone GET/DELETE stream, and no `Last-Event-ID` +resumption. The `legacy_session_mode` flag below only controls behavior for +*legacy* protocol versions (`< 2026-07-28`). + +**MCP Spec:** [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) + +### Server-side + +A default server already serves `2026-07-28` clients statelessly. To also serve +*legacy* clients without sessions, disable `legacy_session_mode` (formerly +`stateful_mode`; builder `with_stateful_mode`). Optionally set +`with_json_response(true)` so simple request/response tools reply with a single +`application/json` body instead of an SSE stream (the server still falls back to +`text/event-stream` if a handler emits a notification or server request first): + +```rust, ignore +use rmcp::transport::streamable_http_server::{ + StreamableHttpService, StreamableHttpServerConfig, + session::local::LocalSessionManager, +}; + +let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) // stateless for legacy versions too + .with_json_response(true); // plain JSON replies for simple tools + +let service = StreamableHttpService::new( + || Ok(Counter::new()), // a fresh handler per request + LocalSessionManager::default().into(), + config, +); + +// `StreamableHttpService` is a Tower service — mount it on any router. +let router = axum::Router::new().nest_service("/mcp", service); +``` + +> Because there is no per-session state, the `service_factory` runs per request. +> Keep shared state (DB pools, caches) in a `Clone` handle captured by the +> closure; don't rely on in-memory state surviving between requests. + +### Client-side + +The Streamable HTTP client transport allows stateless operation by default +(`allow_stateless: true`), so no configuration is needed to talk to a stateless +server — it simply omits the session header when the server doesn't issue one: + +```rust, ignore +use rmcp::transport::StreamableHttpClientTransport; + +// Defaults are stateless-friendly. +let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); +let client = ClientInfo::default().serve(transport).await?; +``` + +**Example:** [`examples/servers/src/counter_streamhttp.rs`](examples/servers/src/counter_streamhttp.rs) (server), [`examples/clients/src/streamable_http.rs`](examples/clients/src/streamable_http.rs) (client) + +--- + ## Examples See [examples](examples/README.md). @@ -1060,8 +1249,8 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. ## Related Resources -- [MCP Specification](https://modelcontextprotocol.io/specification/2025-11-25) -- [Schema](https://github.com/modelcontextprotocol/specification/blob/main/schema/2025-11-25/schema.ts) +- [MCP Specification](https://modelcontextprotocol.io/specification/draft) +- [Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) ## Related Projects diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index c133e40e8..742b819e3 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -11,7 +11,7 @@ -The official Rust SDK for the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25). Build MCP servers that expose tools, resources, and prompts to AI assistants — or build clients that connect to them. +The official Rust SDK for the [Model Context Protocol](https://modelcontextprotocol.io/specification/draft). Build MCP servers that expose tools, resources, and prompts to AI assistants — or build clients that connect to them. For **getting started**, **usage guides**, and **full MCP feature documentation** (resources, prompts, sampling, roots, logging, completions, subscriptions, etc.), see the [main README](../../README.md). diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 16809a407..63622ed4b 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -1,6 +1,6 @@ # Model Context Protocol OAuth Authorization -This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP 2025-11-25 Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization/). +This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/). ## Features @@ -231,7 +231,7 @@ If you encounter authorization issues, check the following: ## References -- [MCP Authorization Specification (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization/) +- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/) - [OAuth 2.1 Specification Draft](https://oauth.net/2.1/) - [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) - [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591) diff --git a/docs/readme/README.zh-cn.md b/docs/readme/README.zh-cn.md deleted file mode 100644 index 55518f095..000000000 --- a/docs/readme/README.zh-cn.md +++ /dev/null @@ -1,1026 +0,0 @@ - - -# RMCP -[![Crates.io Version](https://img.shields.io/crates/v/rmcp)](https://crates.io/crates/rmcp) -[![docs.rs](https://img.shields.io/docsrs/rmcp)](https://docs.rs/rmcp/latest/rmcp) -[![CI](https://github.com/modelcontextprotocol/rust-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/modelcontextprotocol/rust-sdk/actions/workflows/ci.yml) -[![License](https://img.shields.io/crates/l/rmcp)](../../LICENSE) - -一个基于 tokio 异步运行时的官方 Rust Model Context Protocol SDK 实现。 - -> **迁移到 1.x?** 请参阅 [迁移指南](https://github.com/modelcontextprotocol/rust-sdk/discussions/716) 了解破坏性变更和升级说明。 - -本仓库包含以下 crate: - -- [rmcp](../../crates/rmcp):实现 RMCP 协议的核心库 - 详见 [rmcp](../../crates/rmcp/README.md) -- [rmcp-macros](../../crates/rmcp-macros):用于生成 RMCP 工具实现的过程宏库 - 详见 [rmcp-macros](../../crates/rmcp-macros/README.md) - -完整的 MCP 规范请参阅 [modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2025-11-25)。 - -## 目录 - -- [使用](#使用) -- [工具](#工具) -- [资源](#资源) -- [提示词](#提示词) -- [采样](#采样) -- [根目录](#根目录) -- [日志](#日志) -- [补全](#补全) -- [通知](#通知) -- [订阅](#订阅) -- [任务](#任务长时间运行的工具调用) -- [示例](#示例) -- [OAuth 支持](#oauth-支持) -- [相关资源](#相关资源) -- [相关项目](#相关项目) -- [开发](#开发) - -## 使用 - -### 导入 - -```toml -rmcp = { version = "0.16.0", features = ["server"] } -## 或使用最新开发版本 -rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main" } -``` -### 第三方依赖 - -基本依赖: -- [tokio](https://github.com/tokio-rs/tokio) -- [serde](https://github.com/serde-rs/serde) -JSON Schema 生成 (version 2020-12): -- [schemars](https://github.com/GREsau/schemars) - -### 构建客户端 - -
-启动客户端 - -```rust, ignore -use rmcp::{ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}}; -use tokio::process::Command; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let client = ().serve(TokioChildProcess::new(Command::new("npx").configure(|cmd| { - cmd.arg("-y").arg("@modelcontextprotocol/server-everything"); - }))?).await?; - Ok(()) -} -``` -
- -### 客户端生命周期模式 - -`serve()` 使用传统 MCP 生命周期:客户端发送 `initialize`,接收协商后的服务端信息, -然后发送 `notifications/initialized`。如需显式选择其他生命周期,请使用 -[`ClientServiceExt::serve_with_lifecycle`](../../crates/rmcp/src/service/client.rs): - -```rust, ignore -use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion}; - -// 直接通过 server/discover 启动,并在每个请求中携带客户端元数据。 -let client = ClientInfo::default() - .serve_with_lifecycle( - transport, - ClientLifecycleMode::Discover { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - }, - ) - .await?; - -// 或先尝试发现生命周期;当传统服务端报告未实现 server/discover 时回退。 -let client = ClientInfo::default() - .serve_with_lifecycle( - transport, - ClientLifecycleMode::Auto { - preferred_versions: vec![ProtocolVersion::V_2026_07_28], - legacy_version: Some(ProtocolVersion::V_2025_11_25), - }, - ) - .await?; -``` - -`ClientLifecycleMode::Initialize` 等同于现有的 `serve()` 行为。发现启动不会发送 -`notifications/initialized`;发现过程即完成启动,后续每个请求都会在 `_meta` -中携带协议版本、客户端信息和客户端能力。 - -### 构建服务端 - -
-构建传输层 - -```rust, ignore -use tokio::io::{stdin, stdout}; -let transport = (stdin(), stdout()); -``` - -
- -
-构建服务 - -你可以通过 [`ServerHandler`](../../crates/rmcp/src/handler/server.rs) 或 [`ClientHandler`](../../crates/rmcp/src/handler/client.rs) 轻松构建服务。 - -```rust, ignore -let service = common::counter::Counter::new(); -``` -
- -
-启动服务端 - -```rust, ignore -// 此调用将完成初始化过程 -let server = service.serve(transport).await?; -``` -
- -
-与服务端交互 - -服务端初始化完成后,你可以发送请求或通知: - -```rust, ignore -// 请求 -let roots = server.list_roots().await?; - -// 或发送通知 -server.notify_cancelled(...).await?; -``` -
- -
-等待服务停止 - -```rust, ignore -let quit_reason = server.waiting().await?; -// 或将其取消 -let quit_reason = server.cancel().await?; -``` -
- ---- - -## 工具 - -工具允许服务端向客户端暴露可调用的函数。每个工具都有名称、描述和参数的 JSON Schema。客户端通过 `list_tools` 发现工具,通过 `call_tool` 调用工具。 - -**MCP 规范:** [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) - -### 服务端 - -`#[tool]`、`#[tool_router]` 和 `#[tool_handler]` 宏负责所有连接工作。对于纯工具服务端,可以使用 `#[tool_router(server_handler)]` 来省略单独的 `ServerHandler` 实现: - -```rust,ignore -use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, ServiceExt, transport::stdio}; - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -struct AddParams { - a: i32, - b: i32, -} - -#[derive(Clone)] -struct Calculator; - -#[tool_router(server_handler)] -impl Calculator { - #[tool(description = "Add two numbers")] - fn add(&self, Parameters(AddParams { a, b }): Parameters) -> String { - (a + b).to_string() - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let service = Calculator.serve(stdio()).await?; - service.waiting().await?; - Ok(()) -} -``` - -当需要自定义服务端元数据或多种能力(工具 + 提示词)时,使用显式的 `#[tool_handler]`: - -```rust,ignore -use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, tool_handler, ServerHandler, ServiceExt}; - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -struct AddParams { - a: i32, - b: i32, -} - -#[derive(Clone)] -struct Calculator; - -#[tool_router] -impl Calculator { - #[tool(description = "Add two numbers")] - fn add(&self, Parameters(AddParams { a, b }): Parameters) -> String { - (a + b).to_string() - } -} - -#[tool_handler(name = "calculator", version = "1.0.0", instructions = "A simple calculator")] -impl ServerHandler for Calculator {} -``` - -完整的宏文档请参阅 [`crates/rmcp-macros`](../../crates/rmcp-macros/README.md)。 - -### 客户端 - -```rust,ignore -use rmcp::model::CallToolRequestParams; - -// 列出所有工具 -let tools = client.list_all_tools().await?; - -// 按名称调用工具 -let result = client.call_tool(CallToolRequestParams::new("add")).await?; -``` - -**示例:** [`examples/servers/src/common/calculator.rs`](../../examples/servers/src/common/calculator.rs)(服务端),[`examples/servers/src/calculator_stdio.rs`](../../examples/servers/src/calculator_stdio.rs)(stdio 运行器) - ---- - -## 资源 - -资源允许服务端向客户端暴露数据(文件、数据库记录、API 响应)供其读取。每个资源通过 URI 标识,返回文本或二进制(base64 编码)内容。资源模板允许服务端声明带有动态参数的 URI 模式。 - -**MCP 规范:** [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) - -### 服务端 - -在 `ServerHandler` trait 上实现 `list_resources()`、`read_resource()`,以及可选的 `list_resource_templates()`。在 `get_info()` 中启用资源能力。 - -```rust -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, - model::*, - service::RequestContext, - transport::stdio, -}; -use serde_json::json; - -#[derive(Clone)] -struct MyServer; - -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_resources() - .build(), - ) - } - - async fn list_resources( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListResourcesResult { - resources: vec![ - Resource::new("file:///config.json", "config"), - Resource::new("memo://insights", "insights"), - ], - next_cursor: None, - meta: None, - }) - } - - async fn read_resource( - &self, - request: ReadResourceRequestParams, - _context: RequestContext, - ) -> Result { - match request.uri.as_str() { - "file:///config.json" => Ok(ReadResourceResult::new(vec![ - ResourceContents::text(r#"{"key": "value"}"#, &request.uri), - ])), - "memo://insights" => Ok(ReadResourceResult::new(vec![ - ResourceContents::text("Analysis results...", &request.uri), - ])), - _ => Err(McpError::resource_not_found( - "resource_not_found", - Some(json!({ "uri": request.uri })), - )), - } - } - - async fn list_resource_templates( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListResourceTemplatesResult { - resource_templates: vec![], - next_cursor: None, - meta: None, - }) - } -} -``` - -### 客户端 - -```rust -use rmcp::model::{ReadResourceRequestParams}; - -// 列出所有资源(自动处理分页) -let resources = client.list_all_resources().await?; - -// 通过 URI 读取特定资源 -let result = client.read_resource( - ReadResourceRequestParams::new("file:///config.json"), -).await?; - -// 列出资源模板 -let templates = client.list_all_resource_templates().await?; -``` - -### 通知 - -服务端可以在资源列表变更或特定资源更新时通知客户端: - -```rust -// 通知资源列表已变更(客户端应重新获取) -context.peer.notify_resource_list_changed().await?; - -// 通知特定资源已更新 -context.peer.notify_resource_updated( - ResourceUpdatedNotificationParam::new("file:///config.json"), -).await?; -``` - -客户端通过 `ClientHandler` 处理这些通知: - -```rust -impl ClientHandler for MyClient { - async fn on_resource_list_changed( - &self, - _context: NotificationContext, - ) { - // 重新获取资源列表 - } - - async fn on_resource_updated( - &self, - params: ResourceUpdatedNotificationParam, - _context: NotificationContext, - ) { - // 重新读取 params.uri 对应的资源 - } -} -``` - -**示例:** [`examples/servers/src/common/counter.rs`](../../examples/servers/src/common/counter.rs)(服务端),[`examples/clients/src/everything_stdio.rs`](../../examples/clients/src/everything_stdio.rs)(客户端) - ---- - -## 提示词 - -提示词是服务端向客户端暴露的可复用消息模板。它们接受类型化参数并返回对话消息。`#[prompt]` 宏自动处理参数验证和路由。 - -**MCP 规范:** [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) - -### 服务端 - -使用 `#[prompt_router]`、`#[prompt]` 和 `#[prompt_handler]` 宏以声明式方式定义提示词。参数定义为派生 `JsonSchema` 的结构体。 - -```rust -use rmcp::{ - ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, - handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, - model::*, - prompt, prompt_handler, prompt_router, - schemars::JsonSchema, - service::RequestContext, - transport::stdio, -}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct CodeReviewArgs { - #[schemars(description = "Programming language of the code")] - pub language: String, - #[schemars(description = "Focus areas for the review")] - pub focus_areas: Option>, -} - -#[derive(Clone)] -pub struct MyServer { - prompt_router: PromptRouter, -} - -#[prompt_router] -impl MyServer { - fn new() -> Self { - Self { prompt_router: Self::prompt_router() } - } - - /// 无参数的简单提示词 - #[prompt(name = "greeting", description = "A simple greeting")] - async fn greeting(&self) -> Vec { - vec![PromptMessage::new_text( - Role::User, - "Hello! How can you help me today?", - )] - } - - /// 带类型化参数的提示词 - #[prompt(name = "code_review", description = "Review code in a given language")] - async fn code_review( - &self, - Parameters(args): Parameters, - ) -> Result { - let focus = args.focus_areas - .unwrap_or_else(|| vec!["correctness".into()]); - - Ok(GetPromptResult::new(vec![ - PromptMessage::new_text( - Role::User, - format!("Review my {} code. Focus on: {}", args.language, focus.join(", ")), - ), - ]) - .with_description(format!("Code review for {}", args.language))) - } -} - -#[prompt_handler] -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_prompts().build()) - } -} -``` - -提示词函数支持以下返回类型: -- `Vec` -- 简单消息列表 -- `GetPromptResult` -- 带可选描述的消息 -- `Result` -- 以上任一类型,附带错误处理 - -### 客户端 - -```rust -use rmcp::model::GetPromptRequestParams; - -// 列出所有提示词 -let prompts = client.list_all_prompts().await?; - -// 带参数获取提示词 -let result = client.get_prompt(GetPromptRequestParams { - meta: None, - name: "code_review".into(), - arguments: Some(rmcp::object!({ - "language": "Rust", - "focus_areas": ["performance", "safety"] - })), -}).await?; -``` - -### 通知 - -```rust -// 服务端:通知可用提示词已变更 -context.peer.notify_prompt_list_changed().await?; -``` - -**示例:** [`examples/servers/src/prompt_stdio.rs`](../../examples/servers/src/prompt_stdio.rs)(服务端),[`examples/clients/src/everything_stdio.rs`](../../examples/clients/src/everything_stdio.rs)(客户端) - ---- - -## 采样 - -采样反转了通常的方向:服务端请求客户端执行 LLM 补全。服务端发送 `create_message` 请求,客户端通过其 LLM 处理并返回结果。 - -**MCP 规范:** [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) - -### 服务端(请求采样) - -通过 `context.peer.create_message()` 访问客户端的采样能力: - -```rust -use rmcp::model::*; - -// 在 ServerHandler 方法内部(例如 call_tool): -let response = context.peer.create_message( - CreateMessageRequestParams::new( - vec![SamplingMessage::user_text("Explain this error: connection refused")], - 150, - ) - .with_model_preferences( - ModelPreferences::new() - .with_hints(vec![ModelHint::new("claude")]) - .with_cost_priority(0.3) - .with_speed_priority(0.8) - .with_intelligence_priority(0.7), - ) - .with_system_prompt("You are a helpful assistant.") - .with_include_context(ContextInclusion::None) - .with_temperature(0.7), -).await?; - -// 提取响应文本 -let text = response.message.content - .first() - .and_then(|c| c.as_text()) - .map(|t| &t.text); -``` - -### 客户端(处理采样) - -在客户端实现 `ClientHandler::create_message()`。这是你调用实际 LLM 的地方: - -```rust -use rmcp::{ClientHandler, model::*, service::{RequestContext, RoleClient}}; - -#[derive(Clone, Default)] -struct MyClient; - -impl ClientHandler for MyClient { - async fn create_message( - &self, - params: CreateMessageRequestParams, - _context: RequestContext, - ) -> Result { - // 转发到你的 LLM,或返回模拟响应: - let response_text = call_your_llm(¶ms.messages).await; - - Ok(CreateMessageResult::new( - SamplingMessage::assistant_text(response_text), - "my-model".into(), - ) - .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) - } -} -``` - -**示例:** [`examples/servers/src/sampling_stdio.rs`](../../examples/servers/src/sampling_stdio.rs)(服务端),[`examples/clients/src/sampling_stdio.rs`](../../examples/clients/src/sampling_stdio.rs)(客户端) - ---- - -## 根目录 - -根目录告诉服务端客户端正在使用哪些目录或项目。根目录是一个 URI(通常为 `file://`),指向工作区或代码仓库。服务端可以查询根目录以了解在哪里查找文件以及如何限定工作范围。 - -**MCP 规范:** [Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) - -### 服务端 - -向客户端请求根目录列表,并处理变更通知: - -```rust -use rmcp::{ServerHandler, model::*, service::{NotificationContext, RoleServer}}; - -impl ServerHandler for MyServer { - // 向客户端查询根目录 - async fn call_tool( - &self, - request: CallToolRequestParams, - context: RequestContext, - ) -> Result { - let roots = context.peer.list_roots().await?; - // 使用 roots.roots 了解工作区边界 - // ... - } - - // 当客户端的根目录列表变更时调用 - async fn on_roots_list_changed( - &self, - _context: NotificationContext, - ) { - // 重新获取根目录以保持最新 - } -} -``` - -### 客户端 - -客户端声明根目录能力并实现 `list_roots()`: - -```rust -use rmcp::{ClientHandler, model::*}; - -impl ClientHandler for MyClient { - async fn list_roots( - &self, - _context: RequestContext, - ) -> Result { - Ok(ListRootsResult::new(vec![ - Root::new("file:///home/user/project").with_name("My Project"), - ])) - } -} -``` - -客户端在根目录变更时通知服务端: - -```rust -// 添加或移除工作区根目录后: -client.notify_roots_list_changed().await?; -``` - ---- - -## 日志 - -服务端可以向客户端发送结构化日志消息。客户端设置最低严重级别,服务端通过对等通知接口发送消息。 - -**MCP 规范:** [Logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) - -### 服务端 - -启用日志能力,处理客户端的级别变更,并通过对等端发送日志消息: - -```rust -use rmcp::{ServerHandler, model::*, service::RequestContext}; - -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_logging() - .build(), - ) - } - - // 客户端设置最低日志级别 - async fn set_level( - &self, - request: SetLevelRequestParams, - _context: RequestContext, - ) -> Result<(), ErrorData> { - // 存储 request.level 并据此过滤后续日志消息 - Ok(()) - } -} - -// 在任何可以访问 peer 的处理器中发送日志消息: -context.peer.notify_logging_message( - LoggingMessageNotificationParam::new( - LoggingLevel::Info, - serde_json::json!({ - "message": "Processing completed", - "items_processed": 42 - }), - ) - .with_logger("my-server"), -).await?; -``` - -可用日志级别(从低到高):`Debug`、`Info`、`Notice`、`Warning`、`Error`、`Critical`、`Alert`、`Emergency`。 - -### 客户端 - -客户端通过 `ClientHandler` 处理传入的日志消息: - -```rust -impl ClientHandler for MyClient { - async fn on_logging_message( - &self, - params: LoggingMessageNotificationParam, - _context: NotificationContext, - ) { - println!("[{}] {}: {}", params.level, - params.logger.unwrap_or_default(), params.data); - } -} -``` - -客户端也可以设置服务端的日志级别: - -```rust -client.set_level(SetLevelRequestParams::new(LoggingLevel::Warning)).await?; -``` - ---- - -## 补全 - -补全为提示词或资源模板参数提供自动补全建议。当用户填写参数时,客户端可以根据已输入的内容向服务端请求建议。 - -**MCP 规范:** [Completions](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) - -### 服务端 - -启用补全能力并实现 `complete()` 处理器。使用 `request.context` 检查已填写的参数: - -```rust -use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; - -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_completions() - .enable_prompts() - .build(), - ) - } - - async fn complete( - &self, - request: CompleteRequestParams, - _context: RequestContext, - ) -> Result { - let values = match &request.r#ref { - Reference::Prompt(prompt_ref) if prompt_ref.name == "sql_query" => { - match request.argument.name.as_str() { - "operation" => vec!["SELECT", "INSERT", "UPDATE", "DELETE"], - "table" => vec!["users", "orders", "products"], - "columns" => { - // 根据已填写的参数调整建议 - if let Some(ctx) = &request.context { - if let Some(op) = ctx.get_argument("operation") { - match op.to_uppercase().as_str() { - "SELECT" | "UPDATE" => { - vec!["id", "name", "email", "created_at"] - } - _ => vec![], - } - } else { vec![] } - } else { vec![] } - } - _ => vec![], - } - } - _ => vec![], - }; - - // 根据用户的部分输入进行过滤 - let filtered: Vec = values.into_iter() - .map(String::from) - .filter(|v| v.to_lowercase().contains(&request.argument.value.to_lowercase())) - .collect(); - - let completion = CompletionInfo::with_pagination(filtered, None, false) - .map_err(|e| McpError::internal_error(e, None))?; - Ok(CompleteResult::new(completion)) - } -} -``` - -### 客户端 - -```rust -use rmcp::model::*; - -let result = client.complete(CompleteRequestParams::new( - Reference::for_prompt("sql_query"), - ArgumentInfo::new("operation", "SEL"), -)).await?; - -// result.completion.values 包含建议,例如 ["SELECT"] -``` - -**示例:** [`examples/servers/src/completion_stdio.rs`](../../examples/servers/src/completion_stdio.rs) - ---- - -## 通知 - -通知是即发即忘的消息——不需要响应。它们涵盖进度更新、取消和生命周期事件。双方都可以发送和接收通知。 - -**MCP 规范:** [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/notifications) - -### 进度通知 - -服务端可以在长时间运行的操作中报告进度: - -```rust -use rmcp::model::*; - -// 在工具处理器内部: -for i in 0..total_items { - process_item(i).await; - - context.peer.notify_progress( - ProgressNotificationParam::new( - ProgressToken(NumberOrString::Number(i as i64)), - i as f64, - ) - .with_total(total_items as f64) - .with_message(format!("Processing item {}/{}", i + 1, total_items)), - ).await?; -} -``` - -### 取消 - -任一方都可以取消正在进行的请求: - -```rust -// 发送取消通知 -context.peer.notify_cancelled(CancelledNotificationParam::new( - Some(the_request_id), - Some("User requested cancellation".into()), -)).await?; -``` - -在 `ServerHandler` 或 `ClientHandler` 中处理取消: - -```rust -impl ServerHandler for MyServer { - async fn on_cancelled( - &self, - params: CancelledNotificationParam, - _context: NotificationContext, - ) { - // 中止 params.request_id 对应的工作 - } -} -``` - -### 初始化通知 - -传统客户端在 `initialize` 握手完成后发送 `initialized` 通知。 -使用 `ClientLifecycleMode::Discover` 的客户端不会发送此通知: - -```rust -// 在传统 serve() 握手过程中由 rmcp 自动发送。 -// 服务端通过以下方式处理: -impl ServerHandler for MyServer { - async fn on_initialized( - &self, - _context: NotificationContext, - ) { - // 服务端已准备好接收请求 - } -} -``` - -### 列表变更通知 - -当可用的工具、提示词或资源发生变更时,通知客户端: - -```rust -context.peer.notify_tool_list_changed().await?; -context.peer.notify_prompt_list_changed().await?; -context.peer.notify_resource_list_changed().await?; -``` - -**示例:** [`examples/servers/src/common/progress_demo.rs`](../../examples/servers/src/common/progress_demo.rs) - ---- - -## 订阅 - -客户端可以订阅特定资源。当订阅的资源发生变更时,服务端发送通知,客户端可以重新读取该资源。 - -**MCP 规范:** [Resources - Subscriptions](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#subscriptions) - -### 服务端 - -在资源能力中启用订阅,并实现 `subscribe()` / `unsubscribe()` 处理器: - -```rust -use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; -use std::sync::Arc; -use tokio::sync::Mutex; -use std::collections::HashSet; - -#[derive(Clone)] -struct MyServer { - subscriptions: Arc>>, -} - -impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_resources() - .enable_resources_subscribe() - .build(), - ) - } - - async fn subscribe( - &self, - request: SubscribeRequestParams, - _context: RequestContext, - ) -> Result<(), McpError> { - self.subscriptions.lock().await.insert(request.uri); - Ok(()) - } - - async fn unsubscribe( - &self, - request: UnsubscribeRequestParams, - _context: RequestContext, - ) -> Result<(), McpError> { - self.subscriptions.lock().await.remove(&request.uri); - Ok(()) - } -} -``` - -当订阅的资源发生变更时,通知客户端: - -```rust -// 检查资源是否有订阅者,然后通知 -context.peer.notify_resource_updated( - ResourceUpdatedNotificationParam::new("file:///config.json"), -).await?; -``` - -### 客户端 - -```rust -use rmcp::model::*; - -// 订阅资源更新 -client.subscribe(SubscribeRequestParams::new("file:///config.json")).await?; - -// 不再需要时取消订阅 -client.unsubscribe(UnsubscribeRequestParams::new("file:///config.json")).await?; -``` - -在 `ClientHandler` 中处理更新通知: - -```rust -impl ClientHandler for MyClient { - async fn on_resource_updated( - &self, - params: ResourceUpdatedNotificationParam, - _context: NotificationContext, - ) { - // 重新读取 params.uri 对应的资源 - } -} -``` - ---- - -## 任务(长时间运行的工具调用) - -`rmcp` 支持 SEP-1319 中定义的[基于任务的工具调用](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)流程。为工具添加 `execution(task_support = "required" | "optional")` 注解,并在 `ServerHandler` 实现上添加 `#[task_handler]` —— `enqueue_task`、`tasks/list`、`tasks/get`、`tasks/result` 和 `tasks/cancel` 将在 `OperationProcessor` 之上自动生成。 - -```rust, ignore -#[tool( - description = "Sum two numbers after a 2-second delay", - execution(task_support = "required") -)] -async fn slow_sum(/* ... */) -> Result { /* ... */ } - -#[tool_handler] -#[task_handler] -impl ServerHandler for TaskDemo {} -``` - -完整的端到端示例请参阅 [`servers_task_stdio`](../../examples/servers/src/task_stdio.rs) 及对应的 [`clients_task_stdio`](../../examples/clients/src/task_stdio.rs)。 - ---- - -## 示例 - -查看 [examples](../../examples/README.md)。 - -## OAuth 支持 - -查看 [OAuth 支持](../OAUTH_SUPPORT.md) 了解详情。 - -## 相关资源 - -- [MCP 规范](https://modelcontextprotocol.io/specification/2025-11-25) -- [Schema](https://github.com/modelcontextprotocol/specification/blob/main/schema/2025-11-25/schema.ts) - -## 相关项目 - -### 扩展 `rmcp` - -- [rmcp-actix-web](https://gitlab.com/lx-industries/rmcp-actix-web) - 基于 `actix_web` 的 `rmcp` 后端 -- [rmcp-openapi](https://gitlab.com/lx-industries/rmcp-openapi) - 将 OpenAPI 定义的端点转换为 MCP 工具 - -### 基于 `rmcp` 构建 - -- [goose](https://github.com/block/goose) - 一个超越代码建议的开源、可扩展 AI 智能体 -- [apollo-mcp-server](https://github.com/apollographql/apollo-mcp-server) - 通过 Apollo GraphOS 将 AI 智能体连接到 GraphQL API 的 MCP 服务 -- [rustfs-mcp](https://github.com/rustfs/rustfs/tree/main/crates/mcp) - 为 AI/LLM 集成提供 S3 兼容对象存储操作的高性能 MCP 服务 -- [containerd-mcp-server](https://github.com/jokemanfire/mcp-containerd) - 基于 containerd 实现的 MCP 服务 -- [rmcp-openapi-server](https://gitlab.com/lx-industries/rmcp-openapi/-/tree/main/crates/rmcp-openapi-server) - 将 OpenAPI 定义的端点暴露为 MCP 工具的高性能 MCP 服务 -- [nvim-mcp](https://github.com/linw1995/nvim-mcp) - 与 Neovim 交互的 MCP 服务 -- [terminator](https://github.com/mediar-ai/terminator) - AI 驱动的桌面自动化 MCP 服务,支持跨平台,成功率超过 95% -- [stakpak-agent](https://github.com/stakpak/agent) - 安全加固的 DevOps 终端智能体,支持 MCP over mTLS、流式传输、密钥令牌化和异步任务管理 -- [video-transcriber-mcp-rs](https://github.com/nhatvu148/video-transcriber-mcp-rs) - 使用 whisper.cpp 从 1000+ 平台转录视频的高性能 MCP 服务 -- [NexusCore MCP](https://github.com/sjkim1127/Nexuscore_MCP) - 具有 Frida 集成和隐蔽脱壳功能的高级恶意软件分析与动态检测 MCP 服务 -- [spreadsheet-mcp](https://github.com/PSU3D0/spreadsheet-mcp) - 面向 LLM 智能体的高效 Token 使用的电子表格分析 MCP 服务,支持自动区域检测、重新计算、截图和编辑 -- [hyper-mcp](https://github.com/hyper-mcp-rs/hyper-mcp) - 通过 WebAssembly (WASM) 插件扩展功能的快速、安全的 MCP 服务 -- [rudof-mcp](https://github.com/rudof-project/rudof/tree/master/rudof_mcp) - RDF 验证和数据处理 MCP 服务,支持 ShEx/SHACL 验证、SPARQL 查询和格式转换。支持 stdio 和 Streamable HTTP 传输,具备完整的 MCP 功能(工具、提示词、资源、日志、补全、任务) -- [MCPMate](https://github.com/loocor/MCPMate) - 渐进式 MCP 管理桌面应用:从引导式服务导入开始,逐步扩展到多客户端配置集和 Unify 元工具,让能力暴露、Token 消耗与运行状态更可控,并在效率、成本和可靠性上提供更多选择 - - -## 开发 - -### 贡献指南 - -查看 [docs/CONTRIBUTE.MD](../CONTRIBUTE.MD) 获取贡献提示。 - -### 使用 Dev Container - -如果你想使用 Dev Container,查看 [docs/DEVCONTAINER.md](../DEVCONTAINER.md) 获取开发指南。 From e9033411366178196f8fe6023f1b852575d9fc79 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 23 Jul 2026 14:13:11 -0400 Subject: [PATCH 266/333] chore: refactor OAuth client authorization api (#1009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor!: refactor OAuth client authorization api `OAuthState::start_authorization` now accepts a declarative `AuthorizationRequest` describing the client's available identity material and selects the highest-priority mechanism the server supports: pre-registered client information → Client ID Metadata Documents (SEP-991) → Dynamic Client Registration. This replaces the three mechanism-specific entry points (`start_authorization`, `start_authorization_with_metadata_url`, `start_authorization_with_preregistered_client`), where callers previously had to know which mechanism to pick. `AuthorizationSession::new` is consolidated the same way, and the failure-recovery pattern from #994 (returning the manager alongside the error so `OAuthState` recovers to `Unauthorized` on transient failures) is now applied across all registration paths. Includes new `RecordingOAuthHttpClient`-based tests covering the priority matrix and recovery semantics, plus updated docs, examples, and conformance client. * fix: dcr registration uses requested application type * fix: error for invalid combination --- conformance/src/bin/client.rs | 48 +- crates/rmcp/src/transport.rs | 11 +- crates/rmcp/src/transport/auth.rs | 712 ++++++++++++++++------ docs/OAUTH_SUPPORT.md | 60 +- examples/clients/src/auth/oauth_client.rs | 13 +- 5 files changed, 625 insertions(+), 219 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 0785e0675..8505f2403 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -4,7 +4,7 @@ use rmcp::{ service::RequestContext, transport::{ AuthClient, AuthorizationManager, StreamableHttpClientTransport, - auth::{AuthorizationCallback, InMemoryCredentialStore, OAuthState}, + auth::{AuthorizationCallback, AuthorizationRequest, InMemoryCredentialStore, OAuthState}, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -206,11 +206,10 @@ async fn perform_oauth_flow( // Discover + register + get auth URL oauth - .start_authorization_with_metadata_url( - &[], - REDIRECT_URI, - Some("conformance-client"), - Some(CIMD_CLIENT_METADATA_URL), + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), ) .await?; @@ -255,10 +254,12 @@ async fn perform_oauth_flow_preregistered( ) -> anyhow::Result> { let mut oauth = OAuthState::new(server_url, None).await?; - let config = rmcp::transport::auth::OAuthClientConfig::new(client_id, REDIRECT_URI) - .with_client_secret(client_secret); oauth - .start_authorization_with_preregistered_client(config) + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_preregistered_client(client_id) + .with_client_secret(client_secret), + ) .await?; let auth_url = oauth.get_authorization_url().await?; @@ -323,11 +324,11 @@ async fn run_auth_scope_step_up_client( ) -> anyhow::Result<()> { let mut oauth = OAuthState::new(server_url, None).await?; oauth - .start_authorization_with_metadata_url( - SCOPE_STEP_UP_INITIAL_SCOPES, - REDIRECT_URI, - Some("conformance-client"), - Some(CIMD_CLIENT_METADATA_URL), + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_scopes(SCOPE_STEP_UP_INITIAL_SCOPES.iter().copied()) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), ) .await?; @@ -375,11 +376,11 @@ async fn run_auth_scope_step_up_client( let mut oauth2 = OAuthState::new(server_url, None).await?; oauth2 - .start_authorization_with_metadata_url( - SCOPE_STEP_UP_ESCALATED_SCOPES, - REDIRECT_URI, - Some("conformance-client"), - Some(CIMD_CLIENT_METADATA_URL), + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_scopes(SCOPE_STEP_UP_ESCALATED_SCOPES.iter().copied()) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), ) .await?; let auth_url2 = oauth2.get_authorization_url().await?; @@ -427,11 +428,10 @@ async fn run_auth_scope_retry_limit_client( loop { let mut oauth = OAuthState::new(server_url, None).await?; oauth - .start_authorization_with_metadata_url( - &[], - REDIRECT_URI, - Some("conformance-client"), - Some(CIMD_CLIENT_METADATA_URL), + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), ) .await?; let auth_url = oauth.get_authorization_url().await?; diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 8cc48aa41..74a13945b 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -99,11 +99,12 @@ pub mod auth; pub use auth::JwtSigningAlgorithm; #[cfg(feature = "auth")] pub use auth::{ - AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, - ClientCredentialsConfig, CredentialStore, EXTENSION_OAUTH_CLIENT_CREDENTIALS, - InMemoryCredentialStore, InMemoryStateStore, OAuthHttpClient, OAuthHttpClientError, - OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, - StateStore, StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams, + AuthClient, AuthError, AuthorizationManager, AuthorizationRequest, AuthorizationSession, + AuthorizedHttpClient, ClientCredentialsConfig, CredentialStore, + EXTENSION_OAUTH_CLIENT_CREDENTIALS, InMemoryCredentialStore, InMemoryStateStore, + OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, + OAuthHttpRequest, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, StoredCredentials, + WWWAuthenticateParams, }; // #[cfg(feature = "transport-ws")] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index c86dfb8bb..881b54d45 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -647,6 +647,129 @@ impl OAuthClientConfig { } } +/// Declarative description of the client identity material available for an +/// authorization flow. +/// +/// The [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) +/// recommends that clients obtain a client ID using the following priority +/// order. [`OAuthState::start_authorization`] and [`AuthorizationSession::new`] +/// apply it internally: +/// +/// 1. Pre-registered client information +/// ([`with_preregistered_client`](Self::with_preregistered_client)), when +/// the client already holds a `client_id` issued out of band +/// 2. Client ID Metadata Documents (SEP-991, +/// [`with_client_metadata_url`](Self::with_client_metadata_url)), when the +/// authorization server advertises `client_id_metadata_document_supported` +/// 3. Dynamic Client Registration as a fallback, when the authorization server +/// advertises a `registration_endpoint` +/// +/// Provide whichever identity material the client has available; the SDK +/// selects the highest-priority mechanism the server supports. +/// +/// ```rust,ignore +/// let request = AuthorizationRequest::new("http://localhost:8080/callback") +/// // Omit `with_scopes` to let the SDK auto-select scopes from server metadata. +/// // Used when the server supports CIMD and no pre-registered client is set. +/// .with_client_metadata_url("https://example.com/client-metadata.json") +/// // used for dynamic client registration as a last resort +/// .with_client_name("My MCP Client"); +/// oauth_state.start_authorization(request).await?; +/// ``` +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AuthorizationRequest { + /// Redirect URI for the authorization-code flow. + pub redirect_uri: String, + /// Scopes to request. When empty, the SDK selects scopes from the + /// server's `WWW-Authenticate` challenge, Protected Resource Metadata, + /// or authorization server metadata. + pub scopes: Vec, + /// Human-readable client name, used for Dynamic Client Registration. + pub client_name: Option, + /// Pre-registered client ID obtained from the authorization server out of + /// band. When set, registration is skipped entirely. + pub client_id: Option, + /// Client secret paired with the pre-registered [`client_id`](Self::client_id). + pub client_secret: Option, + /// HTTPS URL of a Client ID Metadata Document (SEP-991). Used when the + /// authorization server advertises `client_id_metadata_document_supported` + /// and no pre-registered client is configured. + pub client_metadata_url: Option, + /// OIDC Dynamic Client Registration `application_type` (SEP-837), + /// e.g. `"native"` or `"web"`. + pub application_type: Option, +} + +impl AuthorizationRequest { + /// Create a request for the given redirect URI. With no further identity + /// material, authorization falls back to Dynamic Client Registration. + pub fn new(redirect_uri: impl Into) -> Self { + Self { + redirect_uri: redirect_uri.into(), + scopes: Vec::new(), + client_name: None, + client_id: None, + client_secret: None, + client_metadata_url: None, + application_type: None, + } + } + + /// Set the scopes to request. When not set, the SDK auto-selects scopes + /// using its normal scope-selection policy. + pub fn with_scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + + /// Set the client name used for Dynamic Client Registration. + pub fn with_client_name(mut self, client_name: impl Into) -> Self { + self.client_name = Some(client_name.into()); + self + } + + /// Use a client ID that was pre-registered with the authorization server + /// out of band. This takes priority over every other mechanism. + /// + /// Pair with [`with_client_secret`](Self::with_client_secret) for + /// confidential clients. + pub fn with_preregistered_client(mut self, client_id: impl Into) -> Self { + self.client_id = Some(client_id.into()); + self + } + + /// Set the client secret paired with a pre-registered client ID. + /// + /// Must be used together with + /// [`with_preregistered_client`](Self::with_preregistered_client); + /// authorization fails with [`AuthError::RegistrationFailed`] if a secret + /// is provided without a client ID. + pub fn with_client_secret(mut self, client_secret: impl Into) -> Self { + self.client_secret = Some(client_secret.into()); + self + } + + /// Set the HTTPS URL of a Client ID Metadata Document (SEP-991). Used when + /// the authorization server advertises support and no pre-registered + /// client is configured. + pub fn with_client_metadata_url(mut self, client_metadata_url: impl Into) -> Self { + self.client_metadata_url = Some(client_metadata_url.into()); + self + } + + /// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), + /// e.g. `"native"` or `"web"`. + pub fn with_application_type(mut self, application_type: impl Into) -> Self { + self.application_type = Some(application_type.into()); + self + } +} + // add type aliases for oauth2 types type OAuthErrorResponse = oauth2::StandardErrorResponse; @@ -2932,16 +3055,56 @@ pub struct AuthorizationSession { } impl AuthorizationSession { - /// create new authorization session + /// Create a new authorization session, selecting a client registration + /// mechanism per the [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) + /// priority order: + /// + /// 1. Pre-registered client information + /// ([`AuthorizationRequest::with_preregistered_client`]), when available + /// 2. Client ID Metadata Documents + /// ([`AuthorizationRequest::with_client_metadata_url`]), when the + /// authorization server advertises `client_id_metadata_document_supported` + /// 3. Dynamic Client Registration, when the authorization server + /// advertises a `registration_endpoint` + /// + /// The manager must already have discovered authorization server metadata. + /// If `request.scopes` is empty, scopes are selected using the SDK's + /// normal scope-selection policy. + /// + /// On failure, the manager is returned alongside the error so callers can + /// retry without losing the original configuration and stores. pub async fn new( mut auth_manager: AuthorizationManager, - scopes: &[&str], - redirect_uri: &str, - client_name: Option<&str>, - client_metadata_url: Option<&str>, - ) -> Result { - let metadata = auth_manager.metadata.as_ref(); - let supports_url_based_client_id = metadata + mut request: AuthorizationRequest, + ) -> Result { + if request.client_secret.is_some() && request.client_id.is_none() { + return Err(( + auth_manager, + AuthError::RegistrationFailed( + "client_secret was provided without a pre-registered client_id; \ + pair with_client_secret with with_preregistered_client" + .to_string(), + ), + )); + } + + if request.scopes.is_empty() { + request.scopes = auth_manager.select_scopes(None, &[]); + } else { + auth_manager.add_offline_access_if_supported(&mut request.scopes); + } + + if request.application_type.is_some() { + auth_manager.application_type = request.application_type.clone(); + } + + let redirect_uri = request.redirect_uri.clone(); + let scopes = request.scopes.clone(); + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + + let supports_url_based_client_id = auth_manager + .metadata + .as_ref() .and_then(|m| { m.additional_fields .get("client_id_metadata_document_supported") @@ -2949,80 +3112,74 @@ impl AuthorizationSession { .and_then(|v| v.as_bool()) .unwrap_or(false); - let config = if supports_url_based_client_id { - if let Some(client_metadata_url) = client_metadata_url { - if !is_https_url(client_metadata_url) { - return Err(AuthError::RegistrationFailed(format!( + // 1. pre-registered client information takes priority over everything else + let config = if let Some(client_id) = &request.client_id { + OAuthClientConfig { + client_id: client_id.clone(), + client_secret: request.client_secret.clone(), + scopes: scopes.clone(), + redirect_uri: redirect_uri.clone(), + application_type: request.application_type.clone(), + } + // 2. CIMD (SEP-991), when the server advertises support and the client hosts a metadata document + } else if let Some(client_metadata_url) = request.client_metadata_url.as_deref() + && supports_url_based_client_id + { + if !is_https_url(client_metadata_url) { + return Err(( + auth_manager, + AuthError::RegistrationFailed(format!( "client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {}", client_metadata_url - ))); - } - // SEP-991: URL-based Client IDs - use URL as client_id directly. - // SEP-837: match the hosted client-metadata.json application_type ("native") - OAuthClientConfig { - client_id: client_metadata_url.to_string(), - client_secret: None, - scopes: scopes.iter().map(|s| s.to_string()).collect(), - redirect_uri: redirect_uri.to_string(), - application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), - } - } else { - // Fallback to dynamic registration - auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes) - .await - .map_err(|e| { - AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e)) - })? + )), + )); + } + // SEP-991: URL-based Client IDs - use URL as client_id directly. + // SEP-837: match the hosted client-metadata.json application_type ("native") + OAuthClientConfig { + client_id: client_metadata_url.to_string(), + client_secret: None, + scopes: scopes.clone(), + redirect_uri: redirect_uri.clone(), + application_type: Some( + request + .application_type + .clone() + .unwrap_or_else(|| DEFAULT_APPLICATION_TYPE.to_string()), + ), } + // 3. fall back to dynamic client registration } else { - // Fallback to dynamic registration match auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes) + .register_client( + request.client_name.as_deref().unwrap_or("MCP Client"), + &redirect_uri, + &scope_refs, + ) .await { Ok(config) => config, Err(e) => { - return Err(AuthError::RegistrationFailed(format!( - "Dynamic registration failed: {}", - e - ))); + return Err(( + auth_manager, + AuthError::RegistrationFailed(format!( + "Dynamic registration failed: {}", + e + )), + )); } } }; // reset client config - auth_manager.configure_client(config)?; - let auth_url = auth_manager.get_authorization_url(scopes).await?; - - Ok(Self { - auth_manager, - auth_url, - redirect_uri: redirect_uri.to_string(), - }) - } - - /// create a session using pre-registered client credentials, skipping - /// dynamic client registration and URL-based client IDs. - /// - /// The manager must already have discovered authorization server metadata. - /// - /// On failure, the manager is returned alongside the error so callers can - /// retry without losing the original configuration and stores. - pub async fn with_preregistered_client( - mut auth_manager: AuthorizationManager, - config: OAuthClientConfig, - ) -> Result { - let redirect_uri = config.redirect_uri.clone(); - let scopes = config.scopes.clone(); if let Err(e) = auth_manager.configure_client(config) { return Err((auth_manager, e)); } - let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let auth_url = match auth_manager.get_authorization_url(&scope_refs).await { Ok(url) => url, Err(e) => return Err((auth_manager, e)), }; + Ok(Self { auth_manager, auth_url, @@ -3251,66 +3408,28 @@ impl OAuthState { } } - /// start authorization - pub async fn start_authorization( - &mut self, - scopes: &[&str], - redirect_uri: &str, - client_name: Option<&str>, - ) -> Result<(), AuthError> { - self.start_authorization_with_metadata_url(scopes, redirect_uri, client_name, None) - .await - } - - /// start authorization with optional client metadata URL (SEP-991) - pub async fn start_authorization_with_metadata_url( - &mut self, - scopes: &[&str], - redirect_uri: &str, - client_name: Option<&str>, - client_metadata_url: Option<&str>, - ) -> Result<(), AuthError> { - let placeholder = self.placeholder().await?; - if let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) { - debug!("start discovery"); - let metadata = manager.discover_metadata().await?; - manager.metadata = Some(metadata); - let selected_scopes: Vec = if scopes.is_empty() { - manager.select_scopes(None, &[]) - } else { - let mut s: Vec = scopes.iter().map(|s| s.to_string()).collect(); - manager.add_offline_access_if_supported(&mut s); - s - }; - let scope_refs: Vec<&str> = selected_scopes.iter().map(|s| s.as_str()).collect(); - debug!("start session"); - let session = AuthorizationSession::new( - manager, - &scope_refs, - redirect_uri, - client_name, - client_metadata_url, - ) - .await?; - *self = OAuthState::Session(session); - Ok(()) - } else { - Err(AuthError::InternalError( - "Already in session state".to_string(), - )) - } - } - - /// start authorization using pre-registered client credentials, - /// skipping dynamic client registration. + /// Start authorization. + /// + /// Selects a client registration mechanism from the identity material in + /// `request`, following the [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) + /// priority order: + /// + /// 1. Pre-registered client information + /// ([`AuthorizationRequest::with_preregistered_client`]), when available + /// 2. Client ID Metadata Documents + /// ([`AuthorizationRequest::with_client_metadata_url`]), when the + /// authorization server advertises `client_id_metadata_document_supported` + /// 3. Dynamic Client Registration, when the authorization server + /// advertises a `registration_endpoint` + /// + /// If `request.scopes` is empty, scopes are selected using the SDK's + /// normal scope-selection policy. /// - /// Use this when the client was registered with the authorization server - /// out of band and already holds a `client_id` (and optionally a - /// `client_secret`). If `config.scopes` is empty, scopes are selected - /// using the SDK's normal scope-selection policy. - pub async fn start_authorization_with_preregistered_client( + /// On failure, the state returns to `Unauthorized` so callers can retry + /// without losing the original configuration and stores. + pub async fn start_authorization( &mut self, - mut config: OAuthClientConfig, + request: AuthorizationRequest, ) -> Result<(), AuthError> { let placeholder = self.placeholder().await?; let old = std::mem::replace(self, placeholder); @@ -3320,6 +3439,7 @@ impl OAuthState { "Already in session state".to_string(), )); }; + debug!("start discovery"); let metadata = match manager.discover_metadata().await { Ok(metadata) => metadata, Err(e) => { @@ -3328,12 +3448,8 @@ impl OAuthState { } }; manager.metadata = Some(metadata); - if config.scopes.is_empty() { - config.scopes = manager.select_scopes(None, &[]); - } else { - manager.add_offline_access_if_supported(&mut config.scopes); - } - match AuthorizationSession::with_preregistered_client(manager, config).await { + debug!("start session"); + match AuthorizationSession::new(manager, request).await { Ok(session) => { *self = OAuthState::Session(session); Ok(()) @@ -3547,9 +3663,10 @@ mod tests { use super::{ AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, - CredentialStore, InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig, - OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, - OAuthHttpRequest, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, + AuthorizationRequest, AuthorizationSession, CredentialStore, InMemoryCredentialStore, + InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError, + OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, + StateStore, StoredAuthorizationState, is_https_url, }; use crate::transport::auth::VendorExtraTokenFields; @@ -4031,17 +4148,11 @@ mod tests { .await .unwrap(); - let config = OAuthClientConfig { - client_id: "preregistered-client".to_string(), - client_secret: Some("secret".to_string()), - scopes: vec!["read".to_string()], - redirect_uri: "http://localhost:8080/callback".to_string(), - application_type: None, - }; - state - .start_authorization_with_preregistered_client(config) - .await - .unwrap(); + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_client_secret("secret") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); // the registration endpoint was advertised but must not be called let requests = client.requests(); @@ -4059,30 +4170,25 @@ mod tests { } #[tokio::test] - async fn preregistered_client_selects_default_scopes_when_none_provided() { + async fn authorization_session_selects_default_scopes_when_none_provided() { let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); - let mut state = super::OAuthState::new_with_oauth_http_client( + let mut manager = AuthorizationManager::new_with_oauth_http_client( "https://mcp.example.com/mcp", - Arc::new(client.clone()), + Arc::new(client), ) .await .unwrap(); + manager.metadata = Some(manager.discover_metadata().await.unwrap()); - let config = OAuthClientConfig { - client_id: "preregistered-client".to_string(), - client_secret: None, - scopes: Vec::new(), - redirect_uri: "http://localhost:8080/callback".to_string(), - application_type: None, + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client"); + let session = match AuthorizationSession::new(manager, request).await { + Ok(session) => session, + Err((_, error)) => panic!("authorization session creation failed: {error}"), }; - state - .start_authorization_with_preregistered_client(config) - .await - .unwrap(); - // empty config scopes fall back to the discovered scopes_supported - let auth_url = state.get_authorization_url().await.unwrap(); - let query = auth_url_query(&auth_url); + // Empty request scopes fall back to the discovered scopes_supported. + let query = auth_url_query(&session.auth_url); assert_eq!(query.get("scope").unwrap(), "read write offline_access"); } @@ -4096,17 +4202,10 @@ mod tests { .await .unwrap(); - let config = OAuthClientConfig { - client_id: "preregistered-client".to_string(), - client_secret: None, - scopes: vec!["read".to_string()], - redirect_uri: "http://localhost:8080/callback".to_string(), - application_type: None, - }; - state - .start_authorization_with_preregistered_client(config) - .await - .unwrap(); + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); // explicit scopes are preserved; offline_access is appended per SEP-2207 let auth_url = state.get_authorization_url().await.unwrap(); @@ -4143,15 +4242,11 @@ mod tests { .await .unwrap(); - let config = OAuthClientConfig { - client_id: "preregistered-client".to_string(), - client_secret: None, - scopes: vec!["read".to_string()], - redirect_uri: "http://localhost:8080/callback".to_string(), - application_type: None, - }; + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_scopes(["read"]); let err = state - .start_authorization_with_preregistered_client(config.clone()) + .start_authorization(request.clone()) .await .unwrap_err(); assert!(!matches!(err, AuthError::InternalError(_)), "{err:?}"); @@ -4166,10 +4261,273 @@ mod tests { .lock() .unwrap() .extend(preregistered_discovery_responses()); - state - .start_authorization_with_preregistered_client(config) - .await + state.start_authorization(request).await.unwrap(); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + fn cimd_as_metadata_response() -> HttpResponse { + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "registration_endpoint": "https://auth.example.com/register", + "scopes_supported": ["read", "write", "offline_access"], + "client_id_metadata_document_supported": true + }), + ) + } + + /// discovery responses like [`preregistered_discovery_responses`] but the + /// authorization server advertises CIMD support. + fn cimd_discovery_responses() -> Vec { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) .unwrap(); + vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + cimd_as_metadata_response(), + ] + } + + #[tokio::test] + async fn preregistered_client_takes_priority_over_cimd() { + // server supports CIMD and the request carries both pre-registered + // credentials and a client metadata URL: pre-registration wins + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let requests = client.requests(); + assert!( + requests + .iter() + .all(|request| !request.uri.contains("/register")), + "registration endpoint should not be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "preregistered-client"); + } + + #[tokio::test] + async fn cimd_used_when_server_advertises_support() { + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + // CIMD takes priority over the advertised registration endpoint + let requests = client.requests(); + assert!( + requests + .iter() + .all(|request| !request.uri.contains("/register")), + "registration endpoint should not be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!( + query.get("client_id").unwrap(), + "https://client.example.com/client-metadata.json" + ); + } + + #[tokio::test] + async fn cimd_falls_back_to_dcr_when_server_lacks_support() { + // server does not advertise client_id_metadata_document_supported, so + // the client metadata URL is ignored and DCR is used instead + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let requests = client.requests(); + assert!( + requests + .iter() + .any(|request| request.uri.contains("/register")), + "registration endpoint should be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "dcr-client"); + } + + #[tokio::test] + async fn dcr_used_when_no_identity_material_is_provided() { + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_name("test-client") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "dcr-client"); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + #[tokio::test] + async fn dcr_registration_uses_requested_application_type() { + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_name("test-client") + .with_application_type("web") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + // SEP-837: the requested application_type must be sent in the DCR request + let requests = client.requests(); + let registration = requests + .iter() + .find(|request| request.uri.contains("/register")) + .expect("registration endpoint should be called"); + let body: serde_json::Value = serde_json::from_slice(®istration.body).unwrap(); + assert_eq!(body.get("application_type").unwrap(), "web"); + } + + #[tokio::test] + async fn cimd_rejects_non_https_client_metadata_url_and_recovers_state() { + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("http://client.example.com/client-metadata.json") + .with_scopes(["read"]); + let err = state.start_authorization(request).await.unwrap_err(); + assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}"); + assert!( + matches!(state, super::OAuthState::Unauthorized(_)), + "state should return to Unauthorized after a registration failure" + ); + } + + #[tokio::test] + async fn dcr_recovers_unauthorized_state_after_registration_failure() { + // discovery succeeds, but the registration endpoint rejects the request + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 400, + serde_json::json!({"error": "invalid_client_metadata"}), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_name("test-client") + .with_scopes(["read"]); + let err = state + .start_authorization(request.clone()) + .await + .unwrap_err(); + assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}"); + assert!( + matches!(state, super::OAuthState::Unauthorized(_)), + "state should return to Unauthorized after a registration failure" + ); + + // retrying with the same state succeeds once the server accepts + // registration (discovery runs again on retry) + { + let mut responses = client.responses.lock().unwrap(); + responses.extend(preregistered_discovery_responses()); + responses.push_back(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + } + state.start_authorization(request).await.unwrap(); assert!(matches!(state, super::OAuthState::Session(_))); } diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 63622ed4b..b9d8e1ca5 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -110,14 +110,29 @@ transport. ### 3. Start authorization with OAuthState -The `OAuthState` state machine manages the full authorization lifecycle. When no -scopes are provided, the SDK automatically selects scopes from the server's -WWW-Authenticate header, Protected Resource Metadata, or AS metadata. +The `OAuthState` state machine manages the full authorization lifecycle. +`start_authorization` accepts an `AuthorizationRequest` describing the client +identity material you have available, and selects a client registration +mechanism following the [spec's priority order](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration): + +1. **Pre-registered client information** (`with_preregistered_client`), when + the client already holds a `client_id` issued out of band +2. **Client ID Metadata Documents** (SEP-991, `with_client_metadata_url`), when + the authorization server advertises `client_id_metadata_document_supported` +3. **Dynamic Client Registration**, as a fallback when the authorization server + advertises a `registration_endpoint` + +When no scopes are provided, the SDK automatically selects scopes from the +server's WWW-Authenticate header, Protected Resource Metadata, or AS metadata. ```rust ignore -// start authorization - pass empty scopes to let the SDK auto-select +use rmcp::transport::auth::AuthorizationRequest; + +// start authorization - pass no scopes to let the SDK auto-select oauth_state - .start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client")) + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI).with_client_name("My MCP Client"), + ) .await .context("Failed to start authorization")?; ``` @@ -126,7 +141,40 @@ If you know the scopes you need, you can still pass them explicitly: ```rust ignore oauth_state - .start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client")) + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_scopes(["mcp", "profile"]) + .with_client_name("My MCP Client"), + ) + .await + .context("Failed to start authorization")?; +``` + +If the client hosts a Client ID Metadata Document (SEP-991), pass its URL; the +SDK uses it when the server supports CIMD and falls back to dynamic +registration otherwise: + +```rust ignore +oauth_state + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_client_name("My MCP Client") + .with_client_metadata_url("https://example.com/client-metadata.json"), + ) + .await + .context("Failed to start authorization")?; +``` + +If the client was registered with the authorization server out of band, provide +the pre-registered credentials; they take priority over every other mechanism: + +```rust ignore +oauth_state + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_preregistered_client("my-client-id") + .with_client_secret("my-client-secret"), + ) .await .context("Failed to start authorization")?; ``` diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index ffd343428..58565fb72 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -12,7 +12,7 @@ use rmcp::{ model::ClientInfo, transport::{ StreamableHttpClientTransport, - auth::{AuthClient, OAuthState}, + auth::{AuthClient, AuthorizationRequest, OAuthState}, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -128,14 +128,13 @@ async fn main() -> Result<()> { .await .context("Failed to initialize oauth state machine")?; // use CIMD (SEP-991) with client metadata URL. - // passing empty scopes lets the SDK auto-select from the server's + // passing no scopes lets the SDK auto-select from the server's // WWW-Authenticate header, Protected Resource Metadata, or AS metadata. oauth_state - .start_authorization_with_metadata_url( - &[], - MCP_REDIRECT_URI, - Some("Test MCP Client"), - Some(&client_metadata_url), + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_client_name("Test MCP Client") + .with_client_metadata_url(&client_metadata_url), ) .await .context("Failed to start authorization")?; From 190ea4421853ce0912f580b5881f14a97172a69e Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:28:47 -0400 Subject: [PATCH 267/333] fix: use issuer for JWT client audience (#1031) --- ROADMAP.md | 4 +- conformance/Cargo.toml | 3 +- conformance/expected-failures-extensions.yaml | 1 - conformance/src/bin/client.rs | 142 +++++------------- crates/rmcp/CHANGELOG.md | 4 + crates/rmcp/Cargo.toml | 2 +- crates/rmcp/src/transport/auth.rs | 126 +++++++++++++++- 7 files changed, 163 insertions(+), 119 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 04b0c859a..60c35b4b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,7 +30,7 @@ runs them in separate server and client steps with `conformance/expected-failures-extensions.yaml`. - SEP-2663 Tasks server: 9 expected failures; `tasks-status-notifications` is currently skipped by the upstream harness; tracked in #868 -- Client extensions: `auth/client-credentials-basic` passes; `auth/client-credentials-jwt` and `auth/enterprise-managed-authorization` are expected failures +- Client extensions: `auth/client-credentials-basic` and `auth/client-credentials-jwt` pass; `auth/enterprise-managed-authorization` is an expected failure ### Spec features without conformance scenarios @@ -110,7 +110,7 @@ These extension scenarios are tracked but do not count toward tier advancement: | Scenario | Tag | Status | |---|---|---| -| `auth/client-credentials-jwt` | extension | ❌ Failed — JWT `aud` claim verification error | +| `auth/client-credentials-jwt` | extension | ✅ Passed | | `auth/client-credentials-basic` | extension | ✅ Passed | | `auth/enterprise-managed-authorization` | extension | ❌ Failed — scenario is not implemented by the conformance client | | `tasks-*` | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped scenario | diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index 83b7007cf..fc4be6d90 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [ "client", "elicitation", "auth", + "auth-client-credentials-jwt", "request-state", "transport-streamable-http-server", "transport-streamable-http-client-reqwest", @@ -33,5 +34,3 @@ anyhow = "1" reqwest = { version = "0.13", features = ["json"] } urlencoding = "2" url = "2" -p256 = { version = "0.14", features = ["ecdsa"] } -base64 = "0.22" diff --git a/conformance/expected-failures-extensions.yaml b/conformance/expected-failures-extensions.yaml index 13b94b668..fdf9617df 100644 --- a/conformance/expected-failures-extensions.yaml +++ b/conformance/expected-failures-extensions.yaml @@ -20,5 +20,4 @@ server: [] client: # Informational OAuth extension scenarios. - - auth/client-credentials-jwt - auth/enterprise-managed-authorization diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 8505f2403..9d1348600 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -4,7 +4,10 @@ use rmcp::{ service::RequestContext, transport::{ AuthClient, AuthorizationManager, StreamableHttpClientTransport, - auth::{AuthorizationCallback, AuthorizationRequest, InMemoryCredentialStore, OAuthState}, + auth::{ + AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig, + InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState, + }, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -651,49 +654,43 @@ async fn run_client_credentials_jwt( ) -> anyhow::Result<()> { let client_id = ctx .client_id - .as_deref() - .unwrap_or("conformance-test-client"); - let _pem = ctx + .clone() + .unwrap_or_else(|| "conformance-test-client".to_string()); + let signing_key = ctx .private_key_pem - .as_deref() - .ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?; - let _alg = ctx + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))? + .as_bytes() + .to_vec(); + let signing_algorithm = match ctx .signing_algorithm .as_deref() - .ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?; - - // Discover metadata to get token endpoint - let mut manager = AuthorizationManager::new(server_url).await?; - let metadata = manager.discover_metadata().await?; - let token_endpoint = metadata.token_endpoint.clone(); - manager.set_metadata(metadata); - - // Build JWT assertion - // Parse the PEM private key - let key = openssl_free_ec_sign(_pem, client_id, &token_endpoint)?; - - let http = reqwest::Client::new(); - let form_body = format!( - "grant_type=client_credentials&client_assertion_type={}&client_assertion={}", - urlencoding::encode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), - urlencoding::encode(&key), - ); - let resp = http - .post(&token_endpoint) - .header("content-type", "application/x-www-form-urlencoded") - .body(form_body) - .send() - .await?; - - let token_resp: serde_json::Value = resp.json().await?; - let access_token = token_resp["access_token"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("No access_token: {}", token_resp))?; + .ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))? + { + "RS256" => JwtSigningAlgorithm::RS256, + "RS384" => JwtSigningAlgorithm::RS384, + "RS512" => JwtSigningAlgorithm::RS512, + "ES256" => JwtSigningAlgorithm::ES256, + "ES384" => JwtSigningAlgorithm::ES384, + algorithm => anyhow::bail!("Unsupported signing_algorithm: {algorithm}"), + }; + let config = ClientCredentialsConfig::PrivateKeyJwt { + client_id, + signing_key, + signing_algorithm, + token_endpoint_audience: None, + scopes: vec![], + resource: Some(server_url.to_string()), + }; + let mut oauth_state = OAuthState::new(server_url, None).await?; + oauth_state.authenticate_client_credentials(config).await?; + let manager = oauth_state + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("Client credentials flow did not authorize"))?; let transport = StreamableHttpClientTransport::with_client( - reqwest::Client::default(), - StreamableHttpClientTransportConfig::with_uri(server_url) - .auth_header(access_token.to_string()), + AuthClient::new(reqwest::Client::default(), manager), + StreamableHttpClientTransportConfig::with_uri(server_url), ); let client = BasicClientHandler.serve(transport).await?; @@ -709,73 +706,6 @@ async fn run_client_credentials_jwt( Ok(()) } -/// Minimal ES256 JWT signing without heavy deps. -/// We use ring or pure-Rust approach. For simplicity, use the p256 + base64 crates -/// that are already transitive deps of oauth2. -fn openssl_free_ec_sign(pem: &str, client_id: &str, audience: &str) -> anyhow::Result { - use std::time::{SystemTime, UNIX_EPOCH}; - - // Decode PEM → DER - let pem_body = pem - .lines() - .filter(|l| !l.starts_with("-----")) - .collect::(); - let der = base64_decode(&pem_body)?; - - // Parse PKCS#8 DER to get the raw EC private key bytes - // PKCS#8 for EC P-256: the raw 32-byte key is at the end of the structure - let raw_key = extract_ec_private_key(&der)?; - - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let header = base64url_encode(br#"{"alg":"ES256","typ":"JWT"}"#); - let payload_json = serde_json::json!({ - "iss": client_id, - "sub": client_id, - "aud": audience, - "iat": now, - "exp": now + 300, - "jti": format!("jti-{}", now), - }); - let payload = base64url_encode(payload_json.to_string().as_bytes()); - let signing_input = format!("{}.{}", header, payload); - - // Sign with p256 - let secret_key = p256::ecdsa::SigningKey::from_slice(raw_key.as_slice()) - .map_err(|e| anyhow::anyhow!("Invalid EC key: {}", e))?; - use p256::ecdsa::signature::Signer; - let sig: p256::ecdsa::Signature = secret_key.sign(signing_input.as_bytes()); - let sig_bytes = sig.to_bytes(); - let sig_b64 = base64url_encode(&sig_bytes); - - Ok(format!("{}.{}", signing_input, sig_b64)) -} - -fn base64url_encode(data: &[u8]) -> String { - use base64::Engine; - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) -} - -fn base64_decode(s: &str) -> anyhow::Result> { - use base64::Engine; - Ok(base64::engine::general_purpose::STANDARD.decode(s.trim())?) -} - -/// Extract the raw 32-byte EC private key from a PKCS#8 DER blob. -fn extract_ec_private_key(der: &[u8]) -> anyhow::Result> { - // PKCS#8 wraps an ECPrivateKey. We look for the octet string containing - // the 32-byte private key. A simple heuristic: find 0x04 0x20 (OCTET STRING, len 32) - // followed by exactly 32 bytes that form the key. - // More robust: parse ASN.1. But for conformance testing this suffices. - for i in 0..der.len().saturating_sub(33) { - if der[i] == 0x04 && der[i + 1] == 0x20 && i + 34 <= der.len() { - return Ok(der[i + 2..i + 34].to_vec()); - } - } - Err(anyhow::anyhow!( - "Could not extract 32-byte EC private key from PKCS#8 DER" - )) -} - /// Cross-app access flow (SEP-1046 extension). async fn run_cross_app_access_client( server_url: &str, diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index dbfb14623..34fe75857 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING**: rename `StreamableHttpServerConfig::stateful_mode` to `legacy_session_mode` (and the builder `with_stateful_mode` to `with_legacy_session_mode`) to clarify that the option only affects legacy protocol versions (`< 2026-07-28`); per SEP-2567 the `2026-07-28` draft version is always served statelessly ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999)) +### Fixed + +- use the authorization server issuer as the default `private_key_jwt` audience + ## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08 ### Added diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 737682785..0724c01f2 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -57,7 +57,7 @@ pastey = { version = "0.2.0", optional = true } # oauth2 support oauth2 = { version = "5.0", optional = true, default-features = false } # JWT signing for client credentials (private_key_jwt) -jsonwebtoken = { version = "10", optional = true } +jsonwebtoken = { version = "10", optional = true, features = ["aws_lc_rs"] } # for auto generate schema schemars = { version = "1.0", optional = true, features = ["chrono04"] } diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 881b54d45..74bbbf5de 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -862,13 +862,35 @@ pub enum ClientCredentialsConfig { client_id: String, signing_key: Vec, signing_algorithm: JwtSigningAlgorithm, - /// Override the `aud` claim in the JWT assertion; defaults to token_endpoint + /// Overrides the authorization server issuer used for the JWT `aud` claim. token_endpoint_audience: Option, scopes: Vec, resource: Option, }, } +#[cfg(feature = "auth-client-credentials-jwt")] +fn client_authentication_audience<'a>( + metadata: &'a AuthorizationMetadata, + configured_audience: Option<&'a str>, +) -> Result<&'a str, AuthError> { + configured_audience + .or(metadata.issuer.as_deref()) + .ok_or_else(|| { + AuthError::ClientCredentialsError( + "Authorization server metadata is missing the issuer required for private_key_jwt" + .to_string(), + ) + }) +} + +#[cfg(feature = "auth-client-credentials-jwt")] +fn client_authentication_header(algorithm: JwtSigningAlgorithm) -> jsonwebtoken::Header { + let mut header = jsonwebtoken::Header::new(algorithm.to_jsonwebtoken_algorithm()); + header.typ = Some("client-authentication+jwt".to_string()); + header +} + impl ClientCredentialsConfig { fn client_id(&self) -> &str { match self { @@ -989,6 +1011,18 @@ fn is_https_url(value: &str) -> bool { .unwrap_or(false) } +#[cfg(feature = "auth-client-credentials-jwt")] +fn is_allowed_client_credentials_endpoint(resource: &Url, token_endpoint: &Url) -> bool { + token_endpoint.scheme() == "https" + || (token_endpoint.scheme() == "http" + && resource + .host_str() + .is_some_and(AuthorizationManager::is_loopback_metadata_host) + && token_endpoint + .host_str() + .is_some_and(AuthorizationManager::is_loopback_metadata_host)) +} + impl AuthorizationManager { fn is_http_url(url: &Url) -> bool { matches!(url.scheme(), "http" | "https") && url.host_str().is_some() @@ -2872,22 +2906,20 @@ impl AuthorizationManager { .as_ref() .ok_or(AuthError::NoAuthorizationSupport)?; - // Validate that the token endpoint uses HTTPS before transmitting sensitive credentials. let token_endpoint_url = url::Url::parse(&metadata.token_endpoint).map_err(|e| { AuthError::ClientCredentialsError(format!( "Invalid token endpoint URL in authorization metadata: {e}" )) })?; - if token_endpoint_url.scheme() != "https" { + if !is_allowed_client_credentials_endpoint(&self.base_url, &token_endpoint_url) { return Err(AuthError::ClientCredentialsError( "Insecure token endpoint URL: HTTPS is required for client credentials flow" .to_string(), )); } - let audience = token_endpoint_audience - .as_deref() - .unwrap_or(&metadata.token_endpoint); + let audience = + client_authentication_audience(metadata, token_endpoint_audience.as_deref())?; let assertion = Self::build_jwt_assertion(client_id, audience, signing_key, *signing_algorithm)?; @@ -2993,7 +3025,7 @@ impl AuthorizationManager { "jti": jti, }); - let header = jsonwebtoken::Header::new(algorithm.to_jsonwebtoken_algorithm()); + let header = client_authentication_header(algorithm); let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(signing_key).or_else(|_| { jsonwebtoken::EncodingKey::from_ec_pem(signing_key).map_err(|e| { AuthError::JwtSigningError(format!("Failed to parse signing key: {}", e)) @@ -6476,6 +6508,86 @@ mod tests { // -- client credentials (SEP-1046) -- + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_authentication_audience_defaults_to_metadata_issuer() { + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + issuer: Some("https://auth.example.com".to_string()), + ..Default::default() + }; + + let audience = super::client_authentication_audience(&metadata, None).unwrap(); + + assert_eq!(audience, "https://auth.example.com"); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_authentication_audience_honors_explicit_override() { + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + issuer: Some("https://auth.example.com".to_string()), + ..Default::default() + }; + + let audience = super::client_authentication_audience( + &metadata, + Some("https://legacy.example.com/token"), + ) + .unwrap(); + + assert_eq!(audience, "https://legacy.example.com/token"); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_authentication_audience_rejects_missing_metadata_issuer() { + let metadata = AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + ..Default::default() + }; + + let error = super::client_authentication_audience(&metadata, None).unwrap_err(); + + assert!(matches!(error, AuthError::ClientCredentialsError(_))); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_authentication_header_sets_explicit_type() { + let header = super::client_authentication_header(super::JwtSigningAlgorithm::ES256); + + assert_eq!(header.typ.as_deref(), Some("client-authentication+jwt")); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_credentials_endpoint_allows_http_between_loopback_hosts() { + let resource = Url::parse("http://localhost:8000/mcp").unwrap(); + let token_endpoint = Url::parse("http://127.0.0.1:9000/token").unwrap(); + + assert!(super::is_allowed_client_credentials_endpoint( + &resource, + &token_endpoint + )); + } + + #[cfg(feature = "auth-client-credentials-jwt")] + #[test] + fn client_credentials_endpoint_rejects_http_to_non_loopback_host() { + let resource = Url::parse("http://localhost:8000/mcp").unwrap(); + let token_endpoint = Url::parse("http://auth.example.com/token").unwrap(); + + assert!(!super::is_allowed_client_credentials_endpoint( + &resource, + &token_endpoint + )); + } + #[tokio::test] async fn configure_client_credentials_uses_request_body_auth_for_client_secret() { let mut mgr = manager_with_metadata(None).await; From e660b805ee57d2c6659204f5450a072da207232b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:41:54 -0400 Subject: [PATCH 268/333] chore: release v3.0.0-beta.1 (#964) * chore: release v3.0.0 * chore: release v3.0.0-beta.1 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Alex Hancock --- Cargo.toml | 6 ++--- crates/rmcp-macros/CHANGELOG.md | 17 ++++++++++++ crates/rmcp/CHANGELOG.md | 48 +++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d7a7caca3..a45225dca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "2.2.0", path = "./crates/rmcp" } -rmcp-macros = { version = "2.2.0", path = "./crates/rmcp-macros" } +rmcp = { version = "3.0.0-beta.1", path = "./crates/rmcp" } +rmcp-macros = { version = "3.0.0-beta.1", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" -version = "2.2.0" +version = "3.0.0-beta.1" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 242e591d0..997c9793e 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0-beta.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v2.2.0...rmcp-macros-v3.0.0-beta.1) - 2026-07-23 + +### Added + +- add client-side TTL-honoring response cache (SEP-2549) ([#1025](https://github.com/modelcontextprotocol/rust-sdk/pull/1025)) +- [**breaking**] Implement SEP-2663 Tasks Extension ([#1020](https://github.com/modelcontextprotocol/rust-sdk/pull/1020)) +- add subscription listen streams (SEP-2575) ([#1000](https://github.com/modelcontextprotocol/rust-sdk/pull/1000)) +- add modern client lifecycle modes (SEP-2575) ([#995](https://github.com/modelcontextprotocol/rust-sdk/pull/995)) +- [**breaking**] implement SEP-2549 cache hints ([#889](https://github.com/modelcontextprotocol/rust-sdk/pull/889)) +- [**breaking**] add MRTR behavior support (SEP-2322) ([#929](https://github.com/modelcontextprotocol/rust-sdk/pull/929)) +- relax outputSchema to accept non-object JSON Schema types (SEP-2106) ([#895](https://github.com/modelcontextprotocol/rust-sdk/pull/895)) +- [**breaking**] add MRTR model types (SEP-2322) ([#915](https://github.com/modelcontextprotocol/rust-sdk/pull/915)) + +### Other + +- update for 2026-07-28 version ([#1032](https://github.com/modelcontextprotocol/rust-sdk/pull/1032)) + ## [2.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.8.0...rmcp-macros-v2.0.0) - 2026-06-27 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 34fe75857..dbd82c762 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0-beta.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.2.0...rmcp-v3.0.0-beta.1) - 2026-07-23 + +### Added + +- route SEP-2260 associated server requests to the originating SSE stream ([#1029](https://github.com/modelcontextprotocol/rust-sdk/pull/1029)) +- [**breaking**] add distributed SSE event store ([#1024](https://github.com/modelcontextprotocol/rust-sdk/pull/1024)) +- add client-side TTL-honoring response cache (SEP-2549) ([#1025](https://github.com/modelcontextprotocol/rust-sdk/pull/1025)) +- [**breaking**] Implement SEP-2663 Tasks Extension ([#1020](https://github.com/modelcontextprotocol/rust-sdk/pull/1020)) +- add subscription listen streams (SEP-2575) ([#1000](https://github.com/modelcontextprotocol/rust-sdk/pull/1000)) +- *(auth)* bind DCR client credentials to issuing authorization server (SEP-2352) ([#998](https://github.com/modelcontextprotocol/rust-sdk/pull/998)) +- add modern client lifecycle modes (SEP-2575) ([#995](https://github.com/modelcontextprotocol/rust-sdk/pull/995)) +- *(auth)* accumulate client-side scopes during step-up authorization ([#888](https://github.com/modelcontextprotocol/rust-sdk/pull/888)) +- [**breaking**] add server discovery and negotiation (SEP-2575) ([#973](https://github.com/modelcontextprotocol/rust-sdk/pull/973)) +- *(conformance)* add SEP-2243 header validation tool ([#997](https://github.com/modelcontextprotocol/rust-sdk/pull/997)) +- [**breaking**] align metadata models with draft schema ([#993](https://github.com/modelcontextprotocol/rust-sdk/pull/993)) +- [**breaking**] implement SEP-2549 cache hints ([#889](https://github.com/modelcontextprotocol/rust-sdk/pull/889)) +- [**breaking**] add MRTR behavior support (SEP-2322) ([#929](https://github.com/modelcontextprotocol/rust-sdk/pull/929)) +- [**breaking**] type Annotations.lastModified as a string ([#956](https://github.com/modelcontextprotocol/rust-sdk/pull/956)) +- [**breaking**] add SEP-2243 HTTP standard headers ([#907](https://github.com/modelcontextprotocol/rust-sdk/pull/907)) +- relax outputSchema to accept non-object JSON Schema types (SEP-2106) ([#895](https://github.com/modelcontextprotocol/rust-sdk/pull/895)) +- [**breaking**] relax tool result structuredContent type (SEP-2106) ([#933](https://github.com/modelcontextprotocol/rust-sdk/pull/933)) +- [**breaking**] add MRTR model types (SEP-2322) ([#915](https://github.com/modelcontextprotocol/rust-sdk/pull/915)) + +### Fixed + +- reap completed response send tasks ([#1026](https://github.com/modelcontextprotocol/rust-sdk/pull/1026)) +- accept stringified numeric response IDs ([#1021](https://github.com/modelcontextprotocol/rust-sdk/pull/1021)) +- re-register after auth server change ([#1011](https://github.com/modelcontextprotocol/rust-sdk/pull/1011)) +- .with_stateful_mode -> .with_legacy_session_mode ([#1015](https://github.com/modelcontextprotocol/rust-sdk/pull/1015)) +- preserve negotiated progress responses ([#1005](https://github.com/modelcontextprotocol/rust-sdk/pull/1005)) +- *(server)* serve draft-version requests statelessly per SEP-2567 ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999)) +- pass client header conformance ([#1012](https://github.com/modelcontextprotocol/rust-sdk/pull/1012)) +- *(auth)* add an SDK path for pre-registered OAuth clients ([#994](https://github.com/modelcontextprotocol/rust-sdk/pull/994)) +- *(transport)* cancel in-flight request on stateless streamable-HTTP client disconnect ([#857](https://github.com/modelcontextprotocol/rust-sdk/pull/857)) ([#967](https://github.com/modelcontextprotocol/rust-sdk/pull/967)) +- *(auth)* distinguish rejected refresh tokens from transient failures ([#963](https://github.com/modelcontextprotocol/rust-sdk/pull/963)) +- *(auth)* validate discovered metadata issuer ([#996](https://github.com/modelcontextprotocol/rust-sdk/pull/996)) +- bound streamable HTTP memory usage ([#970](https://github.com/modelcontextprotocol/rust-sdk/pull/970)) +- *(streamable-http)* preserve progress in JSON mode ([#990](https://github.com/modelcontextprotocol/rust-sdk/pull/990)) +- specify compatible sse-stream version ([#968](https://github.com/modelcontextprotocol/rust-sdk/pull/968)) +- flag schema derive on schemars feature ([#966](https://github.com/modelcontextprotocol/rust-sdk/pull/966)) + +### Other + +- refactor OAuth client authorization api ([#1009](https://github.com/modelcontextprotocol/rust-sdk/pull/1009)) +- update for 2026-07-28 version ([#1032](https://github.com/modelcontextprotocol/rust-sdk/pull/1032)) +- *(deps)* update hmac requirement from 0.12 to 0.13 ([#988](https://github.com/modelcontextprotocol/rust-sdk/pull/988)) +- serialize JavaScript dependency install ([#972](https://github.com/modelcontextprotocol/rust-sdk/pull/972)) + ### Changed - **BREAKING**: rename `StreamableHttpServerConfig::stateful_mode` to `legacy_session_mode` (and the builder `with_stateful_mode` to `with_legacy_session_mode`) to clarify that the option only affects legacy protocol versions (`< 2026-07-28`); per SEP-2567 the `2026-07-28` draft version is always served statelessly ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999)) From 76988023b2b4d7b6b1b0c3397801953cf44c451f Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 23 Jul 2026 15:59:55 -0400 Subject: [PATCH 269/333] chore: declare and check MSRV (#1034) * chore: declare and check MSRV * fix: clippy fixes for collapsing if-statements * ci: add explicit contents read permission to MSRV job --- .github/workflows/ci.yml | 24 +++ Cargo.toml | 1 + clippy.toml | 1 - crates/rmcp-macros/Cargo.toml | 1 + crates/rmcp-macros/src/common.rs | 20 ++- crates/rmcp-macros/src/prompt.rs | 14 +- crates/rmcp-macros/src/tool.rs | 31 ++-- crates/rmcp/Cargo.toml | 1 + crates/rmcp/src/handler/server/router/tool.rs | 8 +- crates/rmcp/src/model/elicitation_schema.rs | 18 +-- crates/rmcp/src/service.rs | 18 +-- crates/rmcp/src/transport/async_rw.rs | 12 +- crates/rmcp/src/transport/auth.rs | 140 +++++++++--------- .../src/transport/common/client_side_sse.rs | 16 +- .../rmcp/src/transport/common/mcp_headers.rs | 82 +++++----- .../common/reqwest/streamable_http_client.rs | 54 +++---- .../rmcp/src/transport/common/unix_socket.rs | 112 +++++++------- .../src/transport/streamable_http_client.rs | 50 +++---- .../streamable_http_server/session/local.rs | 18 +-- crates/rmcp/tests/test_mrtr_behavior.rs | 14 +- examples/transport/Cargo.toml | 1 + examples/wasi/Cargo.toml | 1 + 22 files changed, 322 insertions(+), 315 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0709ece8..9dd9604df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,6 +205,30 @@ jobs: - name: Spell Check Repo uses: crate-ci/typos@master + msrv: + name: Check MSRV + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - name: Read MSRV from Cargo.toml + run: | + MSRV=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name == "rmcp") | .rust_version') + echo "MSRV=$MSRV" >> "$GITHUB_ENV" + + - name: Install MSRV Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.MSRV }} + + - uses: Swatinem/rust-cache@v2 + + - name: Check default workspace members with MSRV + run: cargo +${{ env.MSRV }} check --all-targets --all-features + test: name: Run Tests runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index a45225dca..5c4ad4026 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ rmcp-macros = { version = "3.0.0-beta.1", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" +rust-version = "1.88" version = "3.0.0-beta.1" authors = ["4t145 "] license = "Apache-2.0" diff --git a/clippy.toml b/clippy.toml index 9388e0099..b7c01ac13 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,3 +1,2 @@ -msrv = "1.85" too-many-arguments-threshold = 10 check-private-items = false diff --git a/crates/rmcp-macros/Cargo.toml b/crates/rmcp-macros/Cargo.toml index 6f645bdc7..db94afc8c 100644 --- a/crates/rmcp-macros/Cargo.toml +++ b/crates/rmcp-macros/Cargo.toml @@ -5,6 +5,7 @@ name = "rmcp-macros" license = { workspace = true } version = { workspace = true } edition = { workspace = true } +rust-version = { workspace = true } repository = { workspace = true } homepage = { workspace = true } readme = { workspace = true } diff --git a/crates/rmcp-macros/src/common.rs b/crates/rmcp-macros/src/common.rs index 0ca2fdae2..dc7e5bfa6 100644 --- a/crates/rmcp-macros/src/common.rs +++ b/crates/rmcp-macros/src/common.rs @@ -55,17 +55,15 @@ pub fn extract_doc_line( /// Returns the full Parameters type if found pub fn find_parameters_type_in_sig(sig: &Signature) -> Option> { sig.inputs.iter().find_map(|input| { - if let FnArg::Typed(pat_type) = input { - if let Type::Path(type_path) = &*pat_type.ty { - if type_path - .path - .segments - .last() - .is_some_and(|type_name| type_name.ident == "Parameters") - { - return Some(pat_type.ty.clone()); - } - } + if let FnArg::Typed(pat_type) = input + && let Type::Path(type_path) = &*pat_type.ty + && type_path + .path + .segments + .last() + .is_some_and(|type_name| type_name.ident == "Parameters") + { + return Some(pat_type.ty.clone()); } None }) diff --git a/crates/rmcp-macros/src/prompt.rs b/crates/rmcp-macros/src/prompt.rs index 3bf02d2b9..1ebc510ed 100644 --- a/crates/rmcp-macros/src/prompt.rs +++ b/crates/rmcp-macros/src/prompt.rs @@ -132,13 +132,13 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result // 3. make body: { Box::pin(async move { #body }) } let new_output = syn::parse2::({ let mut lt = quote! { 'static }; - if let Some(receiver) = fn_item.sig.receiver() { - if let Some((_, receiver_lt)) = receiver.reference.as_ref() { - if let Some(receiver_lt) = receiver_lt { - lt = quote! { #receiver_lt }; - } else { - lt = quote! { '_ }; - } + if let Some(receiver) = fn_item.sig.receiver() + && let Some((_, receiver_lt)) = receiver.reference.as_ref() + { + if let Some(receiver_lt) = receiver_lt { + lt = quote! { #receiver_lt }; + } else { + lt = quote! { '_ }; } } match &fn_item.sig.output { diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index cb11042eb..69f82e9d2 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -7,16 +7,13 @@ use crate::common::extract_doc_line; /// Check if a type is Json and extract the inner type T fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> { - if let syn::Type::Path(type_path) = ty { - if let Some(last_segment) = type_path.path.segments.last() { - if last_segment.ident == "Json" { - if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments { - if let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() { - return Some(inner_type); - } - } - } - } + if let syn::Type::Path(type_path) = ty + && let Some(last_segment) = type_path.path.segments.last() + && last_segment.ident == "Json" + && let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments + && let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() + { + return Some(inner_type); } None } @@ -286,13 +283,13 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { let omit_send = cfg!(feature = "local") || attribute.local; let new_output = syn::parse2::({ let mut lt = quote! { 'static }; - if let Some(receiver) = fn_item.sig.receiver() { - if let Some((_, receiver_lt)) = receiver.reference.as_ref() { - if let Some(receiver_lt) = receiver_lt { - lt = quote! { #receiver_lt }; - } else { - lt = quote! { '_ }; - } + if let Some(receiver) = fn_item.sig.receiver() + && let Some((_, receiver_lt)) = receiver.reference.as_ref() + { + if let Some(receiver_lt) = receiver_lt { + lt = quote! { #receiver_lt }; + } else { + lt = quote! { '_ }; } } match &fn_item.sig.output { diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 0724c01f2..93f98e428 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -3,6 +3,7 @@ name = "rmcp" license = { workspace = true } version = { workspace = true } edition = { workspace = true } +rust-version = { workspace = true } repository = { workspace = true } homepage = { workspace = true } readme = { workspace = true } diff --git a/crates/rmcp/src/handler/server/router/tool.rs b/crates/rmcp/src/handler/server/router/tool.rs index 31aa6d250..f8fab0239 100644 --- a/crates/rmcp/src/handler/server/router/tool.rs +++ b/crates/rmcp/src/handler/server/router/tool.rs @@ -550,10 +550,10 @@ where } fn notify_if_visible(&self, name: &str) { - if self.map.contains_key(name) { - if let Some(notifier) = &self.notifier { - notifier(); - } + if self.map.contains_key(name) + && let Some(notifier) = &self.notifier + { + notifier(); } } diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 3867502d0..29128fad6 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -983,17 +983,15 @@ impl EnumSchemaBuilder { return Err("One of the provided default values is not in enum values".to_string()); } } - if let Some(min) = self.min_items { - if (default_values.len() as u64) < min { - return Err("Number of provided default values is less than min_items".to_string()); - } + if let Some(min) = self.min_items + && (default_values.len() as u64) < min + { + return Err("Number of provided default values is less than min_items".to_string()); } - if let Some(max) = self.max_items { - if (default_values.len() as u64) > max { - return Err( - "Number of provided default values is greater than max_items".to_string(), - ); - } + if let Some(max) = self.max_items + && (default_values.len() as u64) > max + { + return Err("Number of provided default values is greater than max_items".to_string()); } self.default = default_values; Ok(self) diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 217464ff6..b1b2c2b7c 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -554,11 +554,10 @@ impl RequestHandle { None => None, } }, if reset_timeout_on_progress && idle_sleep.is_some() && self.progress_reset_rx.is_some() => { - if progress.is_some() { - if let Some((timeout, sleep)) = idle_sleep.as_mut() { + if progress.is_some() + && let Some((timeout, sleep)) = idle_sleep.as_mut() { sleep.as_mut().reset(tokio::time::Instant::now() + *timeout); } - } } } } @@ -1351,11 +1350,10 @@ where tracing::trace!(?evt, "new event"); match evt { Event::SendTaskResult(SendTaskResult::Request { id, result }) => { - if let Err(e) = result { - if let Some(responder) = local_responder_pool.remove(&id) { + if let Err(e) = result + && let Some(responder) = local_responder_pool.remove(&id) { let _ = responder.send(Err(ServiceError::TransportSend(e))); } - } } Event::SendTaskResult(SendTaskResult::Notification { responder, @@ -1368,16 +1366,14 @@ where Ok(()) }; let _ = responder.send(response); - if let Some(param) = cancellation_param { - if let Some(request_id) = ¶m.request_id { - if let Some(responder) = local_responder_pool.remove(request_id) { + if let Some(param) = cancellation_param + && let Some(request_id) = ¶m.request_id + && let Some(responder) = local_responder_pool.remove(request_id) { tracing::info!(id = %request_id, reason = param.reason, "cancelled"); let _response_result = responder.send(Err(ServiceError::Cancelled { reason: param.reason.clone(), })); } - } - } } Event::ResponseSendTaskResult(result) => { if let Err(error) = result { diff --git a/crates/rmcp/src/transport/async_rw.rs b/crates/rmcp/src/transport/async_rw.rs index f50d91334..46d5deaa3 100644 --- a/crates/rmcp/src/transport/async_rw.rs +++ b/crates/rmcp/src/transport/async_rw.rs @@ -320,14 +320,12 @@ fn try_parse_with_compatibility( Ok(item) => Ok(Some(item)), Err(e) => { // Check if this is a notification that should be ignored for compatibility - if let Ok(json_value) = serde_json::from_str::(line_str) { - if let Some(method) = + if let Ok(json_value) = serde_json::from_str::(line_str) + && let Some(method) = json_value.get("method").and_then(serde_json::Value::as_str) - { - if should_ignore_notification(&json_value, method) { - return Ok(None); - } - } + && should_ignore_notification(&json_value, method) + { + return Ok(None); } tracing::debug!( diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 74bbbf5de..139443a36 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1248,56 +1248,56 @@ impl AuthorizationManager { /// the client if credentials are found. Returns `false` when credentials /// are absent or discarded after an authorization-server change. pub async fn initialize_from_store(&mut self) -> Result { - if let Some(stored) = self.credential_store.load().await? { - if stored.token_response.is_some() { - if self.metadata.is_none() { - let metadata = self.discover_metadata().await?; - self.metadata = Some(metadata); - } - - if let (Some(stored_issuer), Some(current_issuer)) = - (stored.issuer.as_deref(), self.metadata_issuer().as_deref()) - { - // A CIMD client ID is the client's metadata URL, so it is - // portable across authorization servers and exempt here. - if stored_issuer != current_issuer { - if is_https_url(&stored.client_id) { - // A CIMD client ID is the client's metadata URL, so it is - // portable across authorization servers — but the tokens - // were minted by the previous AS and must not be reused. - tracing::warn!( - stored_issuer, - current_issuer, - "authorization server issuer changed; discarding tokens but keeping portable CIMD client ID" - ); - self.credential_store - .save( - StoredCredentials::new( - stored.client_id.clone(), - None, - vec![], - None, - ) - .with_issuer(self.metadata_issuer()), - ) - .await?; - self.configure_client_id(&stored.client_id)?; - return Ok(false); - } + if let Some(stored) = self.credential_store.load().await? + && stored.token_response.is_some() + { + if self.metadata.is_none() { + let metadata = self.discover_metadata().await?; + self.metadata = Some(metadata); + } + if let (Some(stored_issuer), Some(current_issuer)) = + (stored.issuer.as_deref(), self.metadata_issuer().as_deref()) + { + // A CIMD client ID is the client's metadata URL, so it is + // portable across authorization servers and exempt here. + if stored_issuer != current_issuer { + if is_https_url(&stored.client_id) { + // A CIMD client ID is the client's metadata URL, so it is + // portable across authorization servers — but the tokens + // were minted by the previous AS and must not be reused. tracing::warn!( stored_issuer, current_issuer, - "authorization server issuer changed; clearing stored credentials bound to the previous issuer" + "authorization server issuer changed; discarding tokens but keeping portable CIMD client ID" ); - self.credential_store.clear().await?; + self.credential_store + .save( + StoredCredentials::new( + stored.client_id.clone(), + None, + vec![], + None, + ) + .with_issuer(self.metadata_issuer()), + ) + .await?; + self.configure_client_id(&stored.client_id)?; return Ok(false); } - } - self.configure_client_id(&stored.client_id)?; - return Ok(true); + tracing::warn!( + stored_issuer, + current_issuer, + "authorization server issuer changed; clearing stored credentials bound to the previous issuer" + ); + self.credential_store.clear().await?; + return Ok(false); + } } + + self.configure_client_id(&stored.client_id)?; + return Ok(true); } Ok(false) } @@ -1425,10 +1425,10 @@ impl AuthorizationManager { // RFC 8414 RECOMMENDS response_types_supported in the metadata. This field is optional, // but if present and does not include the flow we use ("code"), bail out early with a clear error. - if let Some(response_types_supported) = metadata.response_types_supported.as_ref() { - if !response_types_supported.contains(&response_type.to_string()) { - return Err(AuthError::InvalidScope(response_type.to_string())); - } + if let Some(response_types_supported) = metadata.response_types_supported.as_ref() + && !response_types_supported.contains(&response_type.to_string()) + { + return Err(AuthError::InvalidScope(response_type.to_string())); } // The client always sends an S256 challenge. A server that advertises @@ -1713,12 +1713,11 @@ impl AuthorizationManager { } // nothing requested or challenged yet: seed from AS metadata, then caller defaults - if let Some(metadata) = &self.metadata { - if let Some(scopes_supported) = &metadata.scopes_supported { - if !scopes_supported.is_empty() { - return scopes_supported.clone(); - } - } + if let Some(metadata) = &self.metadata + && let Some(scopes_supported) = &metadata.scopes_supported + && !scopes_supported.is_empty() + { + return scopes_supported.clone(); } default_scopes.iter().map(|s| s.to_string()).collect() @@ -1730,12 +1729,11 @@ impl AuthorizationManager { if scopes.is_empty() || scopes.iter().any(|s| s == "offline_access") { return; } - if let Some(metadata) = &self.metadata { - if let Some(supported) = &metadata.scopes_supported { - if supported.iter().any(|s| s == "offline_access") { - scopes.push("offline_access".to_string()); - } - } + if let Some(metadata) = &self.metadata + && let Some(supported) = &metadata.scopes_supported + && supported.iter().any(|s| s == "offline_access") + { + scopes.push("offline_access".to_string()); } } @@ -2255,10 +2253,10 @@ impl AuthorizationManager { self.validate_resource_metadata_resource(&resource_metadata)?; // store scopes_supported from protected resource metadata for select_scopes() - if let Some(scopes) = resource_metadata.scopes_supported { - if !scopes.is_empty() { - *self.resource_scopes.write().await = scopes; - } + if let Some(scopes) = resource_metadata.scopes_supported + && !scopes.is_empty() + { + *self.resource_scopes.write().await = scopes; } let mut candidates = Vec::new(); @@ -2701,20 +2699,18 @@ impl AuthorizationManager { if let ClientCredentialsConfig::PrivateKeyJwt { signing_algorithm, .. } = config - { - if let Some(algs) = metadata + && let Some(algs) = metadata .additional_fields .get("token_endpoint_auth_signing_alg_values_supported") .and_then(|v| v.as_array()) - { - let alg_str = signing_algorithm.as_str(); - if !algs.iter().any(|a| a.as_str() == Some(alg_str)) { - let supported: Vec<&str> = algs.iter().filter_map(|a| a.as_str()).collect(); - return Err(AuthError::ClientCredentialsError(format!( - "Authorization server does not support signing algorithm '{}'. Supported: {:?}", - alg_str, supported - ))); - } + { + let alg_str = signing_algorithm.as_str(); + if !algs.iter().any(|a| a.as_str() == Some(alg_str)) { + let supported: Vec<&str> = algs.iter().filter_map(|a| a.as_str()).collect(); + return Err(AuthError::ClientCredentialsError(format!( + "Authorization server does not support signing algorithm '{}'. Supported: {:?}", + alg_str, supported + ))); } } diff --git a/crates/rmcp/src/transport/common/client_side_sse.rs b/crates/rmcp/src/transport/common/client_side_sse.rs index ce425c3a2..4ed05b44f 100644 --- a/crates/rmcp/src/transport/common/client_side_sse.rs +++ b/crates/rmcp/src/transport/common/client_side_sse.rs @@ -178,10 +178,10 @@ pub struct FixedInterval { impl SseRetryPolicy for FixedInterval { fn retry(&self, current_times: usize) -> Option { - if let Some(max_times) = self.max_times { - if current_times >= max_times { - return None; - } + if let Some(max_times) = self.max_times + && current_times >= max_times + { + return None; } Some(self.duration) } @@ -222,10 +222,10 @@ impl Default for ExponentialBackoff { impl SseRetryPolicy for ExponentialBackoff { fn retry(&self, current_times: usize) -> Option { - if let Some(max_times) = self.max_times { - if current_times >= max_times { - return None; - } + if let Some(max_times) = self.max_times + && current_times >= max_times + { + return None; } Some(self.base_duration * (2u32.pow(current_times as u32))) } diff --git a/crates/rmcp/src/transport/common/mcp_headers.rs b/crates/rmcp/src/transport/common/mcp_headers.rs index 12f8594c2..cc5506857 100644 --- a/crates/rmcp/src/transport/common/mcp_headers.rs +++ b/crates/rmcp/src/transport/common/mcp_headers.rs @@ -104,10 +104,10 @@ fn param_header_annotations(input_schema: &JsonObject) -> Vec<(String, String)> let mut out = Vec::new(); if let Some(Value::Object(props)) = input_schema.get("properties") { for (prop, schema) in props { - if let Some(Value::String(header)) = schema.get("x-mcp-header") { - if !header.is_empty() { - out.push((prop.clone(), header.clone())); - } + if let Some(Value::String(header)) = schema.get("x-mcp-header") + && !header.is_empty() + { + out.push((prop.clone(), header.clone())); } } } @@ -236,20 +236,18 @@ pub(crate) fn standard_request_headers( push(HEADER_MCP_NAME, &encode_header_value(&name)); } - if method == "tools/call" { - if let (Some(schema), Some(arguments)) = + if method == "tools/call" + && let (Some(schema), Some(arguments)) = (tool_schema, params.and_then(|p| p.get("arguments"))) - { - for (prop, header) in param_header_annotations(schema) { - let Some(arg) = arguments.get(&prop) else { - continue; - }; - let Some(encoded) = primitive_to_string(arg).map(|s| encode_header_value(&s)) - else { - continue; - }; - push(&format!("{HEADER_MCP_PARAM_PREFIX}{header}"), &encoded); - } + { + for (prop, header) in param_header_annotations(schema) { + let Some(arg) = arguments.get(&prop) else { + continue; + }; + let Some(encoded) = primitive_to_string(arg).map(|s| encode_header_value(&s)) else { + continue; + }; + push(&format!("{HEADER_MCP_PARAM_PREFIX}{header}"), &encoded); } } out @@ -296,34 +294,34 @@ pub(crate) fn validate_request_headers( } } - if method == "tools/call" { - if let Some(schema) = tool_schema { - let arguments = params.and_then(|p| p.get("arguments")); - for (prop, header) in param_header_annotations(schema) { - let full = format!("{HEADER_MCP_PARAM_PREFIX}{header}"); - let header_value = header_str(headers, &full); - let arg = arguments.and_then(|a| a.get(&prop)); - let body_value = arg.filter(|v| !v.is_null()).and_then(primitive_to_string); - - match (header_value, body_value) { - (None, None) => {} - (Some(_), None) => { + if method == "tools/call" + && let Some(schema) = tool_schema + { + let arguments = params.and_then(|p| p.get("arguments")); + for (prop, header) in param_header_annotations(schema) { + let full = format!("{HEADER_MCP_PARAM_PREFIX}{header}"); + let header_value = header_str(headers, &full); + let arg = arguments.and_then(|a| a.get(&prop)); + let body_value = arg.filter(|v| !v.is_null()).and_then(primitive_to_string); + + match (header_value, body_value) { + (None, None) => {} + (Some(_), None) => { + return Err(format!( + "unexpected {full} header for absent or null `{prop}`" + )); + } + (None, Some(_)) => { + return Err(format!("missing {full} header for `{prop}`")); + } + (Some(raw), Some(expected)) => { + let decoded = decode_header_value(raw) + .ok_or_else(|| format!("{full} header is not valid Base64"))?; + if decoded != expected { return Err(format!( - "unexpected {full} header for absent or null `{prop}`" + "{full} header `{decoded}` does not match body value `{expected}`" )); } - (None, Some(_)) => { - return Err(format!("missing {full} header for `{prop}`")); - } - (Some(raw), Some(expected)) => { - let decoded = decode_header_value(raw) - .ok_or_else(|| format!("{full} header is not valid Base64"))?; - if decoded != expected { - return Err(format!( - "{full} header `{decoded}` does not match body value `{expected}`" - )); - } - } } } } diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index d2557dc0e..e2eeebb4a 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -178,36 +178,36 @@ impl StreamableHttpClient for reqwest::Client { request = request.header(HEADER_SESSION_ID, session_id.as_ref()); } let response = request.json(&message).send().await?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { - let header = header - .to_str() - .map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })? - .to_string(); - return Err(StreamableHttpError::AuthRequired(AuthRequiredError { - www_authenticate_header: header, - })); - } - } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { - let header_str = header.to_str().map_err(|_| { + if response.status() == reqwest::StatusCode::UNAUTHORIZED + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header = header + .to_str() + .map_err(|_| { StreamableHttpError::UnexpectedServerResponse(Cow::from( "invalid www-authenticate header value", )) - })?; - let scope = extract_scope_from_header(header_str); - return Err(StreamableHttpError::InsufficientScope( - InsufficientScopeError { - www_authenticate_header: header_str.to_string(), - required_scope: scope, - }, - )); - } + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header: header, + })); + } + if response.status() == reqwest::StatusCode::FORBIDDEN + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); } let status = response.status(); if matches!( diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs index ef6555b7f..5f995db2b 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -226,37 +226,37 @@ impl StreamableHttpClient for UnixSocketHttpClient { let status = response.status(); - if status == StatusCode::UNAUTHORIZED { - if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { - let www_authenticate_header = header - .to_str() - .map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })? - .to_string(); - return Err(StreamableHttpError::AuthRequired(AuthRequiredError { - www_authenticate_header, - })); - } - } - - if status == StatusCode::FORBIDDEN { - if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { - let header_str = header.to_str().map_err(|_| { + if status == StatusCode::UNAUTHORIZED + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let www_authenticate_header = header + .to_str() + .map_err(|_| { StreamableHttpError::UnexpectedServerResponse(Cow::from( "invalid www-authenticate header value", )) - })?; - let scope = extract_scope_from_header(header_str); - return Err(StreamableHttpError::InsufficientScope( - InsufficientScopeError { - www_authenticate_header: header_str.to_string(), - required_scope: scope, - }, - )); - } + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header, + })); + } + + if status == StatusCode::FORBIDDEN + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); } if matches!(status, StatusCode::ACCEPTED | StatusCode::NO_CONTENT) { @@ -438,37 +438,37 @@ impl StreamableHttpClient for UnixSocketHttpClient { return Err(StreamableHttpError::ServerDoesNotSupportSse); } - if response.status() == StatusCode::UNAUTHORIZED { - if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { - let www_authenticate_header = header - .to_str() - .map_err(|_| { - StreamableHttpError::UnexpectedServerResponse(Cow::from( - "invalid www-authenticate header value", - )) - })? - .to_string(); - return Err(StreamableHttpError::AuthRequired(AuthRequiredError { - www_authenticate_header, - })); - } - } - - if response.status() == StatusCode::FORBIDDEN { - if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { - let header_str = header.to_str().map_err(|_| { + if response.status() == StatusCode::UNAUTHORIZED + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let www_authenticate_header = header + .to_str() + .map_err(|_| { StreamableHttpError::UnexpectedServerResponse(Cow::from( "invalid www-authenticate header value", )) - })?; - let scope = extract_scope_from_header(header_str); - return Err(StreamableHttpError::InsufficientScope( - InsufficientScopeError { - www_authenticate_header: header_str.to_string(), - required_scope: scope, - }, - )); - } + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header, + })); + } + + if response.status() == StatusCode::FORBIDDEN + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); } if !response.status().is_success() { diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 27e09c653..5194ba960 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -42,20 +42,20 @@ fn build_request_headers( use serde_json::Value; let mut headers = base.clone(); - if *version >= ProtocolVersion::STANDARD_HEADERS { - if let Ok(value) = serde_json::to_value(message) { - let schema = value - .get("method") - .and_then(Value::as_str) - .filter(|method| *method == "tools/call") - .and_then(|_| value.get("params")) - .and_then(|params| params.get("name")) - .and_then(Value::as_str) - .and_then(|name| tool_cache.get(name)) - .map(Arc::as_ref); - for (name, val) in mcp_headers::standard_request_headers(&value, schema) { - headers.insert(name, val); - } + if *version >= ProtocolVersion::STANDARD_HEADERS + && let Ok(value) = serde_json::to_value(message) + { + let schema = value + .get("method") + .and_then(Value::as_str) + .filter(|method| *method == "tools/call") + .and_then(|_| value.get("params")) + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + .and_then(|name| tool_cache.get(name)) + .map(Arc::as_ref); + for (name, val) in mcp_headers::standard_request_headers(&value, schema) { + headers.insert(name, val); } } headers @@ -90,9 +90,10 @@ fn cache_tools_from_response( if protocol_version < &ProtocolVersion::STANDARD_HEADERS { return; } - if let ServerJsonRpcMessage::Response(response) = message { - if let ServerResult::ListToolsResult(list) = &mut response.result { - list.tools.retain(|tool| { + if let ServerJsonRpcMessage::Response(response) = message + && let ServerResult::ListToolsResult(list) = &mut response.result + { + list.tools.retain(|tool| { let Err(reason) = mcp_headers::validate_param_header_annotations(&tool.input_schema) else { @@ -102,7 +103,6 @@ fn cache_tools_from_response( tracing::warn!(tool = %tool.name, "rejecting invalid x-mcp-header annotations: {reason}"); false }); - } } } @@ -112,13 +112,13 @@ fn negotiate_version_headers( ) -> (ProtocolVersion, HashMap) { let mut version = ProtocolVersion::default(); let mut headers = base; - if let ServerJsonRpcMessage::Response(response) = init_response { - if let ServerResult::InitializeResult(init_result) = &response.result { - version = init_result.protocol_version.clone(); - // HeaderName::from_static requires lowercase - if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { - headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); - } + if let ServerJsonRpcMessage::Response(response) = init_response + && let ServerResult::InitializeResult(init_result) = &response.result + { + version = init_result.protocol_version.clone(); + // HeaderName::from_static requires lowercase + if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { + headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); } } (version, headers) diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index dc8a70b87..231724ba7 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -479,11 +479,11 @@ impl LocalSessionWorker { &mut self, notification: &JsonRpcNotification, ) { - if let ClientNotification::CancelledNotification(n) = ¬ification.notification { - if let Some(request_id) = n.params.request_id.clone() { - let resource = ResourceKey::McpRequestId(request_id); - self.unregister_resource(&resource); - } + if let ClientNotification::CancelledNotification(n) = ¬ification.notification + && let Some(request_id) = n.params.request_id.clone() + { + let resource = ResourceKey::McpRequestId(request_id); + self.unregister_resource(&resource); } } fn evict_expired_channels(&mut self) { @@ -638,11 +638,9 @@ impl LocalSessionWorker { OutboundChannel::RequestWise { id, close } => { if let Some(request_wise) = self.tx_router.get_mut(&id) { request_wise.tx.send(message).await?; - if close { - if let Some(channel) = self.tx_router.remove(&id) { - for resource in channel.resources { - self.resource_router.remove(&resource); - } + if close && let Some(channel) = self.tx_router.remove(&id) { + for resource in channel.resources { + self.resource_router.remove(&resource); } } } else { diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs index 3cb5da31b..cbdd6ddf4 100644 --- a/crates/rmcp/tests/test_mrtr_behavior.rs +++ b/crates/rmcp/tests/test_mrtr_behavior.rs @@ -238,13 +238,13 @@ impl ClientHandler for MrtrClient { request: ElicitRequestParams, _context: RequestContext, ) -> Result { - if let ElicitRequestParams::FormElicitationParams { message, .. } = &request { - if message == "FAIL" { - return Err(ErrorData::internal_error( - "elicitation handler failed", - None, - )); - } + if let ElicitRequestParams::FormElicitationParams { message, .. } = &request + && message == "FAIL" + { + return Err(ErrorData::internal_error( + "elicitation handler failed", + None, + )); } Ok(ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "name": "Ferris" }))) } diff --git a/examples/transport/Cargo.toml b/examples/transport/Cargo.toml index a3db249b7..6ffe96304 100644 --- a/examples/transport/Cargo.toml +++ b/examples/transport/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "transport" edition = { workspace = true } +rust-version = { workspace = true } version = { workspace = true } authors = { workspace = true } license = { workspace = true } diff --git a/examples/wasi/Cargo.toml b/examples/wasi/Cargo.toml index 4cceb43de..4eb77350b 100644 --- a/examples/wasi/Cargo.toml +++ b/examples/wasi/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "wasi-mcp-example" edition = { workspace = true } +rust-version = { workspace = true } version = { workspace = true } authors = { workspace = true } license = { workspace = true } From 6839cfd61314c2a3c2bc51bf5563c42b1eee2fd2 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:22:20 -0400 Subject: [PATCH 270/333] fix!: omit resultType for legacy protocol sessions (#1038) --- crates/rmcp-macros/src/prompt_handler.rs | 2 +- crates/rmcp-macros/src/tool_handler.rs | 2 +- crates/rmcp/src/handler/server.rs | 12 +- crates/rmcp/src/handler/server/prompt.rs | 8 +- crates/rmcp/src/model.rs | 174 +++++++++++++++--- .../server_json_rpc_message_schema.json | 82 +++++---- ...erver_json_rpc_message_schema_current.json | 82 +++++---- crates/rmcp/tests/test_result_type_version.rs | 82 +++++++++ crates/rmcp/tests/test_result_type_wire.rs | 103 ++++++++++- 9 files changed, 438 insertions(+), 109 deletions(-) create mode 100644 crates/rmcp/tests/test_result_type_version.rs diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 88d78f70c..086eb0d52 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -61,7 +61,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result Result { let prompts = #router_expr.list_all(); Ok(rmcp::model::ListPromptsResult { - result_type: Default::default(), + result_type: Some(rmcp::model::ResultType::COMPLETE), prompts, meta: #meta, next_cursor: None, diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index 7732687d0..7614668a9 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -69,7 +69,7 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { Ok(rmcp::model::ListToolsResult{ - result_type: Default::default(), + result_type: Some(rmcp::model::ResultType::COMPLETE), tools: #router.list_all(), meta: #result_meta, next_cursor: None, diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index bebbcb9a9..39b89da00 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -55,7 +55,8 @@ impl Service for H { ) -> Result<::Resp, McpError> { // `context` is moved into the dispatch below, so read the negotiated version first. let protocol_version = context.protocol_version(); - let mrtr_supported = protocol_version + // SEP-2322 (`resultType` discriminator, MRTR) exists from 2026-07-28. + let sep_2322_supported = protocol_version .as_ref() .is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); let requested_version = context.meta.protocol_version(); @@ -240,13 +241,18 @@ impl Service for H { .map(ServerResult::task_ack) } }; - let result = result.and_then(|result| { - if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported { + let result = result.and_then(|mut result| { + if matches!(result, ServerResult::InputRequiredResult(_)) && !sep_2322_supported { Err(McpError::invalid_request( "InputRequiredResult requires negotiated protocol version 2026-07-28 or newer", None, )) } else { + // Peers on protocol versions older than 2026-07-28 keep the + // legacy wire shape without `resultType: "complete"`. + if !sep_2322_supported { + result.strip_result_type_for_legacy_peer(); + } Ok(result) } }); diff --git a/crates/rmcp/src/handler/server/prompt.rs b/crates/rmcp/src/handler/server/prompt.rs index a75e02713..b5b3c469b 100644 --- a/crates/rmcp/src/handler/server/prompt.rs +++ b/crates/rmcp/src/handler/server/prompt.rs @@ -108,13 +108,7 @@ impl IntoGetPromptResult for InputRequiredResult { impl IntoGetPromptResult for Vec { fn into_get_prompt_result(self) -> Result { - Ok(GetPromptResult { - result_type: Default::default(), - description: None, - messages: self, - meta: None, - } - .into()) + Ok(GetPromptResult::new(self).into()) } } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 6531e6ee5..307ce525f 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -776,6 +776,13 @@ impl From for () { /// so unknown values are preserved rather than rejected. Servers implementing this /// protocol version MUST include `resultType` in every result. For backward /// compatibility, clients MUST treat an absent field as `"complete"`. +/// +/// Ordinary results model the field as `Option`: `None` means the +/// field is absent on the wire. Constructors default to `Some(COMPLETE)`, and +/// the server handler strips the `"complete"` discriminator before responding +/// to peers that negotiated a protocol version older than `2026-07-28`, so +/// legacy sessions keep their historical wire shape (see +/// [`ServerResult::strip_result_type_for_legacy_peer`]). #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct ResultType(Cow<'static, str>); @@ -1473,14 +1480,23 @@ macro_rules! paginated_result { ($t:ident { $i_item: ident: $t_item: ty }) => { - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $t { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1502,10 +1518,16 @@ macro_rules! paginated_result { pub $i_item: $t_item, } + impl Default for $t { + fn default() -> Self { + Self::with_all_items(Default::default()) + } + } + impl $t { pub fn with_all_items(items: $t_item) -> Self { Self { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), meta: None, next_cursor: None, ttl_ms: None, @@ -1621,9 +1643,18 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct ReadResourceResult { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). /// Required by spec version 2026-07-28, but optional here to maintain compatibility /// with older spec versions. @@ -1648,7 +1679,7 @@ impl ReadResourceResult { /// Create a new ReadResourceResult with the given contents. pub fn new(contents: Vec) -> Self { Self { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), ttl_ms: None, cache_scope: None, contents, @@ -3208,24 +3239,39 @@ impl CompletionInfo { } } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CompleteResult { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, pub completion: CompletionInfo, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, } +impl Default for CompleteResult { + fn default() -> Self { + Self::new(CompletionInfo::default()) + } +} + impl CompleteResult { /// Create a new CompleteResult with the given completion info. pub fn new(completion: CompletionInfo) -> Self { Self { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), completion, meta: None, } @@ -3674,14 +3720,23 @@ pub type CreateElicitationRequest = ElicitRequest; /// /// Contains the content returned by the tool execution and an optional /// flag indicating whether the operation resulted in an error. -#[derive(Default, Debug, Serialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CallToolResult { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, /// The content returned by the tool (text, images, etc.) #[serde(default)] pub content: Vec, @@ -3710,7 +3765,7 @@ impl<'de> Deserialize<'de> for CallToolResult { #[serde(rename_all = "camelCase")] struct Helper { #[serde(default)] - result_type: ResultType, + result_type: Option, content: Option>, structured_content: Option, is_error: Option, @@ -3741,11 +3796,23 @@ impl<'de> Deserialize<'de> for CallToolResult { } } +impl Default for CallToolResult { + fn default() -> Self { + CallToolResult { + result_type: Some(ResultType::COMPLETE), + content: Vec::new(), + structured_content: None, + is_error: None, + meta: None, + } + } +} + impl CallToolResult { /// Create a successful tool result with unstructured content pub fn success(content: Vec) -> Self { CallToolResult { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), content, structured_content: None, is_error: Some(false), @@ -3803,7 +3870,7 @@ impl CallToolResult { /// ``` pub fn error(content: Vec) -> Self { CallToolResult { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), content, structured_content: None, is_error: Some(true), @@ -3826,7 +3893,7 @@ impl CallToolResult { /// ``` pub fn structured(value: Value) -> Self { CallToolResult { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(false), @@ -3853,7 +3920,7 @@ impl CallToolResult { /// ``` pub fn structured_error(value: Value) -> Self { CallToolResult { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(true), @@ -4041,14 +4108,23 @@ impl CreateMessageResult { } } -#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetPromptResult { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub messages: Vec, @@ -4056,11 +4132,17 @@ pub struct GetPromptResult { pub meta: Option, } +impl Default for GetPromptResult { + fn default() -> Self { + Self::new(Vec::new()) + } +} + impl GetPromptResult { /// Create a new GetPromptResult with required fields. pub fn new(messages: Vec) -> Self { Self { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), description: None, messages, meta: None, @@ -4424,6 +4506,42 @@ impl ServerResult { pub fn task_ack(_: ()) -> ServerResult { ServerResult::TaskAckResult(TaskAckResult::new()) } + + /// Strip the SEP-2322 `resultType: "complete"` discriminator so the result + /// keeps the wire shape that predates protocol version `2026-07-28`. + /// + /// The server handler calls this before responding to a peer that + /// negotiated an older protocol version, where the field did not exist and + /// strict peers may reject it. Only the `"complete"` value is stripped: + /// results whose discriminator carries meaning (`"input_required"`, + /// `"task"`) are already gated to `2026-07-28`+ sessions, and custom + /// extension values are preserved. + /// + /// # Examples + /// + /// ``` + /// use rmcp::model::{CallToolResult, ServerResult}; + /// + /// let mut result = ServerResult::CallToolResult(CallToolResult::success(vec![])); + /// result.strip_result_type_for_legacy_peer(); + /// + /// let json = serde_json::to_value(&result).unwrap(); + /// assert!(json.get("resultType").is_none()); + /// ``` + pub fn strip_result_type_for_legacy_peer(&mut self) { + let result_type = match self { + ServerResult::CompleteResult(r) => &mut r.result_type, + ServerResult::GetPromptResult(r) => &mut r.result_type, + ServerResult::ListPromptsResult(r) => &mut r.result_type, + ServerResult::ListResourcesResult(r) => &mut r.result_type, + ServerResult::ListResourceTemplatesResult(r) => &mut r.result_type, + ServerResult::ReadResourceResult(r) => &mut r.result_type, + ServerResult::ListToolsResult(r) => &mut r.result_type, + ServerResult::CallToolResult(r) => &mut r.result_type, + _ => return, + }; + result_type.take_if(|result_type| result_type.is_complete()); + } } pub type ServerJsonRpcMessage = JsonRpcMessage; diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index b38db540c..595281eca 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -199,13 +199,15 @@ ] }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" @@ -326,13 +328,15 @@ "$ref": "#/definitions/CompletionInfo" }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] } }, "required": [ @@ -1140,13 +1144,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] } }, "required": [ @@ -1783,13 +1789,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1842,13 +1850,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1901,13 +1911,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1959,13 +1971,15 @@ ] }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "tools": { "type": "array", @@ -2581,13 +2595,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -2948,7 +2964,7 @@ } }, "ResultType": { - "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.", + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", "type": "string" }, "Role": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index b38db540c..595281eca 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -199,13 +199,15 @@ ] }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" @@ -326,13 +328,15 @@ "$ref": "#/definitions/CompletionInfo" }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] } }, "required": [ @@ -1140,13 +1144,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] } }, "required": [ @@ -1783,13 +1789,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1842,13 +1850,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1901,13 +1911,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1959,13 +1971,15 @@ ] }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "tools": { "type": "array", @@ -2581,13 +2595,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -2948,7 +2964,7 @@ } }, "ResultType": { - "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.", + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", "type": "string" }, "Role": { diff --git a/crates/rmcp/tests/test_result_type_version.rs b/crates/rmcp/tests/test_result_type_version.rs new file mode 100644 index 000000000..849a6610e --- /dev/null +++ b/crates/rmcp/tests/test_result_type_version.rs @@ -0,0 +1,82 @@ +//! SEP-2322: the `resultType` discriminator follows the negotiated protocol version. +//! +//! Peers negotiating `2026-07-28` or newer receive `resultType: "complete"` on +//! ordinary results; older peers keep the legacy wire shape without the field. +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, RoleServer, ServerHandler, ServiceExt, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, + ErrorData, ProtocolVersion, ResultType, + }, + service::RequestContext, +}; + +#[derive(Debug, Clone, Default)] +struct ToolServer; + +impl ServerHandler for ToolServer { + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn call_tool_result_type(client_version: ProtocolVersion) -> Option { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + ToolServer.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + let client = VersionedClient { + protocol_version: client_version, + } + .serve(client_transport) + .await + .expect("client should connect"); + + let result = client + .call_tool(CallToolRequestParams::new("echo")) + .await + .expect("tool call should succeed"); + + client.cancel().await.expect("client should cancel"); + server_handle.await.expect("server task").expect("server"); + result.result_type +} + +#[tokio::test] +async fn legacy_version_omits_result_type() { + assert_eq!( + call_tool_result_type(ProtocolVersion::V_2025_11_25).await, + None + ); +} + +#[tokio::test] +async fn sep_2322_version_gets_complete_result_type() { + assert_eq!( + call_tool_result_type(ProtocolVersion::V_2026_07_28).await, + Some(ResultType::COMPLETE), + ); +} diff --git a/crates/rmcp/tests/test_result_type_wire.rs b/crates/rmcp/tests/test_result_type_wire.rs index 9c340ff88..d5f201532 100644 --- a/crates/rmcp/tests/test_result_type_wire.rs +++ b/crates/rmcp/tests/test_result_type_wire.rs @@ -2,10 +2,16 @@ //! //! These pin the behavior that keeps older/strict peers working: //! - `EmptyResult` stays a bare `{}` (some peers strict-validate empty results -//! and reject extra keys), and -//! - ordinary results carry `resultType: "complete"`. +//! and reject extra keys), +//! - ordinary results carry `resultType: "complete"` by default, and +//! - [`ServerResult::strip_result_type_for_legacy_peer`] removes the +//! `"complete"` discriminator so legacy sessions keep their historical wire +//! shape (round-tripping a legacy result never adds the field). -use rmcp::model::{CallToolResult, ContentBlock, EmptyResult, ListToolsResult}; +use rmcp::model::{ + CallToolResult, CompleteResult, ContentBlock, EmptyResult, GetPromptResult, ListToolsResult, + ReadResourceResult, ResultType, ServerResult, +}; use serde_json::json; #[test] @@ -27,3 +33,94 @@ fn paginated_result_serializes_complete_result_type() { serde_json::to_value(ListToolsResult::default()).expect("serialize ListToolsResult"); assert_eq!(value["resultType"], "complete"); } + +// Guards the convention the server handler relies on: every constructor and +// `Default` impl produces `Some(COMPLETE)`, so 2026-07-28 sessions always +// include the spec-required field unless a handler clears it explicitly. +#[test] +fn constructors_and_defaults_produce_complete_result_type() { + let complete = Some(ResultType::COMPLETE); + assert_eq!(CallToolResult::default().result_type, complete); + assert_eq!(CallToolResult::success(vec![]).result_type, complete); + assert_eq!(CallToolResult::error(vec![]).result_type, complete); + assert_eq!(CallToolResult::structured(json!({})).result_type, complete); + assert_eq!( + CallToolResult::structured_error(json!({})).result_type, + complete + ); + assert_eq!(ListToolsResult::default().result_type, complete); + assert_eq!( + ListToolsResult::with_all_items(vec![]).result_type, + complete + ); + assert_eq!(ReadResourceResult::new(vec![]).result_type, complete); + assert_eq!(GetPromptResult::default().result_type, complete); + assert_eq!(GetPromptResult::new(vec![]).result_type, complete); + assert_eq!(CompleteResult::default().result_type, complete); +} + +#[test] +fn absent_result_type_deserializes_as_none() { + let legacy = json!({ + "content": [], + "isError": false, + }); + + let result: CallToolResult = + serde_json::from_value(legacy).expect("deserialize legacy CallToolResult"); + + assert_eq!(result.result_type, None); +} + +#[test] +fn legacy_call_tool_result_round_trips_without_result_type() { + let legacy = json!({ + "content": [], + "isError": false, + }); + + let result: CallToolResult = + serde_json::from_value(legacy.clone()).expect("deserialize legacy CallToolResult"); + let reserialized = serde_json::to_value(result).expect("serialize CallToolResult"); + + assert_eq!(reserialized, legacy); +} + +#[test] +fn strip_removes_complete_result_type() { + let mut result = + ServerResult::CallToolResult(CallToolResult::success(vec![ContentBlock::text("ok")])); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!( + value, + json!({ + "content": [{ "type": "text", "text": "ok" }], + "isError": false, + }) + ); +} + +#[test] +fn strip_removes_complete_result_type_from_paginated_result() { + let mut result = ServerResult::ListToolsResult(ListToolsResult::default()); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!(value, json!({ "tools": [] })); +} + +#[test] +fn strip_preserves_custom_result_type() { + let streaming: ResultType = + serde_json::from_value(json!("streaming")).expect("deserialize ResultType"); + let mut tool_result = CallToolResult::success(vec![ContentBlock::text("ok")]); + tool_result.result_type = Some(streaming); + + let mut result = ServerResult::CallToolResult(tool_result); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!(value["resultType"], "streaming"); +} From 60a7a7570356b731b4a7a5b44e703bb3976920b1 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:07:06 -0400 Subject: [PATCH 271/333] feat: expose oauth discovery metadata source (#1041) * feat: expose oauth discovery metadata source * feat!: return source from metadata resolution --- conformance/src/bin/client.rs | 10 +- crates/rmcp/src/transport/auth.rs | 242 ++++++++++++++++++++++++++---- docs/OAUTH_SUPPORT.md | 52 ++++++- examples/clients/README.md | 12 ++ 4 files changed, 277 insertions(+), 39 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 9d1348600..852ca0917 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -509,8 +509,8 @@ async fn migration_token( return Ok(manager.get_access_token().await?); } - let metadata = manager.discover_metadata().await?; - manager.set_metadata(metadata); + let resolution = manager.resolve_metadata().await?; + manager.set_metadata(resolution.metadata); manager .register_client("conformance-client", REDIRECT_URI, &[]) .await?; @@ -609,9 +609,9 @@ async fn run_client_credentials_basic( .unwrap_or("conformance-test-secret"); let mut manager = AuthorizationManager::new(server_url).await?; - let metadata = manager.discover_metadata().await?; - let token_endpoint = metadata.token_endpoint.clone(); - manager.set_metadata(metadata); + let resolution = manager.resolve_metadata().await?; + let token_endpoint = resolution.metadata.token_endpoint.clone(); + manager.set_metadata(resolution.metadata); let http = reqwest::Client::new(); let resp = http diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 139443a36..26e53785d 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -578,6 +578,43 @@ pub struct AuthorizationMetadata { pub additional_fields: HashMap, } +/// How [`AuthorizationMetadata`] was obtained during discovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationMetadataSource { + /// Discovered through RFC 9728 protected resource metadata. + ProtectedResourceMetadata, + /// Discovered through RFC 8414 / OpenID Connect metadata at the server's + /// base URL. + AuthorizationServerMetadata, + /// Nothing was discovered; the endpoints were synthesized from the base + /// URL (`/authorize`, `/token`, `/register`) for compatibility with the + /// 2025-03-26 MCP spec's default-endpoint fallback. The server gave no + /// evidence that it supports OAuth. + /// + /// [Newer MCP revisions] require metadata discovery and do not define an + /// endpoint-synthesis fallback. + /// + /// [Newer MCP revisions]: https://modelcontextprotocol.io/specification/draft/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements + LegacyEndpointFallback, +} + +impl AuthorizationMetadataSource { + /// Whether the metadata was actually published by the server, as opposed + /// to synthesized by the client as a legacy compatibility fallback. + pub fn is_discovered(self) -> bool { + !matches!(self, Self::LegacyEndpointFallback) + } +} + +/// [`AuthorizationMetadata`] together with how it was resolved. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AuthorizationMetadataResolution { + pub metadata: AuthorizationMetadata, + pub source: AuthorizationMetadataSource, +} + #[derive(Debug, Clone, Deserialize)] struct ResourceServerMetadata { resource: Option, @@ -1236,8 +1273,10 @@ impl AuthorizationManager { /// Set OAuth2 authorization metadata /// - /// This should be called after discovering metadata via `discover_metadata()` - /// and before creating an `AuthorizationSession`. + /// This should be called with + /// [`AuthorizationMetadataResolution::metadata`] after + /// [`Self::resolve_metadata`] and before creating an + /// [`AuthorizationSession`]. pub fn set_metadata(&mut self, metadata: AuthorizationMetadata) { self.metadata = Some(metadata); } @@ -1252,8 +1291,8 @@ impl AuthorizationManager { && stored.token_response.is_some() { if self.metadata.is_none() { - let metadata = self.discover_metadata().await?; - self.metadata = Some(metadata); + let resolution = self.resolve_metadata().await?; + self.metadata = Some(resolution.metadata); } if let (Some(stored_issuer), Some(current_issuer)) = @@ -1320,18 +1359,51 @@ impl AuthorizationManager { Ok(()) } - /// discover oauth2 metadata (per SEP-985: Protected Resource Metadata first, then direct OAuth) - pub async fn discover_metadata(&self) -> Result { + /// Resolve OAuth 2.0 metadata and report how it was obtained. + /// + /// Discovery follows SEP-985: protected resource metadata first, then + /// direct OAuth 2.0 Authorization Server Metadata or OpenID Connect + /// Discovery. When discovery finds nothing, the result contains legacy + /// default endpoints derived from the base URL and + /// [`AuthorizationMetadataSource::LegacyEndpointFallback`]. + /// + /// # Examples + /// + /// ```no_run + /// use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadataSource}; + /// + /// # async fn resolve() -> Result<(), Box> { + /// let mut manager = AuthorizationManager::new("https://mcp.example.com").await?; + /// let resolution = manager.resolve_metadata().await?; + /// + /// if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback { + /// println!("the server did not publish OAuth metadata"); + /// } + /// + /// manager.set_metadata(resolution.metadata); + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_metadata(&self) -> Result { if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? { - return Ok(metadata); + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::ProtectedResourceMetadata, + }); } if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? { - return Ok(metadata); + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::AuthorizationServerMetadata, + }); } debug!("falling back to legacy OAuth endpoints derived from the base URL"); - Ok(Self::legacy_authorization_metadata(&self.base_url)) + Ok(AuthorizationMetadataResolution { + metadata: Self::legacy_authorization_metadata(&self.base_url), + source: AuthorizationMetadataSource::LegacyEndpointFallback, + }) } fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata { @@ -3413,8 +3485,8 @@ impl OAuthState { *manager.current_scopes.write().await = granted_scopes.clone(); - let metadata = manager.discover_metadata().await?; - manager.metadata = Some(metadata); + let resolution = manager.resolve_metadata().await?; + manager.metadata = Some(resolution.metadata); let stored = StoredCredentials { client_id: client_id.to_string(), @@ -3468,8 +3540,8 @@ impl OAuthState { )); }; debug!("start discovery"); - let metadata = match manager.discover_metadata().await { - Ok(metadata) => metadata, + let metadata = match manager.resolve_metadata().await { + Ok(resolution) => resolution.metadata, Err(e) => { *self = OAuthState::Unauthorized(manager); return Err(e); @@ -3661,8 +3733,8 @@ impl OAuthState { }; // Discover metadata - let metadata = manager.discover_metadata().await?; - manager.metadata = Some(metadata); + let resolution = manager.resolve_metadata().await?; + manager.metadata = Some(resolution.metadata); // Validate server supports the requested auth method manager.validate_client_credentials_metadata(&config)?; @@ -3691,10 +3763,10 @@ mod tests { use super::{ AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, - AuthorizationRequest, AuthorizationSession, CredentialStore, InMemoryCredentialStore, - InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError, - OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, - StateStore, StoredAuthorizationState, is_https_url, + AuthorizationMetadataSource, AuthorizationRequest, AuthorizationSession, CredentialStore, + InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, + OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, + ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, }; use crate::transport::auth::VendorExtraTokenFields; @@ -3797,7 +3869,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!(metadata.token_endpoint, "https://auth.example.com/token"); assert_eq!( @@ -3860,7 +3932,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!( ( @@ -3919,7 +3991,7 @@ mod tests { .await .unwrap(); - let error = manager.discover_metadata().await.unwrap_err(); + let error = manager.resolve_metadata().await.unwrap_err(); assert!( matches!( @@ -4042,7 +4114,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!( ( @@ -4077,7 +4149,7 @@ mod tests { } #[tokio::test] - async fn discover_metadata_falls_back_to_legacy_default_endpoints() { + async fn resolve_metadata_reports_legacy_fallback_when_nothing_is_discovered() { let client = RecordingOAuthHttpClient::with_responses(vec![ empty_response(404), empty_response(404), @@ -4092,13 +4164,14 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let resolution = manager.resolve_metadata().await.unwrap(); assert_eq!( ( - metadata.authorization_endpoint.as_str(), - metadata.token_endpoint.as_str(), - metadata.registration_endpoint.as_deref(), + resolution.source, + resolution.metadata.authorization_endpoint.as_str(), + resolution.metadata.token_endpoint.as_str(), + resolution.metadata.registration_endpoint.as_deref(), client .requests() .iter() @@ -4106,6 +4179,7 @@ mod tests { .collect::>(), ), ( + AuthorizationMetadataSource::LegacyEndpointFallback, "https://legacy.example.com/authorize", "https://legacy.example.com/token", Some("https://legacy.example.com/register"), @@ -4120,6 +4194,108 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_reports_protected_resource_metadata() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_reports_authorization_server_metadata() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(404), + empty_response(404), + empty_response(404), + http_response( + 200, + serde_json::json!({ + "issuer": "https://mcp.example.com", + "authorization_endpoint": "https://mcp.example.com/oauth/authorize", + "token_endpoint": "https://mcp.example.com/oauth/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::AuthorizationServerMetadata, + "https://mcp.example.com/oauth/token", + ) + ); + } + + #[rstest] + #[case::protected_resource_metadata( + AuthorizationMetadataSource::ProtectedResourceMetadata, + true + )] + #[case::authorization_server_metadata( + AuthorizationMetadataSource::AuthorizationServerMetadata, + true + )] + #[case::legacy_endpoint_fallback(AuthorizationMetadataSource::LegacyEndpointFallback, false)] + fn is_discovered_is_false_only_for_the_legacy_fallback( + #[case] source: AuthorizationMetadataSource, + #[case] expected: bool, + ) { + assert_eq!(source.is_discovered(), expected); + } + fn preregistered_as_metadata_response() -> HttpResponse { http_response( 200, @@ -4206,7 +4382,7 @@ mod tests { ) .await .unwrap(); - manager.metadata = Some(manager.discover_metadata().await.unwrap()); + manager.metadata = Some(manager.resolve_metadata().await.unwrap().metadata); let request = AuthorizationRequest::new("http://localhost:8080/callback") .with_preregistered_client("preregistered-client"); @@ -4661,7 +4837,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; let requests = client.requests(); assert_eq!( @@ -4718,7 +4894,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!( ( @@ -4767,7 +4943,7 @@ mod tests { .await .unwrap(); - let error = manager.discover_metadata().await.unwrap_err(); + let error = manager.resolve_metadata().await.unwrap_err(); assert!( matches!(error, AuthError::MetadataError(ref message) if message.contains("resource mismatch")), @@ -4802,7 +4978,7 @@ mod tests { .await .unwrap(); - let error = manager.discover_metadata().await.unwrap_err(); + let error = manager.resolve_metadata().await.unwrap_err(); assert!( matches!(error, AuthError::MetadataError(ref message) if message.contains("missing required resource")), diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index b9d8e1ca5..1d09d7402 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -108,6 +108,51 @@ Use this path when OAuth traffic must go through a browser fetch API, a remote execution environment, a company gateway, a test fake, or any other non-reqwest transport. +#### Inspect discovery provenance directly + +Most applications can use `OAuthState` without calling metadata discovery +directly. When using `AuthorizationManager`, `resolve_metadata()` returns both +the metadata and how it was obtained. A client that supports the 2025-03-26 +default-endpoint fallback can continue with synthesized metadata, while a +client that requires server-published metadata should reject that result: + +```rust ignore +use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadataSource}; + +async fn configure_metadata( + manager: &mut AuthorizationManager, + allow_legacy_endpoint_fallback: bool, +) -> anyhow::Result<()> { + let resolution = manager.resolve_metadata().await?; + + if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback { + if !allow_legacy_endpoint_fallback { + anyhow::bail!("the server did not publish OAuth metadata"); + } + + tracing::warn!( + "the server did not publish OAuth metadata; using the 2025-03-26 fallback endpoints" + ); + } + + manager.set_metadata(resolution.metadata); + Ok(()) +} +``` + +`ProtectedResourceMetadata` and `AuthorizationServerMetadata` indicate +server-published metadata, so clients can proceed with the returned metadata. +`LegacyEndpointFallback` indicates endpoints synthesized for compatibility +with the 2025-03-26 MCP specification. Clients should proceed only when they +intentionally support that legacy behavior; clients using discovery as an +OAuth capability check should treat it as unsupported. + +Applications using `OAuthState` do not need to handle these sources directly: +the state machine resolves metadata internally and retains the legacy fallback. +Low-level `AuthorizationManager` users can use +`AuthorizationMetadataSource::is_discovered()` when they only need to +distinguish server-published metadata from synthesized metadata. + ### 3. Start authorization with OAuthState The `OAuthState` state machine manages the full authorization lifecycle. @@ -229,7 +274,8 @@ match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await { ## Complete Examples -- **Client**: [`examples/clients/src/auth/oauth_client.rs`](../examples/clients/src/auth/oauth_client.rs) +- **Authorization Code client**: [`examples/clients/src/auth/oauth_client.rs`](../examples/clients/src/auth/oauth_client.rs) +- **Client Credentials client**: [`examples/clients/src/auth/client_credentials.rs`](../examples/clients/src/auth/client_credentials.rs) - **Server**: [`examples/servers/src/complex_auth_streamhttp.rs`](../examples/servers/src/complex_auth_streamhttp.rs) ### Running the Examples @@ -240,6 +286,10 @@ cargo run -p mcp-server-examples --example servers_complex_auth_streamhttp # Run the OAuth client (in another terminal) cargo run -p mcp-client-examples --example clients_oauth_client + +# Run the Client Credentials client +cargo run -p mcp-client-examples --example clients_client_credentials -- \ + ``` ## Authorization Flow Description diff --git a/examples/clients/README.md b/examples/clients/README.md index 76aa97389..36bdd590a 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -57,6 +57,14 @@ A client demonstrating how to authenticate with an MCP server using OAuth. - Establishes an authorized connection to the MCP server using the acquired access token - Demonstrates how to use the authorized connection to retrieve available tools and prompts +### OAuth Client Credentials (`auth/client_credentials.rs`) + +A client demonstrating the OAuth 2.0 Client Credentials flow from SEP-1046. + +- Accepts the server URL, client ID, and client secret as command-line arguments +- Authenticates without an interactive browser or callback server +- Establishes an authorized connection and retrieves the available tools + ### Sampling Standard I/O Client (`sampling_stdio.rs`) @@ -107,6 +115,10 @@ cargo run -p mcp-client-examples --example clients_collection # Run the OAuth client example cargo run -p mcp-client-examples --example clients_oauth_client +# Run the OAuth Client Credentials example +cargo run -p mcp-client-examples --example clients_client_credentials -- \ + + # Run the sampling standard I/O client example cargo run -p mcp-client-examples --example clients_sampling_stdio From 14298b72e0b25473ea79d5465fe186e22eb86397 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:56:40 -0400 Subject: [PATCH 272/333] chore: release v3.0.0-beta.2 (#1035) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 10 ++++++++++ crates/rmcp/CHANGELOG.md | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5c4ad4026..821d90cdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.0.0-beta.1", path = "./crates/rmcp" } -rmcp-macros = { version = "3.0.0-beta.1", path = "./crates/rmcp-macros" } +rmcp = { version = "3.0.0-beta.2", path = "./crates/rmcp" } +rmcp-macros = { version = "3.0.0-beta.2", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.0.0-beta.1" +version = "3.0.0-beta.2" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 997c9793e..28c40f4a1 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0-beta.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.1...rmcp-macros-v3.0.0-beta.2) - 2026-07-24 + +### Fixed + +- [**breaking**] omit resultType for legacy protocol sessions ([#1038](https://github.com/modelcontextprotocol/rust-sdk/pull/1038)) + +### Other + +- declare and check MSRV ([#1034](https://github.com/modelcontextprotocol/rust-sdk/pull/1034)) + ## [3.0.0-beta.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v2.2.0...rmcp-macros-v3.0.0-beta.1) - 2026-07-23 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index dbd82c762..1bdab3317 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0-beta.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.1...rmcp-v3.0.0-beta.2) - 2026-07-24 + +### Added + +- expose oauth discovery metadata source ([#1041](https://github.com/modelcontextprotocol/rust-sdk/pull/1041)) + +### Fixed + +- [**breaking**] omit resultType for legacy protocol sessions ([#1038](https://github.com/modelcontextprotocol/rust-sdk/pull/1038)) + +### Other + +- declare and check MSRV ([#1034](https://github.com/modelcontextprotocol/rust-sdk/pull/1034)) + ## [3.0.0-beta.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.2.0...rmcp-v3.0.0-beta.1) - 2026-07-23 ### Added From 92c0793247b7cca6d8be4279c6533cd36ce647f0 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 27 Jul 2026 10:35:03 -0400 Subject: [PATCH 273/333] fix: default to allowing missing `issuer` (#1051) --- crates/rmcp/src/transport/auth.rs | 76 +++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 26e53785d..541cf36c5 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1000,6 +1000,7 @@ pub struct AuthorizationManager { resource_scopes: RwLock>, /// OIDC Dynamic Client Registration `application_type` (SEP-837) application_type: Option, + strict_issuer_validation: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1245,6 +1246,7 @@ impl AuthorizationManager { www_auth_scopes: RwLock::new(Vec::new()), resource_scopes: RwLock::new(Vec::new()), application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), + strict_issuer_validation: false, }; Ok(manager) @@ -1255,6 +1257,17 @@ impl AuthorizationManager { self.scope_upgrade_config = config; } + /// Configure whether authorization server metadata discovery requires the + /// metadata `issuer` field. + /// + /// The default is `false` to preserve compatibility with legacy + /// authorization servers that omit `issuer`. Set this to `true` to enforce + /// the RFC 8414/OIDC requirement that discovered metadata include `issuer` + /// whenever the expected issuer can be derived from the discovery URL. + pub fn set_strict_issuer_validation(&mut self, strict: bool) { + self.strict_issuer_validation = strict; + } + /// Set a custom credential store /// /// This allows you to provide your own implementation of credential storage, @@ -2226,7 +2239,7 @@ impl AuthorizationManager { match serde_json::from_slice::(response.body()) { Ok(metadata) => { - Self::validate_authorization_metadata_issuer(discovery_url, &metadata)?; + self.validate_authorization_metadata_issuer(discovery_url, &metadata)?; Ok(Some(metadata)) } Err(err) => { @@ -2288,6 +2301,7 @@ impl AuthorizationManager { } fn validate_authorization_metadata_issuer( + &self, discovery_url: &Url, metadata: &AuthorizationMetadata, ) -> Result<(), AuthError> { @@ -2297,7 +2311,10 @@ impl AuthorizationManager { return Ok(()); }; let Some(received_issuer) = metadata.issuer.as_deref() else { - return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer }); + if self.strict_issuer_validation { + return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer }); + } + return Ok(()); }; if !Self::issuer_identifiers_match(received_issuer, &expected_issuer) { return Err(AuthError::AuthorizationServerMismatch { @@ -4006,8 +4023,8 @@ mod tests { ); } - #[test] - fn authorization_metadata_accepts_oidc_path_appended_issuer() { + #[tokio::test] + async fn authorization_metadata_accepts_oidc_path_appended_issuer() { let discovery_url = Url::parse("https://auth.example.com/tenant1/.well-known/openid-configuration") .unwrap(); @@ -4017,8 +4034,12 @@ mod tests { token_endpoint: "https://auth.example.com/tenant1/token".to_string(), ..Default::default() }; + let manager = AuthorizationManager::new("https://mcp.example.com/") + .await + .unwrap(); - AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata) + manager + .validate_authorization_metadata_issuer(&discovery_url, &metadata) .unwrap(); } @@ -4034,8 +4055,8 @@ mod tests { )); } - #[test] - fn authorization_metadata_accepts_oidc_path_inserted_issuer() { + #[tokio::test] + async fn authorization_metadata_accepts_oidc_path_inserted_issuer() { let discovery_url = Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") .unwrap(); @@ -4045,13 +4066,17 @@ mod tests { token_endpoint: "https://auth.example.com/tenant1/token".to_string(), ..Default::default() }; + let manager = AuthorizationManager::new("https://mcp.example.com/") + .await + .unwrap(); - AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata) + manager + .validate_authorization_metadata_issuer(&discovery_url, &metadata) .unwrap(); } - #[test] - fn authorization_metadata_rejects_missing_issuer_for_standard_discovery_url() { + #[tokio::test] + async fn authorization_metadata_allows_missing_issuer_by_default() { let discovery_url = Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") .unwrap(); @@ -4062,9 +4087,34 @@ mod tests { ..Default::default() }; - let error = - AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata) - .unwrap_err(); + let manager = AuthorizationManager::new("https://mcp.example.com/") + .await + .unwrap(); + + manager + .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap(); + } + + #[tokio::test] + async fn authorization_metadata_rejects_missing_issuer_when_required() { + let discovery_url = + Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") + .unwrap(); + let metadata = AuthorizationMetadata { + issuer: None, + authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(), + token_endpoint: "https://auth.example.com/tenant1/token".to_string(), + ..Default::default() + }; + let mut manager = AuthorizationManager::new("https://mcp.example.com/") + .await + .unwrap(); + manager.set_strict_issuer_validation(true); + + let error = manager + .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .unwrap_err(); assert!( matches!( From c284607794a02a22fe78adc9c2cad8023ae62499 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:43:06 -0400 Subject: [PATCH 274/333] refactor: make oauth discovery reactive (#1052) --- conformance/src/bin/client.rs | 110 +++++-- crates/rmcp/src/service/client.rs | 25 ++ crates/rmcp/src/transport/auth.rs | 282 ++++++++++++------ .../common/auth/streamable_http_client.rs | 188 ++++++++---- .../src/transport/streamable_http_client.rs | 26 +- examples/clients/src/auth/oauth_client.rs | 191 ++++++++---- 6 files changed, 581 insertions(+), 241 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 852ca0917..387b45b12 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -195,25 +195,73 @@ const REDIRECT_URI: &str = "http://localhost:3000/callback"; const SCOPE_STEP_UP_INITIAL_SCOPES: &[&str] = &["mcp:basic"]; const SCOPE_STEP_UP_ESCALATED_SCOPES: &[&str] = &["mcp:basic", "mcp:write"]; -/// Perform the headless OAuth authorization-code flow. +/// Attempt the real connection unauthenticated and return the server's +/// `WWW-Authenticate` challenge from the 401 — the reactive discovery +/// trigger. /// -/// 1. Discover metadata, register (or use CIMD), get auth URL -/// 2. Fetch the auth URL with redirect:manual → extract code from Location header -/// 3. Exchange code for token -/// 4. Return an `AuthClient` wrapping `reqwest::Client` +/// `None` (server accepted the unauthenticated connection, which is then +/// closed cleanly) is a legitimate outcome, not an error: the scope-step-up +/// and scope-retry-limit mocks allow unauthenticated `initialize` and only +/// enforce authorization on tool calls. +async fn initialize_challenge( + server_url: &str, + lifecycle: ClientLifecycleMode, +) -> anyhow::Result> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + match BasicClientHandler + .serve_with_lifecycle(transport, lifecycle) + .await + { + Ok(client) => { + client.cancel().await.ok(); + Ok(None) + } + Err(error) => match error.auth_challenge() { + Some(challenge) => Ok(Some(challenge.to_string())), + None => Err(error.into()), + }, + } +} + +fn with_optional_challenge( + request: AuthorizationRequest, + challenge: Option, +) -> AuthorizationRequest { + match challenge { + Some(challenge) => request.with_challenge(challenge), + None => request, + } +} + +/// Perform the headless OAuth authorization-code flow, reactively: +/// +/// 1. Attempt the real connection; take the 401's WWW-Authenticate challenge +/// 2. Discover from the challenge, register (or use CIMD), get auth URL +/// 3. Fetch the auth URL with redirect:manual → extract code from Location header +/// 4. Exchange code for token +/// 5. Return an `AuthClient` wrapping `reqwest::Client` async fn perform_oauth_flow( server_url: &str, _ctx: &ConformanceContext, ) -> anyhow::Result> { + // Always the discover lifecycle here (not `conformance_lifecycle()`): + // this flow serves `run_auth_client`, whose 2026-07-28 auth mocks require + // the per-request MCP-Protocol-Version negotiation. + let challenge = initialize_challenge( + server_url, + ClientLifecycleMode::Discover { + preferred_versions: preferred_protocol_versions(), + }, + ) + .await?; let mut oauth = OAuthState::new(server_url, None).await?; // Discover + register + get auth URL + let request = AuthorizationRequest::new(REDIRECT_URI) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL); oauth - .start_authorization( - AuthorizationRequest::new(REDIRECT_URI) - .with_client_name("conformance-client") - .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), - ) + .start_authorization(with_optional_challenge(request, challenge)) .await?; let auth_url = oauth.get_authorization_url().await?; @@ -255,14 +303,14 @@ async fn perform_oauth_flow_preregistered( client_id: &str, client_secret: &str, ) -> anyhow::Result> { + let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; let mut oauth = OAuthState::new(server_url, None).await?; + let request = AuthorizationRequest::new(REDIRECT_URI) + .with_preregistered_client(client_id) + .with_client_secret(client_secret); oauth - .start_authorization( - AuthorizationRequest::new(REDIRECT_URI) - .with_preregistered_client(client_id) - .with_client_secret(client_secret), - ) + .start_authorization(with_optional_challenge(request, challenge)) .await?; let auth_url = oauth.get_authorization_url().await?; @@ -325,14 +373,14 @@ async fn run_auth_scope_step_up_client( server_url: &str, _ctx: &ConformanceContext, ) -> anyhow::Result<()> { + let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; let mut oauth = OAuthState::new(server_url, None).await?; + let request = AuthorizationRequest::new(REDIRECT_URI) + .with_scopes(SCOPE_STEP_UP_INITIAL_SCOPES.iter().copied()) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL); oauth - .start_authorization( - AuthorizationRequest::new(REDIRECT_URI) - .with_scopes(SCOPE_STEP_UP_INITIAL_SCOPES.iter().copied()) - .with_client_name("conformance-client") - .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), - ) + .start_authorization(with_optional_challenge(request, challenge)) .await?; let auth_url = oauth.get_authorization_url().await?; @@ -427,15 +475,15 @@ async fn run_auth_scope_retry_limit_client( ) -> anyhow::Result<()> { let max_retries = 3u32; let mut attempt = 0u32; + let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; loop { let mut oauth = OAuthState::new(server_url, None).await?; + let request = AuthorizationRequest::new(REDIRECT_URI) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL); oauth - .start_authorization( - AuthorizationRequest::new(REDIRECT_URI) - .with_client_name("conformance-client") - .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), - ) + .start_authorization(with_optional_challenge(request, challenge.clone())) .await?; let auth_url = oauth.get_authorization_url().await?; let callback = headless_authorize(&auth_url).await?; @@ -509,7 +557,10 @@ async fn migration_token( return Ok(manager.get_access_token().await?); } - let resolution = manager.resolve_metadata().await?; + let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; + let resolution = manager + .resolve_metadata_from_challenge(challenge.as_deref()) + .await?; manager.set_metadata(resolution.metadata); manager .register_client("conformance-client", REDIRECT_URI, &[]) @@ -609,7 +660,10 @@ async fn run_client_credentials_basic( .unwrap_or("conformance-test-secret"); let mut manager = AuthorizationManager::new(server_url).await?; - let resolution = manager.resolve_metadata().await?; + let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; + let resolution = manager + .resolve_metadata_from_challenge(challenge.as_deref()) + .await?; let token_endpoint = resolution.metadata.token_endpoint.clone(); manager.set_metadata(resolution.metadata); diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 40d410d5b..b23a1496d 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -86,6 +86,31 @@ impl ClientInitializeError { context: context.into(), } } + + /// The `WWW-Authenticate` challenge from the 401/403 the transport hit + /// during initialization, if that is why initialization failed. + /// + /// This is the trigger of the reactive OAuth flow: feed the challenge to + /// `AuthorizationRequest::with_challenge` to authorize, then reconnect. + #[cfg(feature = "transport-streamable-http-client")] + pub fn auth_challenge(&self) -> Option<&str> { + use crate::transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError}; + + let Self::TransportError { error, .. } = self else { + return None; + }; + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error.error.as_ref()); + while let Some(current) = source { + if let Some(auth_required) = current.downcast_ref::() { + return Some(&auth_required.www_authenticate_header); + } + if let Some(insufficient_scope) = current.downcast_ref::() { + return Some(&insufficient_scope.www_authenticate_header); + } + source = current.source(); + } + None + } } /// Helper function to get the next message from the stream diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 541cf36c5..6d0179f0a 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -31,12 +31,6 @@ use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024; const MAX_OAUTH_DISCOVERY_REDIRECTS: usize = 10; -const RESOURCE_METADATA_POST_PROBE_BODY: &str = concat!( - r#"{"jsonrpc":"2.0","id":"auth-discovery","method":"initialize","params":{"#, - r#""protocolVersion":"2024-11-05","capabilities":{},"#, - r#""clientInfo":{"name":"rmcp-auth-discovery","version":"0.0.0"}}"#, - r#"}"# -); const CLOUD_METADATA_HOSTS: &[&str] = &[ "metadata", "metadata.google.internal", @@ -634,6 +628,12 @@ pub struct WWWAuthenticateParams { } impl WWWAuthenticateParams { + /// Parse a `WWW-Authenticate` header value, resolving a relative + /// `resource_metadata` URL against `base_url`. + pub fn parse(header: &str, base_url: &Url) -> Self { + AuthorizationManager::extract_www_authenticate_params(header, base_url) + } + /// check if this is an insufficient_scope error pub fn is_insufficient_scope(&self) -> bool { self.error.as_deref() == Some("insufficient_scope") @@ -736,6 +736,11 @@ pub struct AuthorizationRequest { /// OIDC Dynamic Client Registration `application_type` (SEP-837), /// e.g. `"native"` or `"web"`. pub application_type: Option, + /// `WWW-Authenticate` header value from a real request's 401 response. + /// When set, discovery is seeded from the challenge (its + /// `resource_metadata` URL and `scope` hint) instead of probing the + /// server — the reactive discovery path. + pub challenge: Option, } impl AuthorizationRequest { @@ -750,6 +755,7 @@ impl AuthorizationRequest { client_secret: None, client_metadata_url: None, application_type: None, + challenge: None, } } @@ -805,6 +811,13 @@ impl AuthorizationRequest { self.application_type = Some(application_type.into()); self } + + /// Seed discovery from the `WWW-Authenticate` header value of a real + /// request's 401 response instead of probing the server. + pub fn with_challenge(mut self, www_authenticate: impl Into) -> Self { + self.challenge = Some(www_authenticate.into()); + self + } } // add type aliases for oauth2 types @@ -1419,6 +1432,52 @@ impl AuthorizationManager { }) } + /// Resolve authorization server metadata starting from the + /// `WWW-Authenticate` challenge of a real request's 401 response — the + /// reactive discovery path, matching the TypeScript and Python SDKs. + /// + /// Seeds scope selection with the challenge's `scope` hint and prefers + /// the challenge's `resource_metadata` URL; falls back to + /// [`resolve_metadata`](Self::resolve_metadata) when the challenge is + /// `None` or carries no usable metadata pointer. + pub async fn resolve_metadata_from_challenge( + &self, + www_authenticate: Option<&str>, + ) -> Result { + let Some(www_authenticate) = www_authenticate else { + return self.resolve_metadata().await; + }; + let params = WWWAuthenticateParams::parse(www_authenticate, &self.base_url); + + self.record_challenge_scope(¶ms).await; + + if let Some(resource_metadata_url) = ¶ms.resource_metadata_url + && let Some(metadata) = self + .discover_oauth_server_from_resource_metadata_url(resource_metadata_url) + .await? + { + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::ProtectedResourceMetadata, + }); + } + + self.resolve_metadata().await + } + + /// Store a challenge's `scope` hint for later scope selection. + async fn record_challenge_scope(&self, params: &WWWAuthenticateParams) { + let Some(scope) = ¶ms.scope else { + return; + }; + let scopes: Vec = scope.split_whitespace().map(str::to_string).collect(); + if scopes.is_empty() { + return; + } + debug!("WWW-Authenticate challenge contains scope: {scope}"); + *self.www_auth_scopes.write().await = scopes; + } + fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata { let endpoint = |path: &str| { let mut url = base_url.clone(); @@ -2056,7 +2115,7 @@ impl AuthorizationManager { /// refresh token or the server rejected it, return `AuthorizationRequired` /// so the caller can re-prompt the user. Infrastructure errors (e.g. store /// I/O failures, misconfigured client) are propagated as-is. - async fn try_refresh_or_reauth(&self) -> Result { + pub(crate) async fn try_refresh_or_reauth(&self) -> Result { match self.refresh_token().await { Ok(new_creds) => { tracing::info!("Refreshed access token."); @@ -2328,12 +2387,19 @@ impl AuthorizationManager { async fn discover_oauth_server_via_resource_metadata( &self, ) -> Result, AuthError> { - let Some(resource_metadata_url) = self.discover_resource_metadata_url().await? else { + let Some(resource_metadata_url) = self.discover_resource_metadata_url().await else { return Ok(None); }; + self.discover_oauth_server_from_resource_metadata_url(&resource_metadata_url) + .await + } + async fn discover_oauth_server_from_resource_metadata_url( + &self, + resource_metadata_url: &Url, + ) -> Result, AuthError> { let Some(resource_metadata) = self - .fetch_resource_metadata_from_url(&resource_metadata_url) + .fetch_resource_metadata_from_url(resource_metadata_url) .await? else { return Ok(None); @@ -2443,11 +2509,10 @@ impl AuthorizationManager { && Self::is_same_origin(&root_resource, &path_resource) } - async fn discover_resource_metadata_url(&self) -> Result, AuthError> { - if let Ok(Some(resource_metadata_url)) = - self.fetch_resource_metadata_url(&self.base_url, true).await + async fn discover_resource_metadata_url(&self) -> Option { + if let Some(resource_metadata_url) = self.probe_resource_metadata_url(&self.base_url).await { - return Ok(Some(resource_metadata_url)); + return Some(resource_metadata_url); } // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. @@ -2459,84 +2524,40 @@ impl AuthorizationManager { discovery_url.set_query(None); discovery_url.set_fragment(None); discovery_url.set_path(&candidate_path); - if let Ok(Some(resource_metadata_url)) = self - .fetch_resource_metadata_url(&discovery_url, false) - .await + if let Some(resource_metadata_url) = + self.probe_resource_metadata_url(&discovery_url).await { - return Ok(Some(resource_metadata_url)); + return Some(resource_metadata_url); } } - Ok(None) + None } - /// Extract the resource metadata url from the WWW-Authenticate header value. + /// Probe `url` with a GET, extracting the resource metadata url from a + /// 200 (the url itself is the metadata document) or from a 401's + /// WWW-Authenticate header value. /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for - async fn fetch_resource_metadata_url( - &self, - url: &Url, - allow_post_probe: bool, - ) -> Result, AuthError> { + async fn probe_resource_metadata_url(&self, url: &Url) -> Option { let response = match self.discovery_get(url).await { Ok(r) => r, Err(e) => { debug!("resource metadata probe failed: {}", e); - return Ok(None); + return None; } }; match response.status() { - StatusCode::OK => Ok(Some(url.clone())), - StatusCode::UNAUTHORIZED => Ok(self - .extract_resource_metadata_url_from_www_authenticate(&response) - .await), - StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED if allow_post_probe => { - self.fetch_resource_metadata_url_with_post_probe(url).await + StatusCode::OK => Some(url.clone()), + StatusCode::UNAUTHORIZED => { + self.extract_resource_metadata_url_from_www_authenticate(&response) + .await } status => { debug!("resource metadata probe returned unexpected status: {status}"); - Ok(None) - } - } - } - - async fn fetch_resource_metadata_url_with_post_probe( - &self, - url: &Url, - ) -> Result, AuthError> { - let request = oauth2::http::Request::builder() - .method("POST") - .uri(url.as_str()) - .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") - .header(CONTENT_TYPE, "application/json") - .body(RESOURCE_METADATA_POST_PROBE_BODY.as_bytes().to_vec()) - .map_err(|error| AuthError::InternalError(error.to_string()))?; - let response = match self - .http_client - .execute(OAuthHttpRequest::new( - request, - OAuthHttpRedirectPolicy::Stop, - )) - .await - { - Ok(response) => response, - Err(error) => { - debug!("resource metadata POST probe failed: {}", error); - return Ok(None); + None } - }; - - if response.status() != StatusCode::UNAUTHORIZED { - debug!( - "resource metadata POST probe returned unexpected status: {}", - response.status() - ); - return Ok(None); } - - Ok(self - .extract_resource_metadata_url_from_www_authenticate(&response) - .await) } async fn extract_resource_metadata_url_from_www_authenticate( @@ -2549,14 +2570,9 @@ impl AuthorizationManager { continue; }; let params = Self::extract_www_authenticate_params(value_str, &self.base_url); - if let Some(url) = params.resource_metadata_url { - if let Some(scope) = ¶ms.scope { - debug!("WWW-Authenticate header contains scope: {}", scope); - let scopes: Vec = - scope.split_whitespace().map(|s| s.to_string()).collect(); - *self.www_auth_scopes.write().await = scopes; - } - parsed_url = Some(url); + if params.resource_metadata_url.is_some() { + self.record_challenge_scope(¶ms).await; + parsed_url = params.resource_metadata_url; break; } } @@ -3432,7 +3448,7 @@ impl OAuthState { ) } - async fn placeholder(&self) -> Result { + async fn placeholder_state(&self) -> Result { let (http_client, refresh_redirect_policy) = self.oauth_http_client_config(); Ok(OAuthState::Unauthorized( AuthorizationManager::new_inner( @@ -3548,7 +3564,7 @@ impl OAuthState { &mut self, request: AuthorizationRequest, ) -> Result<(), AuthError> { - let placeholder = self.placeholder().await?; + let placeholder = self.placeholder_state().await?; let old = std::mem::replace(self, placeholder); let OAuthState::Unauthorized(mut manager) = old else { *self = old; @@ -3557,7 +3573,10 @@ impl OAuthState { )); }; debug!("start discovery"); - let metadata = match manager.resolve_metadata().await { + let resolution = manager + .resolve_metadata_from_challenge(request.challenge.as_deref()) + .await; + let metadata = match resolution { Ok(resolution) => resolution.metadata, Err(e) => { *self = OAuthState::Unauthorized(manager); @@ -3580,7 +3599,7 @@ impl OAuthState { /// complete authorization pub async fn complete_authorization(&mut self) -> Result<(), AuthError> { - let placeholder = self.placeholder().await?; + let placeholder = self.placeholder_state().await?; if let OAuthState::Session(session) = std::mem::replace(self, placeholder) { *self = OAuthState::Authorized(session.auth_manager); Ok(()) @@ -3590,7 +3609,7 @@ impl OAuthState { } /// covert to authorized http client pub async fn to_authorized_http_client(&mut self) -> Result<(), AuthError> { - let placeholder = self.placeholder().await?; + let placeholder = self.placeholder_state().await?; if let OAuthState::Authorized(manager) = std::mem::replace(self, placeholder) { *self = OAuthState::AuthorizedHttpClient(AuthorizedHttpClient::new( Arc::new(manager), @@ -3610,7 +3629,7 @@ impl OAuthState { required_scope: &str, redirect_uri: &str, ) -> Result { - let placeholder = self.placeholder().await?; + let placeholder = self.placeholder_state().await?; let old = std::mem::replace(self, placeholder); let OAuthState::Authorized(manager) = old else { *self = old; @@ -3742,7 +3761,7 @@ impl OAuthState { &mut self, config: ClientCredentialsConfig, ) -> Result<(), AuthError> { - let placeholder = self.placeholder().await?; + let placeholder = self.placeholder_state().await?; let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) else { return Err(AuthError::InternalError( "Client credentials flow requires Unauthorized state".to_string(), @@ -4137,7 +4156,6 @@ mod tests { .body(Vec::new()) .unwrap(); let client = RecordingOAuthHttpClient::with_responses(vec![ - empty_response(404), challenge, http_response( 200, @@ -4178,7 +4196,6 @@ mod tests { ( "https://auth.example.com/tenant1/token", vec![ - "https://mcp.example.com/mcp", "https://mcp.example.com/mcp", "https://mcp.example.com/custom/metadata/location.json", "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", @@ -4187,14 +4204,12 @@ mod tests { ], ) ); - assert_eq!( + assert!( client .requests() .iter() - .take(2) - .map(|request| request.method.as_str()) - .collect::>(), - vec!["GET", "POST"] + .all(|request| request.method == "GET"), + "discovery must not send non-GET requests" ); } @@ -4205,7 +4220,6 @@ mod tests { empty_response(404), empty_response(404), empty_response(404), - empty_response(404), ]); let manager = AuthorizationManager::new_with_oauth_http_client( "https://legacy.example.com/", @@ -4234,7 +4248,6 @@ mod tests { "https://legacy.example.com/token", Some("https://legacy.example.com/register"), vec![ - "https://legacy.example.com/", "https://legacy.example.com/", "https://legacy.example.com/.well-known/oauth-protected-resource", "https://legacy.example.com/.well-known/oauth-authorization-server", @@ -4293,6 +4306,85 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_from_challenge_uses_challenge_pointer_and_scope() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let resolution = manager + .resolve_metadata_from_challenge(Some( + r#"Bearer resource_metadata="https://mcp.example.com/custom/prm.json", scope="mcp:read mcp:write""#, + )) + .await + .unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + manager.select_scopes(None, &[]), + client.requests().first().map(|request| request.uri.clone()), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + vec!["mcp:read".to_string(), "mcp:write".to_string()], + // discovery starts at the challenge's pointer: no probing + Some("https://mcp.example.com/custom/prm.json".to_string()), + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_from_challenge_falls_back_without_metadata_pointer() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(404), + empty_response(404), + empty_response(404), + empty_response(404), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://legacy.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager + .resolve_metadata_from_challenge(Some(r#"Bearer scope="mcp:basic""#)) + .await + .unwrap(); + + assert_eq!( + (resolution.source, manager.select_scopes(None, &[]),), + ( + AuthorizationMetadataSource::LegacyEndpointFallback, + vec!["mcp:basic".to_string()], + ) + ); + } + #[tokio::test] async fn resolve_metadata_reports_authorization_server_metadata() { let client = RecordingOAuthHttpClient::with_responses(vec![ diff --git a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs index 2069e6d31..2053920d2 100644 --- a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs @@ -1,11 +1,70 @@ use std::collections::HashMap; use http::{HeaderName, HeaderValue}; +use tracing::debug; use crate::transport::{ - auth::AuthClient, + auth::{AuthClient, AuthError}, streamable_http_client::{StreamableHttpClient, StreamableHttpError}, }; + +impl AuthClient +where + C: StreamableHttpClient + Send + Sync, +{ + /// Run `call` with a token when one is available, reacting to the + /// server's auth verdict instead of requiring credentials up front: + /// + /// - no usable credentials → the request goes out unauthenticated, and a + /// 401 propagates as [`StreamableHttpError::AuthRequired`] carrying the + /// `WWW-Authenticate` challenge for the caller to authorize with; + /// - a token the server rejects (e.g. revoked) → one silent refresh, one + /// retry, then the challenge propagates. + async fn call_reacting_to_challenges( + &self, + auth_token: Option, + call: F, + ) -> Result> + where + F: Fn(Option) -> Fut, + Fut: Future>>, + { + // Missing credentials are not an error in the reactive model: the + // request goes out unauthenticated and the server's 401 challenge + // drives authorization. + let auth_token = match auth_token { + None => match self.get_access_token().await { + Ok(token) => Some(token), + Err(AuthError::AuthorizationRequired) => None, + Err(error) => return Err(error.into()), + }, + token => token, + }; + match call(auth_token.clone()).await { + Err(StreamableHttpError::AuthRequired(challenge)) => { + // One silent recovery attempt: refresh the rejected token and + // retry only when the refresh actually produced a new one. + let Some(sent_token) = auth_token else { + return Err(StreamableHttpError::AuthRequired(challenge)); + }; + let refreshed = { + let manager = self.auth_manager.lock().await; + manager.try_refresh_or_reauth().await + }; + match refreshed { + Ok(fresh_token) if fresh_token != sent_token => call(Some(fresh_token)).await, + Ok(_) => Err(StreamableHttpError::AuthRequired(challenge)), + Err(error) => { + debug!("token refresh after server rejection failed: {error}"); + Err(StreamableHttpError::AuthRequired(challenge)) + } + } + } + result => result, + } + } +} + impl StreamableHttpClient for AuthClient where C: StreamableHttpClient + Send + Sync, @@ -16,16 +75,21 @@ where &self, uri: std::sync::Arc, session_id: std::sync::Arc, - mut auth_token: Option, + auth_token: Option, custom_headers: HashMap, ) -> Result<(), crate::transport::streamable_http_client::StreamableHttpError> { - if auth_token.is_none() { - auth_token = Some(self.get_access_token().await?); - } - self.http_client - .delete_session(uri, session_id, auth_token, custom_headers) - .await + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let session_id = session_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .delete_session(uri, session_id, token, custom_headers) + .await + } + }) + .await } async fn get_stream( @@ -33,18 +97,24 @@ where uri: std::sync::Arc, session_id: Option>, last_event_id: Option, - mut auth_token: Option, + auth_token: Option, custom_headers: HashMap, ) -> Result< futures::stream::BoxStream<'static, Result>, crate::transport::streamable_http_client::StreamableHttpError, > { - if auth_token.is_none() { - auth_token = Some(self.get_access_token().await?); - } - self.http_client - .get_stream(uri, session_id, last_event_id, auth_token, custom_headers) - .await + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let session_id = session_id.clone(); + let last_event_id = last_event_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .get_stream(uri, session_id, last_event_id, token, custom_headers) + .await + } + }) + .await } async fn get_stream_with_max_sse_event_size( @@ -52,26 +122,32 @@ where uri: std::sync::Arc, session_id: Option>, last_event_id: Option, - mut auth_token: Option, + auth_token: Option, custom_headers: HashMap, max_sse_event_size: usize, ) -> Result< futures::stream::BoxStream<'static, Result>, crate::transport::streamable_http_client::StreamableHttpError, > { - if auth_token.is_none() { - auth_token = Some(self.get_access_token().await?); - } - self.http_client - .get_stream_with_max_sse_event_size( - uri, - session_id, - last_event_id, - auth_token, - custom_headers, - max_sse_event_size, - ) - .await + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let session_id = session_id.clone(); + let last_event_id = last_event_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + token, + custom_headers, + max_sse_event_size, + ) + .await + } + }) + .await } async fn post_message( @@ -79,18 +155,24 @@ where uri: std::sync::Arc, message: crate::model::ClientJsonRpcMessage, session_id: Option>, - mut auth_token: Option, + auth_token: Option, custom_headers: HashMap, ) -> Result< crate::transport::streamable_http_client::StreamableHttpPostResponse, StreamableHttpError, > { - if auth_token.is_none() { - auth_token = Some(self.get_access_token().await?); - } - self.http_client - .post_message(uri, message, session_id, auth_token, custom_headers) - .await + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let message = message.clone(); + let session_id = session_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .post_message(uri, message, session_id, token, custom_headers) + .await + } + }) + .await } async fn post_message_with_max_sse_event_size( @@ -98,25 +180,31 @@ where uri: std::sync::Arc, message: crate::model::ClientJsonRpcMessage, session_id: Option>, - mut auth_token: Option, + auth_token: Option, custom_headers: HashMap, max_sse_event_size: usize, ) -> Result< crate::transport::streamable_http_client::StreamableHttpPostResponse, StreamableHttpError, > { - if auth_token.is_none() { - auth_token = Some(self.get_access_token().await?); - } - self.http_client - .post_message_with_max_sse_event_size( - uri, - message, - session_id, - auth_token, - custom_headers, - max_sse_event_size, - ) - .await + self.call_reacting_to_challenges(auth_token, |token| { + let uri = uri.clone(); + let message = message.clone(); + let session_id = session_id.clone(); + let custom_headers = custom_headers.clone(); + async move { + self.http_client + .post_message_with_max_sse_event_size( + uri, + message, + session_id, + token, + custom_headers, + max_sse_event_size, + ) + .await + } + }) + .await } } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 5194ba960..c43fb7dd5 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -124,7 +124,8 @@ fn negotiate_version_headers( (version, headers) } -#[derive(Debug)] +#[derive(Debug, Error)] +#[error("authorization required: {www_authenticate_header}")] #[non_exhaustive] pub struct AuthRequiredError { pub www_authenticate_header: String, @@ -139,7 +140,8 @@ impl AuthRequiredError { } } -#[derive(Debug)] +#[derive(Debug, Error)] +#[error("insufficient scope: {www_authenticate_header}")] #[non_exhaustive] pub struct InsufficientScopeError { pub www_authenticate_header: String, @@ -197,15 +199,31 @@ pub enum StreamableHttpError { #[error("Auth error: {0}")] Auth(#[from] crate::transport::auth::AuthError), #[error("Auth required")] - AuthRequired(AuthRequiredError), + AuthRequired(#[source] AuthRequiredError), #[error("Insufficient scope")] - InsufficientScope(InsufficientScopeError), + InsufficientScope(#[source] InsufficientScopeError), #[error("Header name '{0}' is reserved and conflicts with default headers")] ReservedHeaderConflict(String), #[error("Session expired (HTTP 404)")] SessionExpired, } +impl StreamableHttpError { + /// The `WWW-Authenticate` challenge carried by this error, when the + /// server answered 401 ([`AuthRequired`](Self::AuthRequired)) or 403 + /// ([`InsufficientScope`](Self::InsufficientScope)). Feed it to + /// [`AuthorizationRequest::with_challenge`](crate::transport::auth::AuthorizationRequest::with_challenge) + /// to authorize reactively. + #[cfg(feature = "auth")] + pub fn auth_challenge(&self) -> Option<&str> { + match self { + Self::AuthRequired(error) => Some(&error.www_authenticate_header), + Self::InsufficientScope(error) => Some(&error.www_authenticate_header), + _ => None, + } + } +} + #[derive(Debug, Clone, Error)] #[non_exhaustive] pub enum StreamableHttpProtocolError { diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 58565fb72..1ab931331 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -8,8 +8,9 @@ use axum::{ routing::get, }; use rmcp::{ - ServiceExt, + RoleClient, ServiceExt, model::ClientInfo, + service::RunningService, transport::{ StreamableHttpClientTransport, auth::{AuthClient, AuthorizationRequest, OAuthState}, @@ -55,6 +56,109 @@ async fn callback_handler( Html(CALLBACK_HTML.to_string()) } +enum ConnectOutcome { + /// The server accepted the unauthenticated connection. + Connected(RunningService), + /// The server answered 401; authorize with this `WWW-Authenticate` + /// challenge and reconnect. + AuthRequired(String), +} + +/// Attempt the real connection unauthenticated — the reactive discovery +/// trigger (matching the TypeScript and Python SDKs). The server's 401 +/// challenge, not a probe, tells us whether and how to authorize. +async fn try_connect(http_client: reqwest::Client, server_url: &str) -> Result { + let transport = StreamableHttpClientTransport::with_client( + http_client, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + match ClientInfo::default().serve(transport).await { + Ok(client) => Ok(ConnectOutcome::Connected(client)), + Err(error) => match error.auth_challenge() { + Some(challenge) => Ok(ConnectOutcome::AuthRequired(challenge.to_string())), + None => Err(error.into()), + }, + } +} + +/// Run the browser OAuth flow seeded by the server's challenge, then +/// reconnect with the authorized transport. +async fn authorize_and_connect( + challenge: String, + oauth_http_client: reqwest::Client, + server_url: &str, + client_metadata_url: &str, + code_receiver: oneshot::Receiver, + output: &mut BufWriter, +) -> Result> { + tracing::info!("Server requires authorization: {challenge}"); + + // initialize oauth state machine + let mut oauth_state = OAuthState::new(server_url, Some(oauth_http_client)) + .await + .context("Failed to initialize oauth state machine")?; + // Seed discovery from the server's challenge, and use CIMD (SEP-991) + // with client metadata URL. Passing no scopes lets the SDK auto-select + // from the challenge's scope hint, Protected Resource Metadata, or AS + // metadata. + oauth_state + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_client_name("Test MCP Client") + .with_client_metadata_url(client_metadata_url) + .with_challenge(challenge), + ) + .await + .context("Failed to start authorization")?; + + // Output authorization URL to user + output + .write_all(b"Please open the following URL in your browser to authorize:\n\n") + .await?; + output + .write_all(oauth_state.get_authorization_url().await?.as_bytes()) + .await?; + output + .write_all(b"\n\nWaiting for browser callback, please do not close this window...\n") + .await?; + output.flush().await?; + + // Wait for authorization code + tracing::info!("Waiting for authorization code..."); + let CallbackParams { + code: auth_code, + state: csrf_token, + iss, + } = code_receiver + .await + .context("Failed to get authorization code")?; + tracing::info!("Received authorization code: {}", auth_code); + // Exchange code for access token + tracing::info!("Exchanging authorization code for access token..."); + oauth_state + .handle_callback_with_issuer(&auth_code, &csrf_token, iss.as_deref()) + .await + .context("Failed to handle callback")?; + tracing::info!("Successfully obtained access token"); + + output + .write_all(b"\nAuthorization successful! Access token obtained.\n\n") + .await?; + output.flush().await?; + + // Reconnect with the authorized transport + tracing::info!("Establishing authorized connection to MCP server..."); + let am = oauth_state + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; + let auth_client = AuthClient::new(reqwest::Client::default(), am); + let transport = StreamableHttpClientTransport::with_client( + auth_client, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + Ok(ClientInfo::default().serve(transport).await?) +} + #[tokio::main] async fn main() -> Result<()> { // Initialize logging @@ -123,73 +227,32 @@ async fn main() -> Result<()> { .build() .context("Failed to build OAuth HTTP client")?; - // initialize oauth state machine - let mut oauth_state = OAuthState::new(&server_url, Some(oauth_http_client)) - .await - .context("Failed to initialize oauth state machine")?; - // use CIMD (SEP-991) with client metadata URL. - // passing no scopes lets the SDK auto-select from the server's - // WWW-Authenticate header, Protected Resource Metadata, or AS metadata. - oauth_state - .start_authorization( - AuthorizationRequest::new(MCP_REDIRECT_URI) - .with_client_name("Test MCP Client") - .with_client_metadata_url(&client_metadata_url), - ) - .await - .context("Failed to start authorization")?; - - // Output authorization URL to user let mut output = BufWriter::new(tokio::io::stdout()); output.write_all(b"\n=== MCP OAuth Client ===\n\n").await?; - output - .write_all(b"Please open the following URL in your browser to authorize:\n\n") - .await?; - output - .write_all(oauth_state.get_authorization_url().await?.as_bytes()) - .await?; - output - .write_all(b"\n\nWaiting for browser callback, please do not close this window...\n") - .await?; - output.flush().await?; - - // Wait for authorization code - tracing::info!("Waiting for authorization code..."); - let CallbackParams { - code: auth_code, - state: csrf_token, - iss, - } = code_receiver - .await - .context("Failed to get authorization code")?; - tracing::info!("Received authorization code: {}", auth_code); - // Exchange code for access token - tracing::info!("Exchanging authorization code for access token..."); - oauth_state - .handle_callback_with_issuer(&auth_code, &csrf_token, iss.as_deref()) - .await - .context("Failed to handle callback")?; - tracing::info!("Successfully obtained access token"); - - output - .write_all(b"\nAuthorization successful! Access token obtained.\n\n") - .await?; output.flush().await?; - // Create authorized transport, this transport is authorized by the oauth state machine - tracing::info!("Establishing authorized connection to MCP server..."); - let am = oauth_state - .into_authorization_manager() - .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; - let client = AuthClient::new(reqwest::Client::default(), am); - let transport = StreamableHttpClientTransport::with_client( - client, - StreamableHttpClientTransportConfig::with_uri(server_url.as_str()), - ); - - // Create client and connect to MCP server - let client_service = ClientInfo::default(); - let client = client_service.serve(transport).await?; + // Reactive discovery: attempt the real connection first. The server's + // 401 challenge — not a probe — tells us whether and how to authorize. + // The transport gets a default client: `oauth_http_client`'s request + // timeout would cut long-lived SSE streams short. + tracing::info!("Attempting connection to MCP server..."); + let client = match try_connect(reqwest::Client::default(), &server_url).await? { + ConnectOutcome::Connected(client) => { + tracing::info!("Server accepted the connection without authorization"); + client + } + ConnectOutcome::AuthRequired(challenge) => { + authorize_and_connect( + challenge, + oauth_http_client, + &server_url, + &client_metadata_url, + code_receiver, + &mut output, + ) + .await? + } + }; tracing::info!("Successfully connected to MCP server"); // Test API requests From 125dbfd5af81fd3033b572f44e11c472969194a8 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 27 Jul 2026 12:25:49 -0400 Subject: [PATCH 275/333] fix!: reject missing `issuer` in authorization server metadata by default (#1054) --- crates/rmcp/src/transport/auth.rs | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 6d0179f0a..c6751d88f 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1013,7 +1013,7 @@ pub struct AuthorizationManager { resource_scopes: RwLock>, /// OIDC Dynamic Client Registration `application_type` (SEP-837) application_type: Option, - strict_issuer_validation: bool, + allow_missing_issuer: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1259,7 +1259,7 @@ impl AuthorizationManager { www_auth_scopes: RwLock::new(Vec::new()), resource_scopes: RwLock::new(Vec::new()), application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), - strict_issuer_validation: false, + allow_missing_issuer: false, }; Ok(manager) @@ -1270,15 +1270,15 @@ impl AuthorizationManager { self.scope_upgrade_config = config; } - /// Configure whether authorization server metadata discovery requires the - /// metadata `issuer` field. + /// Configure whether authorization server metadata discovery tolerates a + /// missing `issuer` field. /// - /// The default is `false` to preserve compatibility with legacy - /// authorization servers that omit `issuer`. Set this to `true` to enforce - /// the RFC 8414/OIDC requirement that discovered metadata include `issuer` - /// whenever the expected issuer can be derived from the discovery URL. - pub fn set_strict_issuer_validation(&mut self, strict: bool) { - self.strict_issuer_validation = strict; + /// The default is `false`, enforcing the RFC 8414/OIDC requirement that + /// discovered metadata include `issuer` whenever the expected issuer can be + /// derived from the discovery URL. Set this to `true` only for compatibility + /// with authorization servers that return incomplete metadata. + pub fn set_allow_missing_issuer(&mut self, allow: bool) { + self.allow_missing_issuer = allow; } /// Set a custom credential store @@ -2370,10 +2370,10 @@ impl AuthorizationManager { return Ok(()); }; let Some(received_issuer) = metadata.issuer.as_deref() else { - if self.strict_issuer_validation { - return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer }); + if self.allow_missing_issuer { + return Ok(()); } - return Ok(()); + return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer }); }; if !Self::issuer_identifiers_match(received_issuer, &expected_issuer) { return Err(AuthError::AuthorizationServerMismatch { @@ -4095,7 +4095,7 @@ mod tests { } #[tokio::test] - async fn authorization_metadata_allows_missing_issuer_by_default() { + async fn authorization_metadata_allows_missing_issuer_when_configured() { let discovery_url = Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") .unwrap(); @@ -4106,9 +4106,10 @@ mod tests { ..Default::default() }; - let manager = AuthorizationManager::new("https://mcp.example.com/") + let mut manager = AuthorizationManager::new("https://mcp.example.com/") .await .unwrap(); + manager.set_allow_missing_issuer(true); manager .validate_authorization_metadata_issuer(&discovery_url, &metadata) @@ -4116,7 +4117,7 @@ mod tests { } #[tokio::test] - async fn authorization_metadata_rejects_missing_issuer_when_required() { + async fn authorization_metadata_rejects_missing_issuer_by_default() { let discovery_url = Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1") .unwrap(); @@ -4126,10 +4127,9 @@ mod tests { token_endpoint: "https://auth.example.com/tenant1/token".to_string(), ..Default::default() }; - let mut manager = AuthorizationManager::new("https://mcp.example.com/") + let manager = AuthorizationManager::new("https://mcp.example.com/") .await .unwrap(); - manager.set_strict_issuer_validation(true); let error = manager .validate_authorization_metadata_issuer(&discovery_url, &metadata) From 9528801f6e1dd1b0d2e7bb3f09344e870d1a5931 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:51:16 -0400 Subject: [PATCH 276/333] chore: release v3.0.0-beta.3 (#1053) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp/CHANGELOG.md | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 821d90cdd..fa9735857 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.0.0-beta.2", path = "./crates/rmcp" } -rmcp-macros = { version = "3.0.0-beta.2", path = "./crates/rmcp-macros" } +rmcp = { version = "3.0.0-beta.3", path = "./crates/rmcp" } +rmcp-macros = { version = "3.0.0-beta.3", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.0.0-beta.2" +version = "3.0.0-beta.3" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 1bdab3317..58c28c8ab 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0-beta.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.2...rmcp-v3.0.0-beta.3) - 2026-07-27 + +### Fixed + +- [**breaking**] reject missing `issuer` in authorization server metadata by default ([#1054](https://github.com/modelcontextprotocol/rust-sdk/pull/1054)) +- default to allowing missing `issuer` ([#1051](https://github.com/modelcontextprotocol/rust-sdk/pull/1051)) + +### Other + +- make oauth discovery reactive ([#1052](https://github.com/modelcontextprotocol/rust-sdk/pull/1052)) + ## [3.0.0-beta.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.1...rmcp-v3.0.0-beta.2) - 2026-07-24 ### Added From 2047ae42ec1e40f84ad08a53951fea236a37cd6f Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 27 Jul 2026 14:20:47 -0700 Subject: [PATCH 277/333] fix: accept namespaced discovery server information (#1044) * fix: accept namespaced discovery server information (#1039) * fix: preserve discovery server-info compatibility --- crates/rmcp/src/model.rs | 49 ++++++++- crates/rmcp/tests/test_server_discover.rs | 122 ++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 307ce525f..9cae7aab2 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1120,7 +1120,7 @@ impl schemars::JsonSchema for DiscoverRequestParams { pub type DiscoverRequest = Request; /// The server's response to a [`DiscoverRequest`]. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] @@ -1145,6 +1145,53 @@ pub struct DiscoverResult { pub meta: Option, } +impl<'de> Deserialize<'de> for DiscoverResult { + fn deserialize<__D>(deserializer: __D) -> Result + where + __D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Helper { + result_type: ResultType, + supported_versions: Vec, + capabilities: ServerCapabilities, + server_info: Option, + instructions: Option, + ttl_ms: u64, + cache_scope: CacheScope, + #[serde(rename = "_meta")] + meta: Option, + } + + let helper = Helper::deserialize(deserializer)?; + let server_info = match helper.server_info { + Some(server_info) => server_info, + None => { + let metadata_server_info = helper + .meta + .as_ref() + .and_then(|metadata| metadata.0.get("io.modelcontextprotocol/serverInfo")) + .ok_or_else(|| serde::de::Error::missing_field("serverInfo"))?; + + serde_json::from_value(metadata_server_info.clone()) + .map_err(serde::de::Error::custom)? + } + }; + + Ok(Self { + result_type: helper.result_type, + supported_versions: helper.supported_versions, + capabilities: helper.capabilities, + server_info, + instructions: helper.instructions, + ttl_ms: helper.ttl_ms, + cache_scope: helper.cache_scope, + meta: helper.meta, + }) + } +} + impl DiscoverResult { /// Create a non-cacheable private discovery result. pub fn new( diff --git a/crates/rmcp/tests/test_server_discover.rs b/crates/rmcp/tests/test_server_discover.rs index 3f689b988..0d4a037b2 100644 --- a/crates/rmcp/tests/test_server_discover.rs +++ b/crates/rmcp/tests/test_server_discover.rs @@ -74,6 +74,128 @@ fn discover_result_deserializes_to_typed_variant() { ); } +#[test] +fn discover_result_accepts_server_info_in_namespaced_metadata() { + let message: ServerJsonRpcMessage = serde_json::from_value(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "conformance-mock-server", + "version": "1.0.0" + }, + "unrelated": { "preserved": true } + } + } + })) + .expect("discovery response with namespaced server info should deserialize"); + + let ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) = message else { + panic!("expected response"); + }; + let ServerResult::DiscoverResult(result) = result else { + panic!("expected discovery response, not a tool-call result"); + }; + + assert_eq!(result.server_info.name, "conformance-mock-server"); + assert_eq!(result.server_info.version, "1.0.0"); + + let metadata = result.meta.expect("discovery metadata should be preserved"); + assert_eq!( + metadata.0.get("io.modelcontextprotocol/serverInfo"), + Some(&json!({ + "name": "conformance-mock-server", + "version": "1.0.0" + })) + ); + assert_eq!( + metadata.0.get("unrelated"), + Some(&json!({ "preserved": true })) + ); +} + +#[test] +fn discover_result_serializes_top_level_server_info() { + let result = DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + rmcp::model::ServerCapabilities::default(), + rmcp::model::Implementation::new("test-server", "1.0.0"), + ); + + let serialized = serde_json::to_value(result).expect("serialize discovery result"); + assert_eq!( + serialized["serverInfo"], + json!({ + "name": "test-server", + "version": "1.0.0" + }) + ); +} + +#[test] +fn discover_result_prefers_top_level_server_info_over_namespaced_metadata() { + let result: DiscoverResult = serde_json::from_value(json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "serverInfo": { + "name": "top-level-server", + "version": "2.0.0" + }, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "metadata-server", + "version": "1.0.0" + }, + "unrelated": true + } + })) + .expect("top-level server info should remain supported"); + + assert_eq!(result.server_info.name, "top-level-server"); + assert_eq!(result.server_info.version, "2.0.0"); + assert_eq!( + result + .meta + .as_ref() + .and_then(|metadata| metadata.0.get("unrelated")), + Some(&json!(true)) + ); +} + +#[test] +fn discover_result_requires_valid_top_level_or_namespaced_server_info() { + let result = json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { "unrelated": true } + }); + + assert!(serde_json::from_value::(result).is_err()); + + let malformed_server_info = json!({ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private", + "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "missing-version" } } + }); + + assert!(serde_json::from_value::(malformed_server_info).is_err()); +} + #[test] fn unsupported_protocol_version_error_matches_draft_schema() { let error = ErrorData::unsupported_protocol_version( From 1f63e43479eee2c345f3a5d94ea64c32b6b6da94 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 27 Jul 2026 18:32:07 -0400 Subject: [PATCH 278/333] ci: simplify conformance while running against both spec versions (#1060) --- .github/workflows/conformance.yml | 72 +++++-------------- conformance/expected-failures-extensions.yaml | 4 +- conformance/src/bin/client.rs | 3 +- conformance/src/bin/server.rs | 5 +- 4 files changed, 22 insertions(+), 62 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 1c69e1ce7..dc5de752a 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -12,12 +12,7 @@ concurrency: cancel-in-progress: true env: - # Pinned for reproducible runs; bump deliberately when the suite updates. - CONFORMANCE_VERSION: "0.1.16" - # When updating DRAFT_CONFORMANCE_VERSION, diff - # `conformance list --spec-version 2026-07-28` - # and update #977 - DRAFT_CONFORMANCE_VERSION: "0.2.0-alpha.9" + CONFORMANCE_VERSION: "0.2.0-alpha.9" jobs: server: @@ -33,15 +28,13 @@ jobs: - uses: Swatinem/rust-cache@v2 - # Build the whole package (server + client bins): the conformance crate is - # excluded from the workspace default-members. - name: Build conformance binaries run: cargo build -p mcp-conformance - name: Test conformance server run: cargo test -p mcp-conformance --bin conformance-server - - name: Start 2025-11-25 server + - name: Start conformance server run: | PORT=8001 ./target/debug/conformance-server & echo $! > server.pid @@ -58,45 +51,19 @@ jobs: run: | npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ --url http://127.0.0.1:8001/mcp \ + --suite all \ --spec-version 2025-11-25 \ - -o conformance-results - - # These pass today but are excluded from the default "active" suite; - # run them explicitly so regressions are still caught. - - name: Run 2025-11-25 pending scenarios - run: | - for scenario in json-schema-2020-12 server-sse-polling; do - npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ - --url http://127.0.0.1:8001/mcp \ - --scenario "$scenario" \ - -o conformance-results - done + -o conformance-results/2025-11-25 - - name: Start draft server + - name: Run 2026-07-28 server suite run: | - STATELESS=1 PORT=8002 ./target/debug/conformance-server & - echo $! > draft-server.pid - for _ in $(seq 1 30); do - if curl -s -o /dev/null http://127.0.0.1:8002/mcp; then - exit 0 - fi - sleep 1 - done - echo "draft conformance server did not become ready" >&2 - exit 1 - - - name: Run 2026-07-28 versioned-spec server suite - run: | - npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ - --url http://127.0.0.1:8002/mcp \ + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8001/mcp \ --suite all \ --spec-version 2026-07-28 \ - -o conformance-results + -o conformance-results/2026-07-28 - # Extension scenarios are excluded by the --spec-version filter and - # are informational for tiering. Run them explicitly so their gaps remain - # visible and newly passing scenarios make the strict baseline fail stale. - - name: Run Tasks extension server suite (informational) + - name: Run extension server scenarios (informational) run: | scenarios=( tasks-lifecycle @@ -110,20 +77,17 @@ jobs: tasks-required-task-error tasks-mrtr-composition ) - for scenario in "${scenarios[@]}"; do - npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \ - --url http://127.0.0.1:8002/mcp \ + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url http://127.0.0.1:8001/mcp \ --scenario "$scenario" \ --expected-failures conformance/expected-failures-extensions.yaml \ -o conformance-extension-results done - - name: Stop conformance servers + - name: Stop conformance server if: always() - run: | - kill "$(cat draft-server.pid)" 2>/dev/null || true - kill "$(cat server.pid)" 2>/dev/null || true + run: kill "$(cat server.pid)" 2>/dev/null || true - name: Upload results if: always() @@ -156,19 +120,19 @@ jobs: --command "$(pwd)/target/debug/conformance-client" \ --suite all \ --spec-version 2025-11-25 \ - -o conformance-client-results/full + -o conformance-client-results/2025-11-25 - - name: Run 2026-07-28 versioned-spec client suite + - name: Run 2026-07-28 client suite run: | - npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \ --command "$(pwd)/target/debug/conformance-client" \ --suite all \ --spec-version 2026-07-28 \ - -o conformance-client-results/draft + -o conformance-client-results/2026-07-28 - name: Run extension client suite (informational) run: | - npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \ + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \ --command "$(pwd)/target/debug/conformance-client" \ --suite extensions \ --expected-failures conformance/expected-failures-extensions.yaml \ diff --git a/conformance/expected-failures-extensions.yaml b/conformance/expected-failures-extensions.yaml index fdf9617df..8400079b0 100644 --- a/conformance/expected-failures-extensions.yaml +++ b/conformance/expected-failures-extensions.yaml @@ -1,5 +1,5 @@ # Known failures for informational extension scenarios in -# @modelcontextprotocol/conformance DRAFT_CONFORMANCE_VERSION. +# @modelcontextprotocol/conformance CONFORMANCE_VERSION. # # Extensions are not selected by a `--spec-version` run and do not count toward # SDK tiering. CI runs them separately so a green versioned-spec suite does not @@ -9,7 +9,7 @@ # - an unlisted failure fails the build # - a listed scenario that starts passing fails the build as a stale entry # -# When bumping DRAFT_CONFORMANCE_VERSION, review the available extension and +# When bumping CONFORMANCE_VERSION, review the available extension and # pending scenarios and update this file deliberately. # The SEP-2663 Tasks Extension server scenarios (tracked in #868) all pass and diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 387b45b12..5d654105a 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -943,8 +943,7 @@ fn preferred_protocol_versions() -> Vec { preferred_versions } -/// Runs draft stateless scenarios through the public discover lifecycle and -/// Streamable HTTP transport. +/// Runs scenarios through the discover lifecycle and Streamable HTTP transport. async fn run_discover_client(server_url: &str) -> anyhow::Result<()> { let preferred_versions = preferred_protocol_versions(); let transport = StreamableHttpClientTransport::from_uri(server_url); diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index bf902f86f..3b552d7d3 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -1740,10 +1740,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting conformance server on {}", bind_addr); let server = ConformanceServer::new(); - let stateless = std::env::var_os("STATELESS").is_some(); - let config = StreamableHttpServerConfig::default() - .with_legacy_session_mode(!stateless) - .with_json_response(stateless); + let config = StreamableHttpServerConfig::default(); let service = StreamableHttpService::new( move || Ok(server.clone()), LocalSessionManager::default().into(), From 1e9e4700f70f023759968d90d42253b727bb61e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:42:45 -0400 Subject: [PATCH 279/333] chore(deps): update jsonwebtoken requirement from 10 to 11 (#1058) Updates the requirements on [jsonwebtoken](https://github.com/Keats/jsonwebtoken) to permit the latest version. - [Changelog](https://github.com/Keats/jsonwebtoken/blob/master/CHANGELOG.md) - [Commits](https://github.com/Keats/jsonwebtoken/compare/v10.0.0...v11.0.0) --- updated-dependencies: - dependency-name: jsonwebtoken dependency-version: 11.0.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- crates/rmcp/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 93f98e428..e8b709b32 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -58,7 +58,7 @@ pastey = { version = "0.2.0", optional = true } # oauth2 support oauth2 = { version = "5.0", optional = true, default-features = false } # JWT signing for client credentials (private_key_jwt) -jsonwebtoken = { version = "10", optional = true, features = ["aws_lc_rs"] } +jsonwebtoken = { version = "11", optional = true, features = ["aws_lc_rs"] } # for auto generate schema schemars = { version = "1.0", optional = true, features = ["chrono04"] } From 506e69207425f593b7b3f843d7073aa823ee207b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:12:57 -0400 Subject: [PATCH 280/333] chore(deps): update base64 requirement from 0.22 to 0.23 (#1059) Updates the requirements on [base64](https://github.com/marshallpierce/rust-base64) to permit the latest version. - [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md) - [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.0...v0.23.0) --- updated-dependencies: - dependency-name: base64 dependency-version: 0.23.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- crates/rmcp/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index e8b709b32..5d2bea792 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -64,7 +64,7 @@ jsonwebtoken = { version = "11", optional = true, features = ["aws_lc_rs"] } schemars = { version = "1.0", optional = true, features = ["chrono04"] } # for image encoding -base64 = { version = "0.22", optional = true } +base64 = { version = "0.23", optional = true } # for SEP-2322 requestState integrity sealing (opt-in via the `request-state` feature) hmac = { version = "0.13", optional = true } From fff05d40fd05e7370803905ca02c3775978893bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:32:31 -0400 Subject: [PATCH 281/333] chore: release v3.0.0-beta.4 (#1061) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp/CHANGELOG.md | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fa9735857..f2a45e278 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.0.0-beta.3", path = "./crates/rmcp" } -rmcp-macros = { version = "3.0.0-beta.3", path = "./crates/rmcp-macros" } +rmcp = { version = "3.0.0-beta.4", path = "./crates/rmcp" } +rmcp-macros = { version = "3.0.0-beta.4", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.0.0-beta.3" +version = "3.0.0-beta.4" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 58c28c8ab..ccbffab29 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0-beta.4](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.3...rmcp-v3.0.0-beta.4) - 2026-07-28 + +### Fixed + +- accept namespaced discovery server information ([#1044](https://github.com/modelcontextprotocol/rust-sdk/pull/1044)) + +### Other + +- *(deps)* update base64 requirement from 0.22 to 0.23 ([#1059](https://github.com/modelcontextprotocol/rust-sdk/pull/1059)) +- *(deps)* update jsonwebtoken requirement from 10 to 11 ([#1058](https://github.com/modelcontextprotocol/rust-sdk/pull/1058)) + ## [3.0.0-beta.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.2...rmcp-v3.0.0-beta.3) - 2026-07-27 ### Fixed From e5403cf956ce20d1d1cbf986515ae22fe41453ec Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:02:32 -0400 Subject: [PATCH 282/333] fix: gate client handler bounds for local (#1068) --- crates/rmcp/Cargo.toml | 5 + crates/rmcp/src/handler/client.rs | 365 +++++++++--------- .../rmcp/tests/test_local_client_handler.rs | 16 + 3 files changed, 210 insertions(+), 176 deletions(-) create mode 100644 crates/rmcp/tests/test_local_client_handler.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 5d2bea792..44c064418 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -238,6 +238,11 @@ required-features = [ ] path = "tests/test_with_js.rs" +[[test]] +name = "test_local_client_handler" +required-features = ["client", "local"] +path = "tests/test_local_client_handler.rs" + [[test]] name = "test_notification" required-features = ["server", "client"] diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index d61070b61..532ca7ed2 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -85,189 +85,202 @@ impl Service for H { } } -#[allow(unused_variables)] -pub trait ClientHandler: Sized + Send + Sync + 'static { - fn ping( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(())) - } +macro_rules! client_handler_methods { + () => { + fn ping( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(())) + } - fn create_message( - &self, - params: CreateMessageRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Err( - McpError::method_not_found::(), - )) - } + fn create_message( + &self, + params: CreateMessageRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Err( + McpError::method_not_found::(), + )) + } - fn list_roots( - &self, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListRootsResult::default())) - } + fn list_roots( + &self, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + std::future::ready(Ok(ListRootsResult::default())) + } - /// Handle an elicitation request from a server asking for user input. - /// - /// This method is called when a server needs interactive input from the user - /// during tool execution. Implementations should present the message to the user, - /// collect their input according to the requested schema, and return the result. - /// - /// # Arguments - /// * `request` - The elicitation request with message and schema - /// * `context` - The request context - /// - /// # Returns - /// The user's response including action (accept/decline/cancel) and optional data - /// - /// # Default Behavior - /// The default implementation automatically declines all elicitation requests. - /// Real clients should override this to provide user interaction. - /// - /// # Example - /// ```rust,ignore - /// use rmcp::model::ElicitRequestParams; - /// use rmcp::{ - /// model::ErrorData as McpError, - /// model::*, - /// service::{NotificationContext, RequestContext, RoleClient, Service, ServiceRole}, - /// }; - /// use rmcp::ClientHandler; - /// - /// impl ClientHandler for MyClient { - /// async fn create_elicitation( - /// &self, - /// request: ElicitRequestParams, - /// context: RequestContext, - /// ) -> Result { - /// match request { - /// ElicitRequestParams::FormElicitationParam {meta, message, requested_schema,} => { - /// // Display message to user and collect input according to requested_schema - /// let user_input = get_user_input(message, requested_schema).await?; - /// Ok(ElicitResult { - /// action: ElicitationAction::Accept, - /// content: Some(user_input), - /// meta: None, - /// }) - /// } - /// ElicitRequestParams::UrlElicitationParam {meta, message, url, elicitation_id,} => { - /// // Open URL in browser for user to complete elicitation - /// open_url_in_browser(url).await?; - /// Ok(ElicitResult { - /// action: ElicitationAction::Accept, - /// content: None, - /// meta: None, - /// }) - /// } - /// } - /// } - /// } - /// ``` - fn create_elicitation( - &self, - request: ElicitRequestParams, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - // Default implementation declines all requests - real clients should override this - let _ = (request, context); - std::future::ready(Ok(ElicitResult { - action: ElicitationAction::Decline, - content: None, - meta: None, - })) - } + /// Handle an elicitation request from a server asking for user input. + /// + /// This method is called when a server needs interactive input from the user + /// during tool execution. Implementations should present the message to the user, + /// collect their input according to the requested schema, and return the result. + /// + /// # Arguments + /// * `request` - The elicitation request with message and schema + /// * `context` - The request context + /// + /// # Returns + /// The user's response including action (accept/decline/cancel) and optional data + /// + /// # Default Behavior + /// The default implementation automatically declines all elicitation requests. + /// Real clients should override this to provide user interaction. + /// + /// # Example + /// ```rust,ignore + /// use rmcp::model::ElicitRequestParams; + /// use rmcp::{ + /// model::ErrorData as McpError, + /// model::*, + /// service::{NotificationContext, RequestContext, RoleClient, Service, ServiceRole}, + /// }; + /// use rmcp::ClientHandler; + /// + /// impl ClientHandler for MyClient { + /// async fn create_elicitation( + /// &self, + /// request: ElicitRequestParams, + /// context: RequestContext, + /// ) -> Result { + /// match request { + /// ElicitRequestParams::FormElicitationParam {meta, message, requested_schema,} => { + /// // Display message to user and collect input according to requested_schema + /// let user_input = get_user_input(message, requested_schema).await?; + /// Ok(ElicitResult { + /// action: ElicitationAction::Accept, + /// content: Some(user_input), + /// meta: None, + /// }) + /// } + /// ElicitRequestParams::UrlElicitationParam {meta, message, url, elicitation_id,} => { + /// // Open URL in browser for user to complete elicitation + /// open_url_in_browser(url).await?; + /// Ok(ElicitResult { + /// action: ElicitationAction::Accept, + /// content: None, + /// meta: None, + /// }) + /// } + /// } + /// } + /// } + /// ``` + fn create_elicitation( + &self, + request: ElicitRequestParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + // Default implementation declines all requests - real clients should override this + let _ = (request, context); + std::future::ready(Ok(ElicitResult { + action: ElicitationAction::Decline, + content: None, + meta: None, + })) + } - fn on_custom_request( - &self, - request: CustomRequest, - context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - let CustomRequest { method, .. } = request; - let _ = context; - std::future::ready(Err(McpError::new( - ErrorCode::METHOD_NOT_FOUND, - method, - None, - ))) - } + fn on_custom_request( + &self, + request: CustomRequest, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let CustomRequest { method, .. } = request; + let _ = context; + std::future::ready(Err(McpError::new( + ErrorCode::METHOD_NOT_FOUND, + method, + None, + ))) + } - fn on_cancelled( - &self, - params: CancelledNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_progress( - &self, - params: ProgressNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_logging_message( - &self, - params: LoggingMessageNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_resource_updated( - &self, - params: ResourceUpdatedNotificationParam, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_resource_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_tool_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_prompt_list_changed( - &self, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_subscriptions_acknowledged( - &self, - params: SubscriptionsAcknowledgedNotificationParams, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } + fn on_cancelled( + &self, + params: CancelledNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_progress( + &self, + params: ProgressNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_logging_message( + &self, + params: LoggingMessageNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_resource_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_tool_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_prompt_list_changed( + &self, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_subscriptions_acknowledged( + &self, + params: SubscriptionsAcknowledgedNotificationParams, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } - fn on_task_status( - &self, - params: TaskStatusNotificationParams, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - std::future::ready(()) - } - fn on_custom_notification( - &self, - notification: CustomNotification, - context: NotificationContext, - ) -> impl Future + MaybeSendFuture + '_ { - let _ = (notification, context); - std::future::ready(()) - } + fn on_task_status( + &self, + params: TaskStatusNotificationParams, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + std::future::ready(()) + } + fn on_custom_notification( + &self, + notification: CustomNotification, + context: NotificationContext, + ) -> impl Future + MaybeSendFuture + '_ { + let _ = (notification, context); + std::future::ready(()) + } - fn get_info(&self) -> ClientInfo { - ClientInfo::default() - } + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } + }; +} + +#[allow(unused_variables)] +#[cfg(not(feature = "local"))] +pub trait ClientHandler: Sized + Send + Sync + 'static { + client_handler_methods!(); +} + +#[allow(unused_variables)] +#[cfg(feature = "local")] +pub trait ClientHandler: Sized + 'static { + client_handler_methods!(); } /// Do nothing, with default client info. diff --git a/crates/rmcp/tests/test_local_client_handler.rs b/crates/rmcp/tests/test_local_client_handler.rs new file mode 100644 index 000000000..e85d3d8c9 --- /dev/null +++ b/crates/rmcp/tests/test_local_client_handler.rs @@ -0,0 +1,16 @@ +use std::rc::Rc; + +use rmcp::ClientHandler; + +struct LocalClientHandler { + _state: Rc<()>, +} + +impl ClientHandler for LocalClientHandler {} + +#[test] +fn client_handler_accepts_non_send_sync_state_with_local_feature() { + fn assert_client_handler() {} + + assert_client_handler::(); +} From 0dea39d10146b96fbba4c0fcb2db74af822fad4c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:38:39 -0400 Subject: [PATCH 283/333] fix!: preserve OAuth discovery transport errors (#1069) * fix(auth): preserve OAuth discovery transport errors * refactor!: expose domain-specific boxed OAuth HTTP errors Let custom OAuth HTTP clients preserve native error types and source chains while keeping the type-erased boundary explicit in the public API. BREAKING CHANGE: OAuthHttpClientError is now a boxed error alias; custom clients should return native errors with .into() or box them directly. --------- Co-authored-by: Theodore Ni <3806110+tjni@users.noreply.github.com> --- crates/rmcp/src/error.rs | 14 ++ crates/rmcp/src/transport/auth.rs | 259 ++++++++++++++++++++++-------- docs/OAUTH_SUPPORT.md | 4 +- 3 files changed, 205 insertions(+), 72 deletions(-) diff --git a/crates/rmcp/src/error.rs b/crates/rmcp/src/error.rs index 74f7d4383..1e8635a3a 100644 --- a/crates/rmcp/src/error.rs +++ b/crates/rmcp/src/error.rs @@ -17,6 +17,20 @@ impl Display for ErrorData { impl std::error::Error for ErrorData {} +#[cfg(all(feature = "auth", any(feature = "client", feature = "server")))] +pub(crate) struct ErrorChain<'a>(pub(crate) &'a (dyn std::error::Error + 'static)); + +#[cfg(all(feature = "auth", any(feature = "client", feature = "server")))] +impl Display for ErrorChain<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0)?; + for source in std::iter::successors(self.0.source(), |source| source.source()) { + write!(f, "\n Caused by: {source}")?; + } + Ok(()) + } +} + /// This is an unified error type for the errors could be returned by the service. #[derive(Debug, thiserror::Error)] #[allow(clippy::large_enum_variant)] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index c6751d88f..ef562ffda 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -70,20 +70,19 @@ impl OAuthHttpRequest { } } -/// Error returned by a custom OAuth HTTP client. -#[derive(Debug, Error)] -#[error("{message}")] -pub struct OAuthHttpClientError { - message: String, -} +/// Type-erased error returned by an [`OAuthHttpClient`]. +pub type OAuthHttpClientError = Box; -impl OAuthHttpClientError { - /// Create an error from a transport-provided message. - pub fn new(message: impl Into) -> Self { - Self { - message: message.into(), - } - } +#[derive(Debug, Error)] +enum OAuthHttpError { + #[error("OAuth HTTP response body exceeds {0} bytes")] + ResponseBodyTooLarge(usize), + #[error("unexpected HTTP status {0}")] + UnexpectedStatus(StatusCode), + #[error("OAuth discovery redirect to non-same-origin URL rejected: {0}")] + CrossOriginRedirect(Url), + #[error("OAuth discovery exceeded {0} redirects")] + TooManyRedirects(usize), } /// Future returned by [`OAuthHttpClient::execute`]. @@ -132,11 +131,11 @@ impl OAuthHttpClient for ReqwestOAuthHttpClient { OAuthHttpRedirectPolicy::Stop => &self.stop_redirects, }; let request = reqwest::Request::try_from(request) - .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; let response = client .execute(request) .await - .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; let mut builder = oauth2::http::Response::builder() .status(response.status()) @@ -147,17 +146,17 @@ impl OAuthHttpClient for ReqwestOAuthHttpClient { let mut body = Vec::new(); let mut body_stream = response.bytes_stream(); while let Some(chunk) = body_stream.next().await { - let chunk = chunk.map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + let chunk = chunk.map_err(|error| Box::new(error) as OAuthHttpClientError)?; if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES - body.len() { - return Err(OAuthHttpClientError::new(format!( - "OAuth HTTP response body exceeds {MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES} bytes" - ))); + return Err(Box::new(OAuthHttpError::ResponseBodyTooLarge( + MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES, + )) as OAuthHttpClientError); } body.extend_from_slice(&chunk); } builder .body(body) - .map_err(|error| OAuthHttpClientError::new(error.to_string())) + .map_err(|error| Box::new(error) as OAuthHttpClientError) }) } } @@ -167,16 +166,35 @@ struct OAuth2HttpClient<'a> { redirect_policy: OAuthHttpRedirectPolicy, } +#[derive(Debug)] +struct OAuth2HttpClientError(OAuthHttpClientError); + +impl std::fmt::Display for OAuth2HttpClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("OAuth HTTP request failed") + } +} + +impl std::error::Error for OAuth2HttpClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.0.as_ref()) + } +} + impl<'c> AsyncHttpClient<'c> for OAuth2HttpClient<'_> { - type Error = OAuthHttpClientError; + type Error = OAuth2HttpClientError; type Future = std::pin::Pin< Box> + Send + 'c>, >; fn call(&'c self, request: HttpRequest) -> Self::Future { - self.client - .execute(OAuthHttpRequest::new(request, self.redirect_policy)) + Box::pin(async move { + self.client + .execute(OAuthHttpRequest::new(request, self.redirect_policy)) + .await + .map_err(OAuth2HttpClientError) + }) } } @@ -2283,13 +2301,10 @@ impl AuthorizationManager { discovery_url: &Url, ) -> Result, AuthError> { debug!("discovery url: {:?}", discovery_url); - let response = match self.discovery_get(discovery_url).await { - Ok(r) => r, - Err(e) => { - debug!("discovery request failed: {}", e); - return Ok(None); - } - }; + let response = self + .discovery_get(discovery_url) + .await + .map_err(|error| Self::discovery_failed(discovery_url, error))?; if response.status() != StatusCode::OK { debug!("discovery returned non-200: {}", response.status()); @@ -2387,7 +2402,7 @@ impl AuthorizationManager { async fn discover_oauth_server_via_resource_metadata( &self, ) -> Result, AuthError> { - let Some(resource_metadata_url) = self.discover_resource_metadata_url().await else { + let Some(resource_metadata_url) = self.discover_resource_metadata_url().await? else { return Ok(None); }; self.discover_oauth_server_from_resource_metadata_url(&resource_metadata_url) @@ -2509,10 +2524,11 @@ impl AuthorizationManager { && Self::is_same_origin(&root_resource, &path_resource) } - async fn discover_resource_metadata_url(&self) -> Option { - if let Some(resource_metadata_url) = self.probe_resource_metadata_url(&self.base_url).await + async fn discover_resource_metadata_url(&self) -> Result, AuthError> { + if let Some(resource_metadata_url) = + self.probe_resource_metadata_url(&self.base_url).await? { - return Some(resource_metadata_url); + return Ok(Some(resource_metadata_url)); } // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. @@ -2525,37 +2541,33 @@ impl AuthorizationManager { discovery_url.set_fragment(None); discovery_url.set_path(&candidate_path); if let Some(resource_metadata_url) = - self.probe_resource_metadata_url(&discovery_url).await + self.probe_resource_metadata_url(&discovery_url).await? { - return Some(resource_metadata_url); + return Ok(Some(resource_metadata_url)); } } - None + Ok(None) } /// Probe `url` with a GET, extracting the resource metadata url from a /// 200 (the url itself is the metadata document) or from a 401's /// WWW-Authenticate header value. /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for - async fn probe_resource_metadata_url(&self, url: &Url) -> Option { - let response = match self.discovery_get(url).await { - Ok(r) => r, - Err(e) => { - debug!("resource metadata probe failed: {}", e); - return None; - } - }; + async fn probe_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { + let response = self + .discovery_get(url) + .await + .map_err(|error| Self::discovery_failed(url, error))?; match response.status() { - StatusCode::OK => Some(url.clone()), - StatusCode::UNAUTHORIZED => { - self.extract_resource_metadata_url_from_www_authenticate(&response) - .await - } + StatusCode::OK => Ok(Some(url.clone())), + StatusCode::UNAUTHORIZED => Ok(self + .extract_resource_metadata_url_from_www_authenticate(&response) + .await), status => { debug!("resource metadata probe returned unexpected status: {status}"); - None + Ok(None) } } } @@ -2588,13 +2600,10 @@ impl AuthorizationManager { "resource metadata discovery url: {:?}", resource_metadata_url ); - let response = match self.discovery_get(resource_metadata_url).await { - Ok(r) => r, - Err(e) => { - debug!("resource metadata request failed: {}", e); - return Ok(None); - } - }; + let response = self + .discovery_get(resource_metadata_url) + .await + .map_err(|error| Self::discovery_failed(resource_metadata_url, error))?; if response.status() != StatusCode::OK { debug!( @@ -2614,6 +2623,26 @@ impl AuthorizationManager { Ok(Some(metadata)) } + fn discovery_failed(url: &Url, error: OAuthHttpClientError) -> AuthError { + AuthError::MetadataError(format!( + "OAuth metadata discovery failed for {url}\n Caused by: {}", + crate::error::ErrorChain(error.as_ref()) + )) + } + + async fn discovery_request( + &self, + request: OAuthHttpRequest, + ) -> Result { + let response = self.http_client.execute(request).await?; + if response.status().is_server_error() { + return Err(Box::new(OAuthHttpError::UnexpectedStatus( + response.status(), + ))); + } + Ok(response) + } + async fn discovery_get(&self, url: &Url) -> Result { let mut current_url = url.clone(); for _ in 0..MAX_OAUTH_DISCOVERY_REDIRECTS { @@ -2622,10 +2651,9 @@ impl AuthorizationManager { .uri(current_url.as_str()) .header(HEADER_MCP_PROTOCOL_VERSION, "2024-11-05") .body(Vec::new()) - .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; let response = self - .http_client - .execute(OAuthHttpRequest::new( + .discovery_request(OAuthHttpRequest::new( request, OAuthHttpRedirectPolicy::Stop, )) @@ -2640,23 +2668,21 @@ impl AuthorizationManager { }; let location = location .to_str() - .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; let next_url = current_url .join(location) - .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + .map_err(|error| Box::new(error) as OAuthHttpClientError)?; if Self::is_http_url(&next_url) && Self::is_same_origin(¤t_url, &next_url) { current_url = next_url; continue; } - return Err(OAuthHttpClientError::new(format!( - "OAuth discovery redirect to non-same-origin URL rejected: {next_url}" - ))); + return Err(Box::new(OAuthHttpError::CrossOriginRedirect(next_url))); } - Err(OAuthHttpClientError::new(format!( - "OAuth discovery exceeded {MAX_OAUTH_DISCOVERY_REDIRECTS} redirects" + Err(Box::new(OAuthHttpError::TooManyRedirects( + MAX_OAUTH_DISCOVERY_REDIRECTS, ))) } @@ -3842,9 +3868,7 @@ mod tests { body: request.request.body().clone(), }); let response = self.responses.lock().unwrap().pop_front(); - Box::pin(async move { - response.ok_or_else(|| OAuthHttpClientError::new("missing fake response")) - }) + Box::pin(async move { response.ok_or_else(|| "missing fake response".into()) }) } } @@ -3870,6 +3894,99 @@ mod tests { .unwrap() } + #[test] + fn oauth_http_client_error_preserves_source_chain() { + #[derive(Debug, thiserror::Error)] + #[error("request failed")] + struct RequestError(#[source] std::io::Error); + + let error: OAuthHttpClientError = RequestError(std::io::Error::other( + "certificate signed by unknown authority", + )) + .into(); + assert!(error.downcast_ref::().is_some()); + + let url = Url::parse("https://mcp.example.com/mcp").unwrap(); + let error = AuthorizationManager::discovery_failed(&url, error); + assert_eq!( + error.to_string(), + "Metadata error: OAuth metadata discovery failed for https://mcp.example.com/mcp\n Caused by: request failed\n Caused by: certificate signed by unknown authority" + ); + } + + #[tokio::test] + async fn default_http_client_preserves_connection_failure_cause() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/mcp", listener.local_addr().unwrap()); + drop(listener); + + let manager = AuthorizationManager::new(&url).await.unwrap(); + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::MetadataError(ref reason) + if reason.contains(&url) + && reason.contains("\n Caused by: error sending request for url") + && reason.matches("error sending request for url").count() == 1 + && reason.to_ascii_lowercase().contains("connection refused") + ), + "unexpected discovery error: {error}" + ); + } + + #[tokio::test] + async fn authorization_metadata_propagates_transport_failure() { + let responses = preregistered_discovery_responses() + .into_iter() + .take(2) + .collect(); + let client = RecordingOAuthHttpClient::with_responses(responses); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::MetadataError(ref reason) + if reason.contains("https://auth.example.com/.well-known/oauth-authorization-server") + && reason.contains("missing fake response") + ), + "unexpected discovery error: {error}" + ); + assert_eq!(client.requests().len(), 3); + } + + #[tokio::test] + async fn discovery_propagates_server_errors() { + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(RecordingOAuthHttpClient::with_responses(vec![ + empty_response(503), + ])), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::MetadataError(ref reason) + if reason.contains("https://mcp.example.com/mcp") && reason.contains("503") + ), + "unexpected discovery error: {error}" + ); + } + #[tokio::test] async fn custom_http_client_handles_protected_resource_discovery() { let challenge = oauth2::http::Response::builder() diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 1d09d7402..b35478739 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -61,7 +61,9 @@ have been obtained. If OAuth requests must run outside reqwest, implement `OAuthHttpClient` and use `OAuthState::new_with_oauth_http_client`. The SDK passes each OAuth request to your implementation with the raw HTTP request, a suggested timeout, and an -`OAuthHttpRedirectPolicy`. +`OAuthHttpRedirectPolicy`. `OAuthHttpClientFuture` returns +`OAuthHttpClientError`, so implementations can propagate their native error +types with `?` without flattening their source chains into strings. ```rust ignore use std::sync::Arc; From 25213a271acffd7cd310a68c09f8ede118112482 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:01:56 -0400 Subject: [PATCH 284/333] refactor!: remove deprecated v3 APIs (#1066) --- crates/rmcp/src/error.rs | 4 - crates/rmcp/src/handler/client.rs | 69 +++++++----- crates/rmcp/src/lib.rs | 3 +- crates/rmcp/src/model.rs | 106 +++--------------- crates/rmcp/src/model/elicitation_schema.rs | 41 ------- crates/rmcp/src/model/meta.rs | 9 -- crates/rmcp/src/model/serde_impl.rs | 13 ++- crates/rmcp/src/service/server.rs | 14 --- crates/rmcp/src/transport/child_process.rs | 40 +------ .../streamable_http_server/session/local.rs | 3 - crates/rmcp/tests/test_elicitation.rs | 19 ++-- 11 files changed, 72 insertions(+), 249 deletions(-) diff --git a/crates/rmcp/src/error.rs b/crates/rmcp/src/error.rs index 1e8635a3a..0940f29c2 100644 --- a/crates/rmcp/src/error.rs +++ b/crates/rmcp/src/error.rs @@ -1,10 +1,6 @@ use std::{borrow::Cow, fmt::Display}; pub use crate::model::ErrorData; -#[deprecated( - note = "Use `rmcp::ErrorData` instead, `rmcp::ErrorData` could become `RmcpError` in the future." -)] -pub type Error = ErrorData; impl Display for ErrorData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}: {}", self.code.0, self.message)?; diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 532ca7ed2..414387028 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -129,42 +129,51 @@ macro_rules! client_handler_methods { /// Real clients should override this to provide user interaction. /// /// # Example - /// ```rust,ignore - /// use rmcp::model::ElicitRequestParams; + /// ```rust,no_run /// use rmcp::{ - /// model::ErrorData as McpError, - /// model::*, - /// service::{NotificationContext, RequestContext, RoleClient, Service, ServiceRole}, + /// ClientHandler, + /// model::{ + /// ElicitRequestParams, ElicitResult, ElicitationAction, ElicitationSchema, + /// ErrorData as McpError, + /// }, + /// service::{RequestContext, RoleClient}, /// }; - /// use rmcp::ClientHandler; /// + /// # struct MyClient; + /// # + /// # async fn get_user_input( + /// # _message: String, + /// # _schema: ElicitationSchema, + /// # ) -> Result { + /// # std::future::pending().await + /// # } + /// # + /// # async fn open_url_in_browser(_url: String) -> Result<(), McpError> { + /// # Ok(()) + /// # } + /// # /// impl ClientHandler for MyClient { - /// async fn create_elicitation( - /// &self, - /// request: ElicitRequestParams, - /// context: RequestContext, - /// ) -> Result { - /// match request { - /// ElicitRequestParams::FormElicitationParam {meta, message, requested_schema,} => { - /// // Display message to user and collect input according to requested_schema - /// let user_input = get_user_input(message, requested_schema).await?; - /// Ok(ElicitResult { - /// action: ElicitationAction::Accept, - /// content: Some(user_input), - /// meta: None, - /// }) - /// } - /// ElicitRequestParams::UrlElicitationParam {meta, message, url, elicitation_id,} => { - /// // Open URL in browser for user to complete elicitation - /// open_url_in_browser(url).await?; - /// Ok(ElicitResult { - /// action: ElicitationAction::Accept, - /// content: None, - /// meta: None, - /// }) + /// async fn create_elicitation( + /// &self, + /// request: ElicitRequestParams, + /// _context: RequestContext, + /// ) -> Result { + /// match request { + /// ElicitRequestParams::FormElicitationParams { + /// message, + /// requested_schema, + /// .. + /// } => { + /// let input = get_user_input(message, requested_schema).await?; + /// Ok(ElicitResult::new(ElicitationAction::Accept).with_content(input)) + /// } + /// ElicitRequestParams::UrlElicitationParams { url, .. } => { + /// open_url_in_browser(url).await?; + /// Ok(ElicitResult::new(ElicitationAction::Accept)) + /// } + /// _ => Ok(ElicitResult::new(ElicitationAction::Decline)), /// } /// } - /// } /// } /// ``` fn create_elicitation( diff --git a/crates/rmcp/src/lib.rs b/crates/rmcp/src/lib.rs index 7c9b7b195..3be6616ed 100644 --- a/crates/rmcp/src/lib.rs +++ b/crates/rmcp/src/lib.rs @@ -3,8 +3,7 @@ #![doc = include_str!("../README.md")] mod error; -#[allow(deprecated)] -pub use error::{Error, ErrorData, RmcpError}; +pub use error::{ErrorData, RmcpError}; /// Basic data types in MCP specification pub mod model; diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 9cae7aab2..bff0342fb 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1029,10 +1029,6 @@ impl RequestParamsMeta for InitializeRequestParams { } } -/// Deprecated: Use [`InitializeRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use InitializeRequestParams instead")] -pub type InitializeRequestParam = InitializeRequestParams; - /// The server's response to an initialization request. /// /// Contains the server's protocol version, capabilities, and implementation @@ -1437,9 +1433,6 @@ impl RequestParamsMeta for PaginatedRequestParams { } } -/// Deprecated: Use [`PaginatedRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use PaginatedRequestParams instead")] -pub type PaginatedRequestParam = PaginatedRequestParams; // ============================================================================= // PROGRESS AND PAGINATION // ============================================================================= @@ -1680,10 +1673,6 @@ impl RequestParamsMeta for ReadResourceRequestParams { } } -/// Deprecated: Use [`ReadResourceRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use ReadResourceRequestParams instead")] -pub type ReadResourceRequestParam = ReadResourceRequestParams; - /// Result containing the contents of a read resource #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] @@ -1788,10 +1777,6 @@ impl RequestParamsMeta for SubscribeRequestParams { } } -/// Deprecated: Use [`SubscribeRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use SubscribeRequestParams instead")] -pub type SubscribeRequestParam = SubscribeRequestParams; - /// Request to subscribe to resource updates #[deprecated( note = "resources/subscribe is legacy-only; use subscriptions/listen for protocol version 2026-07-28" @@ -1831,10 +1816,6 @@ impl RequestParamsMeta for UnsubscribeRequestParams { } } -/// Deprecated: Use [`UnsubscribeRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use UnsubscribeRequestParams instead")] -pub type UnsubscribeRequestParam = UnsubscribeRequestParams; - /// Request to unsubscribe from resource updates #[deprecated( note = "resources/unsubscribe is legacy-only; cancel the subscriptions/listen request for protocol version 2026-07-28" @@ -2333,10 +2314,6 @@ impl RequestParamsMeta for GetPromptRequestParams { } } -/// Deprecated: Use [`GetPromptRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use GetPromptRequestParams instead")] -pub type GetPromptRequestParam = GetPromptRequestParams; - /// Request to get a specific prompt pub type GetPromptRequest = Request; @@ -2406,10 +2383,6 @@ impl RequestParamsMeta for SetLevelRequestParams { } } -/// Deprecated: Use [`SetLevelRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use SetLevelRequestParams instead")] -pub type SetLevelRequestParam = SetLevelRequestParams; - /// Request to set the logging level #[deprecated( since = "2.0.0", @@ -2679,9 +2652,6 @@ pub enum SamplingMessageContentBlock { ToolResult(ToolResultContent), } -#[deprecated(since = "2.0.0", note = "Renamed to SamplingMessageContentBlock")] -pub type SamplingMessageContent = SamplingMessageContentBlock; - impl SamplingMessageContentBlock { /// Create a text content pub fn text(text: impl Into) -> Self { @@ -3008,10 +2978,6 @@ impl CreateMessageRequestParams { } } -/// Deprecated: Use [`CreateMessageRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use CreateMessageRequestParams instead")] -pub type CreateMessageRequestParam = CreateMessageRequestParams; - /// Preferences for model selection and behavior in sampling requests. /// /// This allows servers to express their preferences for which model to use @@ -3201,10 +3167,6 @@ impl RequestParamsMeta for CompleteRequestParams { } } -/// Deprecated: Use [`CompleteRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use CompleteRequestParams instead")] -pub type CompleteRequestParam = CompleteRequestParams; - pub type CompleteRequest = Request; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] @@ -3391,9 +3353,6 @@ impl ResourceTemplateReference { } } -#[deprecated(since = "2.0.0", note = "Renamed to ResourceTemplateReference")] -pub type ResourceReference = ResourceTemplateReference; - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] @@ -3545,21 +3504,20 @@ pub enum ElicitationAction { Cancel, } -/// Helper enum for deserializing CreateElicitationRequestParam with backward compatibility. -/// When mode is missing, it defaults to FormElicitationParam. +/// Wire representation for tagged elicitation parameters and legacy forms without `mode`. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(tag = "mode")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -enum CreateElicitationRequestParamDeserializeHelper { +enum ElicitRequestParamsWire { #[serde(rename = "form", rename_all = "camelCase")] - FormElicitationParam { + Form { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] meta: Option, message: String, requested_schema: ElicitationSchema, }, #[serde(rename = "url", rename_all = "camelCase")] - UrlElicitationParam { + Url { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] meta: Option, message: String, @@ -3567,7 +3525,7 @@ enum CreateElicitationRequestParamDeserializeHelper { elicitation_id: String, }, #[serde(untagged, rename_all = "camelCase")] - FormElicitationParamBackwardsCompat { + LegacyForm { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] meta: Option, message: String, @@ -3575,19 +3533,17 @@ enum CreateElicitationRequestParamDeserializeHelper { }, } -impl TryFrom for ElicitRequestParams { +impl TryFrom for ElicitRequestParams { type Error = serde_json::Error; - fn try_from( - value: CreateElicitationRequestParamDeserializeHelper, - ) -> Result { + fn try_from(value: ElicitRequestParamsWire) -> Result { match value { - CreateElicitationRequestParamDeserializeHelper::FormElicitationParam { + ElicitRequestParamsWire::Form { meta, message, requested_schema, } - | CreateElicitationRequestParamDeserializeHelper::FormElicitationParamBackwardsCompat { + | ElicitRequestParamsWire::LegacyForm { meta, message, requested_schema, @@ -3596,7 +3552,7 @@ impl TryFrom for ElicitRequestPa message, requested_schema, }), - CreateElicitationRequestParamDeserializeHelper::UrlElicitationParam { + ElicitRequestParamsWire::Url { meta, message, url, @@ -3642,10 +3598,7 @@ impl TryFrom for ElicitRequestPa /// }; /// ``` #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde( - tag = "mode", - try_from = "CreateElicitationRequestParamDeserializeHelper" -)] +#[serde(tag = "mode", try_from = "ElicitRequestParamsWire")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub enum ElicitRequestParams { @@ -3697,13 +3650,6 @@ impl RequestParamsMeta for ElicitRequestParams { } } -/// Deprecated: Use [`ElicitRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use ElicitRequestParams instead")] -pub type CreateElicitationRequestParam = ElicitRequestParams; - -#[deprecated(since = "2.0.0", note = "Renamed to ElicitRequestParams")] -pub type CreateElicitationRequestParams = ElicitRequestParams; - /// The result returned by a client in response to an elicitation request. /// /// Contains the user's decision (accept/decline/cancel) and optionally their input data @@ -3750,15 +3696,9 @@ impl ElicitResult { } } -#[deprecated(since = "2.0.0", note = "Renamed to ElicitResult")] -pub type CreateElicitationResult = ElicitResult; - /// Request type for creating an elicitation to gather user input pub type ElicitRequest = Request; -#[deprecated(since = "2.0.0", note = "Renamed to ElicitRequest")] -pub type CreateElicitationRequest = ElicitRequest; - // ============================================================================= // TOOL EXECUTION RESULTS // ============================================================================= @@ -4088,10 +4028,6 @@ impl RequestParamsMeta for CallToolRequestParams { } } -/// Deprecated: Use [`CallToolRequestParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use CallToolRequestParams instead")] -pub type CallToolRequestParam = CallToolRequestParams; - /// Request to call a specific tool pub type CallToolRequest = Request; @@ -4625,14 +4561,6 @@ mod tests { use super::*; - #[test] - #[allow(deprecated)] - fn deprecated_aliases_still_resolve() { - // 하위호환: 구 이름이 새 타입으로 여전히 resolve되는지 확인. - let _: CreateElicitationResult = ElicitResult::new(ElicitationAction::Accept); - let _: ResourceReference = ResourceTemplateReference::new("res://x"); - } - #[cfg(feature = "transport-streamable-http-client")] #[test] fn transport_closed_marker_accepts_only_the_process_local_token() { @@ -4853,12 +4781,11 @@ mod tests { serde_json::from_value(request.clone()).expect("invalid request"); let (request, id) = request.into_request().expect("should be a request"); assert_eq!(id, RequestId::Number(1)); - #[allow(deprecated)] match request { ClientRequest::InitializeRequest(Request { method: _, params: - InitializeRequestParam { + InitializeRequestParams { meta: _, protocol_version: _, capabilities, @@ -5124,8 +5051,7 @@ mod tests { } #[test] - fn test_elicitation_deserialization_untagged() { - // Test deserialization without the "type" field (should default to FormElicitationParam) + fn elicitation_without_mode_deserializes_as_form() { let json_data_without_tag = json!({ "message": "Please provide more details.", "requestedSchema": { @@ -5151,7 +5077,7 @@ mod tests { assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); assert_eq!(requested_schema.type_, ObjectTypeConst); } else { - panic!("Expected FormElicitationParam"); + panic!("Expected FormElicitationParams"); } } @@ -5189,7 +5115,7 @@ mod tests { assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); assert_eq!(requested_schema.type_, ObjectTypeConst); } else { - panic!("Expected FormElicitationParam"); + panic!("Expected FormElicitationParams"); } let json_data_url = json!({ @@ -5218,7 +5144,7 @@ mod tests { assert_eq!(url, "https://example.com/form"); assert_eq!(elicitation_id, "elicitation-123"); } else { - panic!("Expected UrlElicitationParam"); + panic!("Expected UrlElicitationParams"); } } diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 29128fad6..3cc24ca4c 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -63,9 +63,6 @@ pub enum PrimitiveSchemaDefinition { Boolean(BooleanSchema), } -#[deprecated(since = "2.0.0", note = "Renamed to PrimitiveSchemaDefinition")] -pub type PrimitiveSchema = PrimitiveSchemaDefinition; - // ============================================================================= // STRING SCHEMA // ============================================================================= @@ -1600,44 +1597,6 @@ impl ElicitationSchemaBuilder { self.property(name, PrimitiveSchemaDefinition::Enum(enum_schema)) } - /// Add a required enum property using values. Creates an untitled single-select enum. - #[deprecated( - since = "0.13.0", - note = "Use ElicitationSchemaBuilder::required_enum_schema with EnumSchema::builder instead" - )] - pub fn required_enum(self, name: impl Into, values: Vec) -> Self { - self.required_property( - name, - PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema { - type_: StringTypeConst, - title: None, - description: None, - enum_: values, - enum_names: None, - default: None, - })), - ) - } - - /// Add an optional enum property using values. Creates an untitled single-select enum. - #[deprecated( - since = "0.13.0", - note = "Use ElicitationSchemaBuilder::optional_enum_schema with EnumSchema::builder instead" - )] - pub fn optional_enum(self, name: impl Into, values: Vec) -> Self { - self.property( - name, - PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema { - type_: StringTypeConst, - title: None, - description: None, - enum_: values, - enum_names: None, - default: None, - })), - ) - } - /// Mark an existing property as required pub fn mark_required(mut self, name: impl Into) -> Self { self.required.push(name.into()); diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 0f7d90ce6..d80064399 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -243,15 +243,6 @@ variant_extension! { #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct MetaObject(pub JsonObject); -/// Deprecated alias for [`MetaObject`]. -/// -/// This is a re-export rather than a type alias so the `Meta(...)` tuple -/// constructor keeps working. Request and notification metadata now have -/// dedicated types; use [`RequestMetaObject`] or [`NotificationMetaObject`] -/// where those are expected. -#[deprecated(note = "Use MetaObject (or RequestMetaObject / NotificationMetaObject)")] -pub use self::MetaObject as Meta; - impl MetaObject { /// Reserved `_meta` key for the W3C Trace Context `traceparent` value (SEP-414). const TRACEPARENT_FIELD: &str = "traceparent"; diff --git a/crates/rmcp/src/model/serde_impl.rs b/crates/rmcp/src/model/serde_impl.rs index b974722af..bfd3b5783 100644 --- a/crates/rmcp/src/model/serde_impl.rs +++ b/crates/rmcp/src/model/serde_impl.rs @@ -86,9 +86,8 @@ struct ProxyNoParam { } /// Combine the message-specific `_meta` map with a legacy [`MetaObject`] -/// extension (inserted through the deprecated `Meta` name), so pre-3.x code -/// does not silently lose metadata on the wire. On key conflicts the -/// message-specific map wins. +/// extension so metadata stored in [`Extensions`] is not lost on the wire. +/// On key conflicts the message-specific map wins. fn merge_legacy_meta<'a>( typed: Option<&'a JsonObject>, extensions: &'a Extensions, @@ -99,7 +98,11 @@ fn merge_legacy_meta<'a>( (None, Some(legacy)) => Some(Cow::Borrowed(legacy)), (Some(typed), Some(legacy)) => { let mut merged = legacy.clone(); - merged.extend(typed.clone()); + merged.extend( + typed + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); Some(Cow::Owned(merged)) } (None, None) => None, @@ -778,8 +781,6 @@ mod test { #[test] fn test_legacy_meta_extension_still_serializes() { - // Pre-3.x code inserts `MetaObject` into extensions through the - // deprecated `Meta` name; its metadata must not be silently dropped. let mut extensions = Extensions::new(); let mut legacy = crate::model::MetaObject::new(); legacy.insert("traceId".to_string(), json!("legacy")); diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 2b84a9431..938896ea6 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -85,13 +85,6 @@ pub enum ServerInitializeError { #[error("expect initialized request, but received: {0:?}")] ExpectedInitializeRequest(Option), - #[deprecated( - since = "1.4.0", - note = "The server no longer gates on the initialized notification. This variant is never constructed and will be removed in a future major release." - )] - #[error("expect initialized notification, but received: {0:?}")] - ExpectedInitializedNotification(Option), - #[error("connection closed: {0}")] ConnectionClosed(String), @@ -101,13 +94,6 @@ pub enum ServerInitializeError { #[error("initialize failed: {0}")] InitializeFailed(ErrorData), - #[deprecated( - since = "1.8.0", - note = "Negotiation now falls back to the server-configured version. This variant is never constructed and will be removed in a future major release." - )] - #[error("unsupported protocol version: {0}")] - UnsupportedProtocolVersion(ProtocolVersion), - #[error("Send message error {error}, when {context}")] TransportError { error: DynamicTransportError, diff --git a/crates/rmcp/src/transport/child_process.rs b/crates/rmcp/src/transport/child_process.rs index ebb6cc928..6e19a0c3b 100644 --- a/crates/rmcp/src/transport/child_process.rs +++ b/crates/rmcp/src/transport/child_process.rs @@ -2,10 +2,7 @@ use std::process::Stdio; use futures::future::Future; use process_wrap::tokio::{ChildWrapper, CommandWrap}; -use tokio::{ - io::AsyncRead, - process::{ChildStderr, ChildStdin, ChildStdout}, -}; +use tokio::process::{ChildStderr, ChildStdin, ChildStdout}; use super::{RxJsonRpcMessage, Transport, TxJsonRpcMessage, async_rw::AsyncRwTransport}; use crate::RoleClient; @@ -59,32 +56,6 @@ impl Drop for ChildWithCleanup { } } -// we hold the child process with stdout, for it's easier to implement AsyncRead -pin_project_lite::pin_project! { - pub struct TokioChildProcessOut { - child: ChildWithCleanup, - #[pin] - child_stdout: ChildStdout, - } -} - -impl TokioChildProcessOut { - /// Get the process ID of the child process. - pub fn id(&self) -> Option { - self.child.inner.as_ref()?.id() - } -} - -impl AsyncRead for TokioChildProcessOut { - fn poll_read( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> std::task::Poll> { - self.project().child_stdout.poll_read(cx, buf) - } -} - impl TokioChildProcess { /// Convenience: spawn with default `piped` stdio pub fn new(command: impl Into) -> std::io::Result { @@ -139,15 +110,6 @@ impl TokioChildProcess { pub fn into_inner(mut self) -> Option> { self.child.inner.take() } - - /// Split this helper into a reader (stdout) and writer (stdin). - #[deprecated( - since = "0.5.0", - note = "use the Transport trait implementation instead" - )] - pub fn split(self) -> (TokioChildProcessOut, ChildStdin) { - unimplemented!("This method is deprecated, use the Transport trait implementation instead"); - } } /// Builder for `TokioChildProcess` allowing custom `Stdio` configuration. diff --git a/crates/rmcp/src/transport/streamable_http_server/session/local.rs b/crates/rmcp/src/transport/streamable_http_server/session/local.rs index 231724ba7..cc9e14893 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session/local.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session/local.rs @@ -1032,9 +1032,6 @@ pub enum LocalSessionWorkerError { FailToSendInitializeRequest(SessionError), #[error("fail to handle message: {0}")] FailToHandleMessage(SessionError), - #[deprecated(note = "idle timeout now surfaces as WorkerQuitReason::IdleTimeout")] - #[error("keep alive timeout after {}ms", _0.as_millis())] - KeepAliveTimeout(Duration), #[error("init timeout after {}ms", _0.as_millis())] InitTimeout(Duration), #[error("Transport closed")] diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index b2bc39a70..953648c0e 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -90,7 +90,7 @@ async fn test_elicitation_request_param_serialization() { assert_eq!(msg1, msg2); assert_eq!(schema1, schema2); } - _ => panic!("Expected FormElicitationParam variant"), + _ => panic!("Expected FormElicitationParams variant"), } } @@ -179,7 +179,7 @@ async fn test_elicitation_json_rpc_protocol() { ElicitRequestParams::FormElicitationParams { message, .. } => { assert_eq!(message, "Do you want to continue?"); } - _ => panic!("Expected FormElicitationParam variant"), + _ => panic!("Expected FormElicitationParams variant"), } } @@ -496,7 +496,7 @@ async fn test_elicitation_structured_schemas() { ]) ); } - _ => panic!("Expected FormElicitationParam variant"), + _ => panic!("Expected FormElicitationParams variant"), } } @@ -738,7 +738,7 @@ async fn test_elicitation_multi_select_enum() { ) } } - _ => panic!("Expected FormElicitationParam variant"), + _ => panic!("Expected FormElicitationParams variant"), } } @@ -799,7 +799,7 @@ async fn test_elicitation_single_select_enum() { ) } } - _ => panic!("Expected FormElicitationParam variant"), + _ => panic!("Expected FormElicitationParams variant"), } } @@ -1056,10 +1056,8 @@ async fn test_client_capabilities_with_elicitation() { assert!(capabilities_without.elicitation.is_none()); } -/// Test InitializeRequestParam with elicitation capability #[tokio::test] async fn test_initialize_request_with_elicitation() { - // Test InitializeRequestParam with elicitation capability let init_param = InitializeRequestParams::new( ClientCapabilities::builder() .enable_elicitation_with( @@ -1829,7 +1827,7 @@ async fn test_url_elicitation_request_param_serialization() { assert_eq!(url, "https://example.com/verify"); assert_eq!(elicitation_id, "elicit-123"); } - _ => panic!("Expected UrlElicitationParam variant"), + _ => panic!("Expected UrlElicitationParams variant"), } } @@ -1878,7 +1876,7 @@ async fn test_url_elicitation_json_rpc_protocol() { assert_eq!(url, "https://auth.example.com/authorize/abc123"); assert_eq!(elicitation_id, "auth-request-456"); } - _ => panic!("Expected UrlElicitationParam variant"), + _ => panic!("Expected UrlElicitationParams variant"), } } @@ -1927,7 +1925,6 @@ async fn test_url_elicitation_capability() { /// Test backward compatibility: ElicitRequestParams without mode tag #[tokio::test] async fn test_elicitation_backward_compatibility_no_mode() { - // JSON without "mode" field should deserialize as FormElicitationParam let json_without_mode = json!({ "message": "Please enter your details", "requestedSchema": { @@ -1953,7 +1950,7 @@ async fn test_elicitation_backward_compatibility_no_mode() { assert_eq!(requested_schema.properties.len(), 1); assert!(requested_schema.properties.contains_key("name")); } - _ => panic!("Expected FormElicitationParam for backward compatibility"), + _ => panic!("Expected FormElicitationParams for backward compatibility"), } } From ce28e6d32314d422b8df1f09ff3f0445816745c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:39:54 -0400 Subject: [PATCH 285/333] chore(deps): bump actions/stale from 10 to 11 (#1074) Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/v10...v11) --- updated-dependencies: - dependency-name: actions/stale dependency-version: '11' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3c7d1d7fb..58794d517 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,7 +12,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v10 + - uses: actions/stale@v11 with: days-before-stale: 60 days-before-close: 14 From 82a6c48e92fae0887a3062845aa7b8faf2b9fc50 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 12:07:39 -0700 Subject: [PATCH 286/333] fix: preserve transient OAuth discovery HTTP errors (#1071) * fix: preserve transient OAuth discovery HTTP errors * fix: preserve HTTP 425 during OAuth discovery --- crates/rmcp/src/transport/auth.rs | 96 ++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 9 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index ef562ffda..be8733384 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2635,10 +2635,14 @@ impl AuthorizationManager { request: OAuthHttpRequest, ) -> Result { let response = self.http_client.execute(request).await?; - if response.status().is_server_error() { - return Err(Box::new(OAuthHttpError::UnexpectedStatus( - response.status(), - ))); + let status = response.status(); + if status.is_server_error() + || matches!( + status, + StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_EARLY | StatusCode::TOO_MANY_REQUESTS + ) + { + return Err(Box::new(OAuthHttpError::UnexpectedStatus(status))); } Ok(response) } @@ -3820,6 +3824,7 @@ mod tests { }; use oauth2::{AuthType, CsrfToken, HttpResponse, PkceCodeVerifier}; + use reqwest::StatusCode; use rstest::rstest; use url::Url; @@ -3987,6 +3992,74 @@ mod tests { ); } + #[rstest] + #[case::resource_request_timeout(StatusCode::REQUEST_TIMEOUT, 0, "https://mcp.example.com/mcp")] + #[case::resource_too_early(StatusCode::TOO_EARLY, 0, "https://mcp.example.com/mcp")] + #[case::resource_too_many_requests( + StatusCode::TOO_MANY_REQUESTS, + 0, + "https://mcp.example.com/mcp" + )] + #[case::protected_metadata_request_timeout( + StatusCode::REQUEST_TIMEOUT, + 1, + "https://mcp.example.com/.well-known/oauth-protected-resource" + )] + #[case::protected_metadata_too_early( + StatusCode::TOO_EARLY, + 1, + "https://mcp.example.com/.well-known/oauth-protected-resource" + )] + #[case::protected_metadata_too_many_requests( + StatusCode::TOO_MANY_REQUESTS, + 1, + "https://mcp.example.com/.well-known/oauth-protected-resource" + )] + #[case::authorization_request_timeout( + StatusCode::REQUEST_TIMEOUT, + 2, + "https://auth.example.com/.well-known/oauth-authorization-server" + )] + #[case::authorization_too_early( + StatusCode::TOO_EARLY, + 2, + "https://auth.example.com/.well-known/oauth-authorization-server" + )] + #[case::authorization_too_many_requests( + StatusCode::TOO_MANY_REQUESTS, + 2, + "https://auth.example.com/.well-known/oauth-authorization-server" + )] + #[tokio::test] + async fn discovery_propagates_transient_client_errors( + #[case] status: StatusCode, + #[case] successful_response_count: usize, + #[case] expected_url: &str, + ) { + let mut responses = preregistered_discovery_responses(); + responses.insert(successful_response_count, empty_response(status.as_u16())); + + let client = RecordingOAuthHttpClient::with_responses(responses); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert!( + matches!( + error, + AuthError::MetadataError(ref reason) + if reason.contains(expected_url) && reason.contains(status.as_str()) + ), + "unexpected discovery error for {status}: {error}" + ); + assert_eq!(client.requests().len(), successful_response_count + 1); + } + #[tokio::test] async fn custom_http_client_handles_protected_resource_discovery() { let challenge = oauth2::http::Response::builder() @@ -4330,13 +4403,18 @@ mod tests { ); } + #[rstest] + #[case::not_found(StatusCode::NOT_FOUND)] + #[case::method_not_allowed(StatusCode::METHOD_NOT_ALLOWED)] #[tokio::test] - async fn resolve_metadata_reports_legacy_fallback_when_nothing_is_discovered() { + async fn resolve_metadata_reports_legacy_fallback_when_nothing_is_discovered( + #[case] status: StatusCode, + ) { let client = RecordingOAuthHttpClient::with_responses(vec![ - empty_response(404), - empty_response(404), - empty_response(404), - empty_response(404), + empty_response(status.as_u16()), + empty_response(status.as_u16()), + empty_response(status.as_u16()), + empty_response(status.as_u16()), ]); let manager = AuthorizationManager::new_with_oauth_http_client( "https://legacy.example.com/", From b429fc124085e54c1da69dd817cd8420ebeb49c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kone=C4=8Dn=C3=BD?= Date: Tue, 28 Jul 2026 21:08:25 +0200 Subject: [PATCH 287/333] RFC 9728 resource is used instead of base url when possible (#962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transport)!: use declared OAuth resource * fix(transport): validate OAuth resource paths --------- Co-authored-by: Filip Konečný --- crates/rmcp/src/transport/auth.rs | 210 ++++++++++++++++++++++-------- 1 file changed, 159 insertions(+), 51 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index be8733384..c2d1bac75 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1029,6 +1029,8 @@ pub struct AuthorizationManager { www_auth_scopes: RwLock>, /// scopes_supported from protected resource metadata (RFC 9728) resource_scopes: RwLock>, + /// resource indicator from protected resource metadata, used for RFC 8707 `resource` + discovered_resource: RwLock>, /// OIDC Dynamic Client Registration `application_type` (SEP-837) application_type: Option, allow_missing_issuer: bool, @@ -1276,6 +1278,7 @@ impl AuthorizationManager { scope_upgrade_config: ScopeUpgradeConfig::default(), www_auth_scopes: RwLock::new(Vec::new()), resource_scopes: RwLock::new(Vec::new()), + discovered_resource: RwLock::new(None), application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), allow_missing_issuer: false, }; @@ -1745,7 +1748,7 @@ impl AuthorizationManager { let mut auth_request = oauth_client .authorize_url(CsrfToken::new_random) .set_pkce_challenge(pkce_challenge) - .add_extra_param("resource", self.base_url.to_string()); + .add_extra_param("resource", self.oauth_resource().await); // add request scopes for scope in scopes { @@ -1783,6 +1786,14 @@ impl AuthorizationManager { Ok(auth_url.to_string()) } + async fn oauth_resource(&self) -> String { + self.discovered_resource + .read() + .await + .clone() + .unwrap_or_else(|| self.base_url.to_string()) + } + /// get the current granted scopes pub async fn get_current_scopes(&self) -> Vec { self.current_scopes.read().await.clone() @@ -2024,7 +2035,7 @@ impl AuthorizationManager { let token_result = match oauth_client .exchange_code(AuthorizationCode::new(code.to_string())) .set_pkce_verifier(pkce_verifier) - .add_extra_param("resource", self.base_url.to_string()) + .add_extra_param("resource", self.oauth_resource().await) .request_async(&OAuth2HttpClient { client: self.http_client.as_ref(), redirect_policy: OAuthHttpRedirectPolicy::Stop, @@ -2422,6 +2433,11 @@ impl AuthorizationManager { self.validate_resource_metadata_resource(&resource_metadata)?; + self.discovered_resource + .write() + .await + .replace(resource_metadata.resource.clone().unwrap_or_default()); + // store scopes_supported from protected resource metadata for select_scopes() if let Some(scopes) = resource_metadata.scopes_supported && !scopes.is_empty() @@ -2485,9 +2501,21 @@ impl AuthorizationManager { )); }; - if !Self::resource_identifiers_match(self.base_url.as_str(), resource) { + let Ok(resource_url) = Url::parse(resource) else { + return Err(AuthError::MetadataError( + "Protected resource metadata resource field is not a valid URL".to_string(), + )); + }; + + if resource_url.fragment().is_some() { + return Err(AuthError::MetadataError( + "Protected resource metadata resource does not permit fragment in URL as specified by RFC 8707".to_string() + )); + } + + if !Self::is_resource_identifier_valid(&self.base_url, &resource_url) { return Err(AuthError::MetadataError(format!( - "Protected resource metadata resource mismatch: expected '{}', got '{}'", + "Protected resource metadata resource mismatch: reference '{}', permitted '{}'", self.base_url, resource ))); } @@ -2495,33 +2523,31 @@ impl AuthorizationManager { Ok(()) } - fn resource_identifiers_match(expected: &str, actual: &str) -> bool { - expected == actual - || (Self::is_root_resource_identifier(expected) - && actual == expected.trim_end_matches('/')) - || (Self::is_root_resource_identifier(actual) - && expected == actual.trim_end_matches('/')) - || Self::root_resource_identifier_covers_path(actual, expected) - } - - fn is_root_resource_identifier(value: &str) -> bool { - Url::parse(value) - .is_ok_and(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none()) - } + fn is_resource_identifier_valid(expected: &Url, actual: &Url) -> bool { + if expected == actual { + return true; + } - fn root_resource_identifier_covers_path(root_resource: &str, path_resource: &str) -> bool { - let Ok(root_resource) = Url::parse(root_resource) else { - return false; - }; - let Ok(path_resource) = Url::parse(path_resource) else { + if expected.scheme() != actual.scheme() + || expected.host_str() != actual.host_str() + || expected.port_or_known_default() != actual.port_or_known_default() + { return false; - }; + } + + let expected_path = expected.path(); + let actual_path = actual.path(); + + // URL query part supported, even if it is discouraged in RFC 8707 + if expected_path == actual_path && expected.query() == actual.query() { + return true; + } - root_resource.path() == "/" - && root_resource.query().is_none() - && root_resource.fragment().is_none() - && path_resource.path() != "/" - && Self::is_same_origin(&root_resource, &path_resource) + expected_path.starts_with(actual_path) + && expected.query().is_none() + && actual.query().is_none() + && (actual_path.ends_with('/') + || expected_path.as_bytes().get(actual_path.len()) == Some(&b'/')) } async fn discover_resource_metadata_url(&self) -> Result, AuthError> { @@ -4075,7 +4101,7 @@ mod tests { http_response( 200, serde_json::json!({ - "resource": "https://mcp.example.com/mcp", + "resource": "https://mcp.example.com", "authorization_servers": ["https://auth.example.com"] }), ), @@ -4098,6 +4124,10 @@ mod tests { let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!(metadata.token_endpoint, "https://auth.example.com/token"); + assert_eq!( + manager.discovered_resource.read().await.as_deref(), + Some("https://mcp.example.com") + ); assert_eq!( client.requests(), vec![ @@ -5325,35 +5355,55 @@ mod tests { } #[test] - fn resource_identifier_matching_allows_only_root_trailing_slash_difference() { - assert!(AuthorizationManager::resource_identifiers_match( - "https://mcp.example.com/", - "https://mcp.example.com" + fn resource_identifier_matching_allows_matching_host_or_parent_path() { + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/").unwrap(), + &Url::parse("https://mcp.example.com").unwrap() )); - assert!(AuthorizationManager::resource_identifiers_match( - "https://mcp.example.com", - "https://mcp.example.com/" + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com").unwrap(), + &Url::parse("https://mcp.example.com/").unwrap() )); - assert!(AuthorizationManager::resource_identifiers_match( - "https://mcp.example.com/mcp", - "https://mcp.example.com" + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com").unwrap() + )); + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp/tools").unwrap(), + &Url::parse("https://mcp.example.com/mcp").unwrap() + )); + assert!(AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp?query=param").unwrap(), + &Url::parse("https://mcp.example.com/mcp?query=param").unwrap() )); - assert!(!AuthorizationManager::resource_identifiers_match( - "https://mcp.example.com/mcp", - "https://mcp.example.com/mcp/" + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com/mcp/").unwrap() )); - assert!(!AuthorizationManager::resource_identifiers_match( - "https://mcp.example.com/mcp", - "https://real.example.com/mcp" + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp-tools").unwrap(), + &Url::parse("https://mcp.example.com/mcp").unwrap() )); - assert!(!AuthorizationManager::resource_identifiers_match( - "https://mcp.example.com/mcp", - "https://real.example.com" + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com/mcp-tools").unwrap() )); - assert!(!AuthorizationManager::resource_identifiers_match( - "https://mcp.example.com/mcp", - "https://mcp.example.com?resource=mcp" + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com/mcp/tools").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://real.example.com/mcp").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp").unwrap(), + &Url::parse("https://mcp.example.com/mcp?query=value1").unwrap() + )); + assert!(!AuthorizationManager::is_resource_identifier_valid( + &Url::parse("https://mcp.example.com/mcp?query=value1").unwrap(), + &Url::parse("https://mcp.example.com/mcp?query=value2").unwrap() )); } @@ -6313,6 +6363,64 @@ mod tests { assert!(scope.contains("write")); } + #[tokio::test] + async fn authorization_url_uses_discovered_resource() { + let base_url = "https://mcp.example.com/mcp"; + let auth_endpoint = "https://auth.example.com/authorize"; + let mut manager = AuthorizationManager::new(base_url).await.unwrap(); + + let metadata = AuthorizationMetadata { + authorization_endpoint: auth_endpoint.to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: None, + issuer: None, + jwks_uri: None, + scopes_supported: None, + response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), + additional_fields: std::collections::HashMap::new(), + }; + manager.set_metadata(metadata); + manager.configure_client_id("test-client-id").unwrap(); + *manager.discovered_resource.write().await = Some("https://mcp.example.com".to_string()); + + let auth_url = manager.get_authorization_url(&["read"]).await.unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); + + assert_eq!( + params.get("resource").map(|v| v.as_ref()), + Some("https://mcp.example.com") + ); + } + + #[tokio::test] + async fn authorization_url_uses_default_resource_without_protected_resource_document() { + let base_url = "https://mcp.example.com/mcp"; + let auth_endpoint = "https://auth.example.com/authorize"; + let mut manager = AuthorizationManager::new(base_url).await.unwrap(); + + let metadata = AuthorizationMetadata { + authorization_endpoint: auth_endpoint.to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: None, + issuer: None, + jwks_uri: None, + scopes_supported: None, + response_types_supported: Some(vec!["code".to_string()]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), + additional_fields: std::collections::HashMap::new(), + }; + manager.set_metadata(metadata); + manager.configure_client_id("test-client-id").unwrap(); + + let auth_url = manager.get_authorization_url(&["read"]).await.unwrap(); + let parsed = Url::parse(&auth_url).unwrap(); + let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); + + assert_eq!(params.get("resource").map(|v| v.as_ref()), Some(base_url)); + } + #[test] fn authorization_callback_parses_optional_issuer() { let callback = AuthorizationCallback::from_redirect_url( From e54c2220af4e58ad845f1ecea5aa1f6f32c4e33b Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:09:56 -0400 Subject: [PATCH 288/333] docs: prepare for stable 3.0 release (#1073) * docs: prepare for stable 3.0 release * docs: update stable spec code references --- README.md | 42 +++++++++---------- crates/rmcp/README.md | 2 +- crates/rmcp/src/handler/server.rs | 4 +- crates/rmcp/src/model.rs | 16 +++---- crates/rmcp/src/model/meta.rs | 17 ++++---- crates/rmcp/src/transport/auth.rs | 8 ++-- .../transport/streamable_http_server/tower.rs | 4 +- crates/rmcp/tests/test_message_schema.rs | 12 +++--- .../client_json_rpc_message_schema.json | 4 +- ...lient_json_rpc_message_schema_current.json | 4 +- .../server_json_rpc_message_schema.json | 18 ++++---- ...erver_json_rpc_message_schema_current.json | 18 ++++---- docs/OAUTH_SUPPORT.md | 6 +-- 13 files changed, 77 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 0863fc634..dd646404a 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,20 @@ An official Rust Model Context Protocol SDK implementation with tokio async runtime. -> **Migrating to 1.x?** See the [migration guide](https://github.com/modelcontextprotocol/rust-sdk/discussions/716) for breaking changes and upgrade instructions. +> **Migrating to 3.x?** See the [migration guide](https://github.com/modelcontextprotocol/rust-sdk/discussions/969) for breaking changes and upgrade instructions. This repository contains the following crates: - [rmcp](crates/rmcp): The core crate providing the RMCP protocol implementation - see [rmcp](crates/rmcp/README.md) - [rmcp-macros](crates/rmcp-macros): A procedural macro crate for generating RMCP tool implementations - see [rmcp-macros](crates/rmcp-macros/README.md) -This SDK tracks the MCP **`2026-07-28`** draft (the current development spec) -while remaining fully compatible with the stable **`2025-11-25`** release and -earlier versions. New `2026-07-28` features — server discovery & negotiation, +This SDK implements the stable MCP **`2026-07-28`** specification while +remaining fully compatible with the **`2025-11-25`** release and earlier +versions. Features introduced in `2026-07-28` — server discovery & negotiation, transport-neutral subscriptions, long-running tasks, response caching, multi-round-trip requests, and standard HTTP routing headers — are documented -below alongside the stable feature set. For the full MCP specification, see -[modelcontextprotocol.io](https://modelcontextprotocol.io/specification/draft). +below. For the full MCP specification, see +[modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2026-07-28). ## Table of Contents @@ -185,7 +185,7 @@ let quit_reason = server.cancel().await?; Tools let servers expose callable functions to clients. Each tool has a name, description, and a JSON Schema for its parameters. Clients discover tools via `list_tools` and invoke them via `call_tool`. -**MCP Spec:** [Tools](https://modelcontextprotocol.io/specification/draft/server/tools) +**MCP Spec:** [Tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) ### Server-side @@ -274,7 +274,7 @@ let result = client.call_tool(CallToolRequestParams::new("add")).await?; Resources let servers expose data (files, database records, API responses) that clients can read. Each resource is identified by a URI and returns content as text or binary (base64-encoded) data. Resource templates allow servers to declare URI patterns with dynamic parameters. -**MCP Spec:** [Resources](https://modelcontextprotocol.io/specification/draft/server/resources) +**MCP Spec:** [Resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources) ### Server-side @@ -409,7 +409,7 @@ impl ClientHandler for MyClient { Prompts are reusable message templates that servers expose to clients. They accept typed arguments and return conversation messages. The `#[prompt]` macro handles argument validation and routing automatically. -**MCP Spec:** [Prompts](https://modelcontextprotocol.io/specification/draft/server/prompts) +**MCP Spec:** [Prompts](https://modelcontextprotocol.io/specification/2026-07-28/server/prompts) ### Server-side @@ -523,7 +523,7 @@ context.peer.notify_prompt_list_changed().await?; Sampling flips the usual direction: the server asks the client to run an LLM completion. The server sends a `create_message` request, the client processes it through its LLM, and returns the result. -**MCP Spec:** [Sampling](https://modelcontextprotocol.io/specification/draft/client/sampling) +**MCP Spec:** [Sampling](https://modelcontextprotocol.io/specification/2026-07-28/client/sampling) ### Server-side (requesting sampling) @@ -595,7 +595,7 @@ impl ClientHandler for MyClient { Roots tell servers which directories or projects the client is working in. A root is a URI (typically `file://`) pointing to a workspace or repository. Servers can query roots to know where to look for files and how to scope their work. -**MCP Spec:** [Roots](https://modelcontextprotocol.io/specification/draft/client/roots) +**MCP Spec:** [Roots](https://modelcontextprotocol.io/specification/2026-07-28/client/roots) ### Server-side @@ -660,7 +660,7 @@ client.notify_roots_list_changed().await?; Servers can send structured log messages to clients. The client sets a minimum severity level, and the server sends messages through the peer notification interface. -**MCP Spec:** [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging) +**MCP Spec:** [Logging](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging) ### Server-side @@ -733,7 +733,7 @@ client.set_level(SetLevelRequestParams::new(LoggingLevel::Warning)).await?; Completions give auto-completion suggestions for prompt or resource template arguments. As a user fills in arguments, the client can ask the server for suggestions based on what's already been entered. -**MCP Spec:** [Completions](https://modelcontextprotocol.io/specification/draft/server/utilities/completion) +**MCP Spec:** [Completions](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion) ### Server-side @@ -815,7 +815,7 @@ let result = client.complete(CompleteRequestParams::new( Notifications are fire-and-forget messages -- no response is expected. They cover progress updates, cancellation, and lifecycle events. Both sides can send and receive them. -**MCP Spec:** [Notifications](https://modelcontextprotocol.io/specification/draft/basic#notifications) +**MCP Spec:** [Notifications](https://modelcontextprotocol.io/specification/2026-07-28/basic#notifications) ### Progress notifications @@ -903,7 +903,7 @@ Protocol `2026-07-28` replaces `resources/subscribe`, `resources/unsubscribe`, a the standalone HTTP GET stream with the transport-neutral, long-lived `subscriptions/listen` request. Each requested notification category is opt-in. -**MCP Spec:** [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions) +**MCP Spec:** [Subscriptions](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions) ### Server-side @@ -995,7 +995,7 @@ one or more embedded server requests (elicitation, sampling, or roots) and then retry. The exchange is stateless — the server carries its progress in an opaque `requestState` that the client echoes back verbatim. -**MCP Spec:** [Multiple Round-Trip Requests](https://modelcontextprotocol.io/specification/draft/server/tools#multiple-round-trip-requests) +**MCP Spec:** [Multiple Round-Trip Requests](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#multiple-round-trip-requests) ### Server-side @@ -1098,7 +1098,7 @@ See [`servers_task_stdio`](examples/servers/src/task_stdio.rs) and the matching ## Caching `rmcp` clients transparently cache responses that carry the -[SEP-2549](https://modelcontextprotocol.io/specification/draft/server/utilities/caching) +[SEP-2549](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching) caching hints (`ttlMs` / `cacheScope`) for `server/discover`, `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`. @@ -1150,7 +1150,7 @@ validates these automatically once a connection negotiates `2026-07-28` or newer — no call-site changes are required, and older negotiated versions are untouched. -**MCP Spec:** [Header standardization](https://modelcontextprotocol.io/specification/draft/basic/transports#header) +**MCP Spec:** [Header standardization](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#header) - `Mcp-Method` — the JSON-RPC method (e.g. `tools/call`). - `Mcp-Name` — the target name, sourced from `params.name` (`tools/call`, @@ -1186,7 +1186,7 @@ no `Mcp-Session-Id`, no standalone GET/DELETE stream, and no `Last-Event-ID` resumption. The `legacy_session_mode` flag below only controls behavior for *legacy* protocol versions (`< 2026-07-28`). -**MCP Spec:** [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) +**MCP Spec:** [Transports](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) ### Server-side @@ -1249,8 +1249,8 @@ See [Oauth_support](docs/OAUTH_SUPPORT.md) for details. ## Related Resources -- [MCP Specification](https://modelcontextprotocol.io/specification/draft) -- [Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) +- [MCP Specification](https://modelcontextprotocol.io/specification/2026-07-28) +- [Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts) ## Related Projects diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index 742b819e3..bb7837e84 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -11,7 +11,7 @@ -The official Rust SDK for the [Model Context Protocol](https://modelcontextprotocol.io/specification/draft). Build MCP servers that expose tools, resources, and prompts to AI assistants — or build clients that connect to them. +The official Rust SDK for the [Model Context Protocol](https://modelcontextprotocol.io/specification/2026-07-28). Build MCP servers that expose tools, resources, and prompts to AI assistants — or build clients that connect to them. For **getting started**, **usage guides**, and **full MCP feature documentation** (resources, prompts, sampling, roots, logging, completions, subscriptions, etc.), see the [main README](../../README.md). diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 39b89da00..415c1a3ba 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -171,7 +171,7 @@ impl Service for H { let subscription_id = context.id.clone(); let subscription = SubscriptionContext::establish(context, requested, accepted).await?; - // The integrated draft schema defines a final result for graceful + // The 2026-07-28 schema defines a final result for graceful // server teardown; explicit stdio cancellation remains a notification. self.listen(subscription).await.map(|()| { ServerResult::SubscriptionsListenResult( @@ -405,7 +405,7 @@ macro_rules! server_handler_methods { /// /// The SDK sends the acknowledgment before invoking this method. Returning /// `Ok(())` sends the final [`SubscriptionsListenResult`] defined by the - /// integrated draft schema, marking graceful server teardown. Explicit + /// 2026-07-28 schema, marking graceful server teardown. Explicit /// stdio cancellation uses `notifications/cancelled` instead. fn listen( &self, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index bff0342fb..08890fab2 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -503,9 +503,9 @@ pub struct JsonRpcResponse { #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct JsonRpcError { pub jsonrpc: JsonRpcVersion2_0, - // MCP 2025-11-25 §Error Responses: `id` is optional and omitted when the + // MCP 2026-07-28 §Error Responses: `id` is optional and omitted when the // server cannot read the request id (e.g. parse error / invalid request). - // https://modelcontextprotocol.io/specification/2025-11-25/basic#error-responses + // https://modelcontextprotocol.io/specification/2026-07-28/basic#error-responses #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, pub error: ErrorData, @@ -1534,7 +1534,7 @@ macro_rules! paginated_result { /// the server handler clears the field when responding to peers that /// negotiated an older version. /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 #[serde(default, skip_serializing_if = "Option::is_none")] pub result_type: Option, #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] @@ -1688,7 +1688,7 @@ pub struct ReadResourceResult { /// the server handler clears the field when responding to peers that /// negotiated an older version. /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 #[serde(default, skip_serializing_if = "Option::is_none")] pub result_type: Option, /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). @@ -2046,7 +2046,7 @@ fn subscriptions_listen_request_meta_schema( #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct SubscriptionsListenRequestParams { - /// Protocol-level metadata. Required by the draft wire schema. + /// Protocol-level metadata. Required by the 2026-07-28 wire schema. #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] #[cfg_attr( feature = "schemars", @@ -3262,7 +3262,7 @@ pub struct CompleteResult { /// the server handler clears the field when responding to peers that /// negotiated an older version. /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 #[serde(default, skip_serializing_if = "Option::is_none")] pub result_type: Option, pub completion: CompletionInfo, @@ -3721,7 +3721,7 @@ pub struct CallToolResult { /// the server handler clears the field when responding to peers that /// negotiated an older version. /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 #[serde(default, skip_serializing_if = "Option::is_none")] pub result_type: Option, /// The content returned by the tool (text, images, etc.) @@ -4105,7 +4105,7 @@ pub struct GetPromptResult { /// the server handler clears the field when responding to peers that /// negotiated an older version. /// - /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235 #[serde(default, skip_serializing_if = "Option::is_none")] pub result_type: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index d80064399..e779593fe 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -358,7 +358,7 @@ impl schemars::JsonSchema for MetaObject { fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { schemars::json_schema!({ - "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", "type": "object", "additionalProperties": true, }) @@ -374,10 +374,9 @@ impl schemars::JsonSchema for MetaObject { /// - `io.modelcontextprotocol/clientCapabilities` (SEP-2575) /// - `io.modelcontextprotocol/logLevel` (SEP-2575) /// -/// The 2026-07-28 draft schema marks the protocol-version, client-info, and -/// client-capabilities keys as required; earlier protocol versions do not know -/// them. All keys therefore stay optional at runtime and in the generated -/// (version-shared) JSON schema — use +/// The 2026-07-28 schema defines required per-request metadata; earlier +/// protocol versions do not know these keys. All keys therefore stay optional +/// at runtime and in the generated (version-shared) JSON schema — use /// [`RequestMetaObject::missing_required_keys`] to validate a request against /// the negotiated protocol version. /// @@ -396,7 +395,7 @@ impl RequestMetaObject { const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; - /// Request `_meta` keys the 2026-07-28 draft schema marks as required. + /// Request `_meta` keys validated for the 2026-07-28 protocol. pub const DRAFT_REQUIRED_KEYS: [&str; 3] = [ Self::META_KEY_PROTOCOL_VERSION, Self::META_KEY_CLIENT_INFO, @@ -510,7 +509,7 @@ impl RequestMetaObject { /// meta.missing_required_keys(&ProtocolVersion::V_2025_11_25) /// .is_empty() /// ); - /// // The 2026-07-28 draft requires the SEP-2575 keys. + /// // The 2026-07-28 protocol requires per-request context. /// assert_eq!( /// meta.missing_required_keys(&ProtocolVersion::V_2026_07_28), /// RequestMetaObject::DRAFT_REQUIRED_KEYS.to_vec(), @@ -577,9 +576,9 @@ impl schemars::JsonSchema for RequestMetaObject { let client_capabilities = generator.subschema_for::(); let log_level = generator.subschema_for::(); // rmcp generates one schema shared by every supported protocol - // version, so the keys the 2026-07-28 draft marks as required are left + // version, so the keys validated for 2026-07-28 are left // optional here: a 2025-11-25 request whose `_meta` only carries - // `progressToken` is valid. Draft-strict validation is available at + // `progressToken` is valid. Version-specific validation is available at // runtime via [`RequestMetaObject::missing_required_keys`]. schemars::json_schema!({ "description": "Metadata reserved by MCP on requests. Extension keys are also allowed.", diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index c2d1bac75..957a5fdc6 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -607,7 +607,7 @@ pub enum AuthorizationMetadataSource { /// [Newer MCP revisions] require metadata discovery and do not define an /// endpoint-synthesis fallback. /// - /// [Newer MCP revisions]: https://modelcontextprotocol.io/specification/draft/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements + /// [Newer MCP revisions]: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements LegacyEndpointFallback, } @@ -705,7 +705,7 @@ impl OAuthClientConfig { /// Declarative description of the client identity material available for an /// authorization flow. /// -/// The [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) +/// The [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) /// recommends that clients obtain a client ID using the following priority /// order. [`OAuthState::start_authorization`] and [`AuthorizationSession::new`] /// apply it internally: @@ -3245,7 +3245,7 @@ pub struct AuthorizationSession { impl AuthorizationSession { /// Create a new authorization session, selecting a client registration - /// mechanism per the [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) + /// mechanism per the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) /// priority order: /// /// 1. Pre-registered client information @@ -3600,7 +3600,7 @@ impl OAuthState { /// Start authorization. /// /// Selects a client registration mechanism from the identity material in - /// `request`, following the [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) + /// `request`, following the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) /// priority order: /// /// 1. Pre-registered client information diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index f66235ce8..36e9c6b98 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -63,7 +63,7 @@ pub struct StreamableHttpServerConfig { /// When enabled, SSE priming events are sent to enable client reconnection. /// /// Only applies to legacy protocol versions (`< 2026-07-28`). Per SEP-2567, - /// sessions are removed from the `2026-07-28` draft version, so requests + /// sessions are removed from the `2026-07-28` version, so requests /// negotiating that version are always served statelessly regardless of /// this setting. pub legacy_session_mode: bool, @@ -745,7 +745,7 @@ fn validate_origin_header( /// # Streamable HTTP server /// /// An HTTP service that implements the -/// [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) +/// [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) /// for MCP servers. /// /// ## Session management diff --git a/crates/rmcp/tests/test_message_schema.rs b/crates/rmcp/tests/test_message_schema.rs index 2b0990d71..867b9ef6a 100644 --- a/crates/rmcp/tests/test_message_schema.rs +++ b/crates/rmcp/tests/test_message_schema.rs @@ -61,15 +61,15 @@ mod tests { ); } - /// The three metadata definitions must expose the MCP 2026-07-28 draft + /// The three metadata definitions must expose the MCP 2026-07-28 /// vocabulary: `MetaObject` is an open map, `RequestMetaObject` reserves /// `progressToken` plus the SEP-2575 keys, and `NotificationMetaObject` - /// reserves `io.modelcontextprotocol/subscriptionId`. The keys the draft - /// marks as required stay optional because rmcp generates one schema - /// shared by every supported protocol version; draft-strict validation is + /// reserves `io.modelcontextprotocol/subscriptionId`. Version-specific + /// required keys stay optional because rmcp generates one schema shared + /// by every supported protocol version; current-version validation is /// a runtime concern (`RequestMetaObject::missing_required_keys`). #[test] - fn test_metadata_definitions_match_draft_schema() { + fn test_metadata_definitions_match_2026_07_28_schema() { let settings = SchemaSettings::draft07(); let schema = settings .into_generator() @@ -80,7 +80,7 @@ mod tests { assert_eq!( definitions["MetaObject"], serde_json::json!({ - "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", "type": "object", "additionalProperties": true, }) diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index fe2eb5e70..fab0954c8 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -1206,7 +1206,7 @@ ] }, "MetaObject": { - "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", "type": "object", "additionalProperties": true }, @@ -2233,7 +2233,7 @@ "type": "object", "properties": { "_meta": { - "description": "Protocol-level metadata. Required by the draft wire schema.", + "description": "Protocol-level metadata. Required by the 2026-07-28 wire schema.", "type": "object", "properties": { "io.modelcontextprotocol/clientCapabilities": { diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index fe2eb5e70..fab0954c8 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -1206,7 +1206,7 @@ ] }, "MetaObject": { - "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", "type": "object", "additionalProperties": true }, @@ -2233,7 +2233,7 @@ "type": "object", "properties": { "_meta": { - "description": "Protocol-level metadata. Required by the draft wire schema.", + "description": "Protocol-level metadata. Required by the 2026-07-28 wire schema.", "type": "object", "properties": { "io.modelcontextprotocol/clientCapabilities": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 595281eca..a254b9802 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -199,7 +199,7 @@ ] }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -328,7 +328,7 @@ "$ref": "#/definitions/CompletionInfo" }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1144,7 +1144,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1789,7 +1789,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1850,7 +1850,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1911,7 +1911,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1971,7 +1971,7 @@ ] }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -2061,7 +2061,7 @@ ] }, "MetaObject": { - "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", "type": "object", "additionalProperties": true }, @@ -2595,7 +2595,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 595281eca..a254b9802 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -199,7 +199,7 @@ ] }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -328,7 +328,7 @@ "$ref": "#/definitions/CompletionInfo" }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1144,7 +1144,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1789,7 +1789,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1850,7 +1850,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1911,7 +1911,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -1971,7 +1971,7 @@ ] }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" @@ -2061,7 +2061,7 @@ ] }, "MetaObject": { - "description": "See [specification/draft/basic/index#general-fields] for notes on _meta usage.", + "description": "See [MCP general fields](https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields) for notes on _meta usage.", "type": "object", "additionalProperties": true }, @@ -2595,7 +2595,7 @@ } }, "resultType": { - "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/271ecc9accafdd9b83a3c869fa67c22953b2af80/schema/2026-07-28/schema.ts#L219-L235", "anyOf": [ { "$ref": "#/definitions/ResultType" diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index b35478739..cdd91294a 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -1,6 +1,6 @@ # Model Context Protocol OAuth Authorization -This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/). +This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/). ## Features @@ -160,7 +160,7 @@ distinguish server-published metadata from synthesized metadata. The `OAuthState` state machine manages the full authorization lifecycle. `start_authorization` accepts an `AuthorizationRequest` describing the client identity material you have available, and selects a client registration -mechanism following the [spec's priority order](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration): +mechanism following the [spec's priority order](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration): 1. **Pre-registered client information** (`with_preregistered_client`), when the client already holds a `client_id` issued out of band @@ -331,7 +331,7 @@ If you encounter authorization issues, check the following: ## References -- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/) +- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/) - [OAuth 2.1 Specification Draft](https://oauth.net/2.1/) - [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) - [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591) From 9a41811a66883c9652bd2293560710e3204f92f7 Mon Sep 17 00:00:00 2001 From: John Howard Date: Tue, 28 Jul 2026 14:55:40 -0500 Subject: [PATCH 289/333] fix!: remove server_info from DiscoverResult (#1065) Fixes https://github.com/modelcontextprotocol/rust-sdk/issues/1064 Replaces https://github.com/modelcontextprotocol/rust-sdk/pull/1044 This removes the `server_info` field which is not part of the spec: https://modelcontextprotocol.io/specification/draft/schema#discoverresult. --- .github/workflows/conformance.yml | 2 +- conformance/expected-failures-extensions.yaml | 3 + crates/rmcp/src/model.rs | 174 ++++++++++++------ crates/rmcp/src/model/meta.rs | 16 +- crates/rmcp/src/service/client.rs | 28 ++- .../rmcp/tests/test_client_initialization.rs | 8 + .../rmcp/tests/test_client_lifecycle_modes.rs | 152 +++++++++++++-- crates/rmcp/tests/test_message_schema.rs | 7 + .../client_json_rpc_message_schema.json | 1 - ...lient_json_rpc_message_schema_current.json | 1 - .../server_json_rpc_message_schema.json | 9 - ...erver_json_rpc_message_schema_current.json | 9 - crates/rmcp/tests/test_mrtr_behavior.rs | 4 +- crates/rmcp/tests/test_server_discover.rs | 49 +++-- .../rmcp/tests/test_server_discover_client.rs | 4 +- .../rmcp/tests/test_server_discover_http.rs | 40 +++- crates/rmcp/tests/test_subscriptions.rs | 10 +- crates/rmcp/tests/test_subscriptions_model.rs | 1 - examples/clients/src/progress_client.rs | 10 +- 19 files changed, 373 insertions(+), 155 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index dc5de752a..ced1875d8 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: true env: - CONFORMANCE_VERSION: "0.2.0-alpha.9" + CONFORMANCE_VERSION: "0.2.0-alpha.10" jobs: server: diff --git a/conformance/expected-failures-extensions.yaml b/conformance/expected-failures-extensions.yaml index 8400079b0..0ebbeeff1 100644 --- a/conformance/expected-failures-extensions.yaml +++ b/conformance/expected-failures-extensions.yaml @@ -21,3 +21,6 @@ server: [] client: # Informational OAuth extension scenarios. - auth/enterprise-managed-authorization + - auth/dpop + - auth/dpop-nonce + - auth/wif-jwt-bearer diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 08890fab2..14aa31d2a 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1085,6 +1085,66 @@ impl InitializeResult { pub type ServerInfo = InitializeResult; pub type ClientInfo = InitializeRequestParams; +/// Information negotiated about a server peer. +/// +/// Unlike [`InitializeResult`], the server implementation identity is optional +/// because discovery responses are not required to provide it. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ServerPeerInfo { + /// The negotiated MCP protocol version. + pub protocol_version: ProtocolVersion, + /// The capabilities this server provides. + pub capabilities: ServerCapabilities, + /// Information about the server implementation, when provided. + #[serde(skip_serializing_if = "Option::is_none")] + pub server_info: Option, + /// Optional human-readable instructions about using this server. + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// Protocol-level response metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ServerPeerInfo { + /// Create peer information without a server implementation identity. + pub fn new(protocol_version: ProtocolVersion, capabilities: ServerCapabilities) -> Self { + Self { + protocol_version, + capabilities, + server_info: None, + instructions: None, + meta: None, + } + } + + /// Set the server implementation identity. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.server_info = Some(server_info); + self + } + + /// Set instructions supplied by the server. + pub fn with_instructions(mut self, instructions: impl Into) -> Self { + self.instructions = Some(instructions.into()); + self + } +} + +impl From for ServerPeerInfo { + fn from(result: InitializeResult) -> Self { + Self { + protocol_version: result.protocol_version, + capabilities: result.capabilities, + server_info: Some(result.server_info), + instructions: result.instructions, + meta: result.meta, + } + } +} + const_string!(DiscoverRequestMethod = "server/discover"); /// Parameters for [`DiscoverRequest`]. @@ -1116,9 +1176,9 @@ impl schemars::JsonSchema for DiscoverRequestParams { pub type DiscoverRequest = Request; /// The server's response to a [`DiscoverRequest`]. -#[derive(Debug, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct DiscoverResult { /// Identifies how the result should be parsed. @@ -1127,8 +1187,6 @@ pub struct DiscoverResult { pub supported_versions: Vec, /// Capabilities provided by this server. pub capabilities: ServerCapabilities, - /// Information about the server implementation. - pub server_info: Implementation, /// Optional guidance for using the server. #[serde(skip_serializing_if = "Option::is_none")] pub instructions: Option, @@ -1141,65 +1199,15 @@ pub struct DiscoverResult { pub meta: Option, } -impl<'de> Deserialize<'de> for DiscoverResult { - fn deserialize<__D>(deserializer: __D) -> Result - where - __D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Helper { - result_type: ResultType, - supported_versions: Vec, - capabilities: ServerCapabilities, - server_info: Option, - instructions: Option, - ttl_ms: u64, - cache_scope: CacheScope, - #[serde(rename = "_meta")] - meta: Option, - } - - let helper = Helper::deserialize(deserializer)?; - let server_info = match helper.server_info { - Some(server_info) => server_info, - None => { - let metadata_server_info = helper - .meta - .as_ref() - .and_then(|metadata| metadata.0.get("io.modelcontextprotocol/serverInfo")) - .ok_or_else(|| serde::de::Error::missing_field("serverInfo"))?; - - serde_json::from_value(metadata_server_info.clone()) - .map_err(serde::de::Error::custom)? - } - }; - - Ok(Self { - result_type: helper.result_type, - supported_versions: helper.supported_versions, - capabilities: helper.capabilities, - server_info, - instructions: helper.instructions, - ttl_ms: helper.ttl_ms, - cache_scope: helper.cache_scope, - meta: helper.meta, - }) - } -} - impl DiscoverResult { + const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo"; + /// Create a non-cacheable private discovery result. - pub fn new( - supported_versions: Vec, - capabilities: ServerCapabilities, - server_info: Implementation, - ) -> Self { + pub fn new(supported_versions: Vec, capabilities: ServerCapabilities) -> Self { Self { result_type: ResultType::COMPLETE, supported_versions, capabilities, - server_info, instructions: None, ttl_ms: 0, cache_scope: CacheScope::Private, @@ -1207,6 +1215,31 @@ impl DiscoverResult { } } + /// Return the server implementation information stored in result metadata. + pub fn server_info(&self) -> Option { + self.meta + .as_ref()? + .0 + .get(Self::SERVER_INFO_META_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + } + + /// Store server implementation information in result metadata. + pub fn set_server_info(&mut self, server_info: Implementation) { + let server_info = + serde_json::to_value(server_info).expect("Implementation serialization cannot fail"); + self.meta + .get_or_insert_default() + .0 + .insert(Self::SERVER_INFO_META_KEY.to_owned(), server_info); + } + + /// Store server implementation information in result metadata. + pub fn with_server_info(mut self, server_info: Implementation) -> Self { + self.set_server_info(server_info); + self + } + /// Create a discovery result from the server's initialization information. pub fn from_server_info( supported_versions: Vec, @@ -1219,9 +1252,16 @@ impl DiscoverResult { meta, .. } = server_info; - let mut result = Self::new(supported_versions, capabilities, server_info); - result.instructions = instructions; - result.meta = meta; + let mut result = Self { + result_type: ResultType::COMPLETE, + supported_versions, + capabilities, + instructions, + ttl_ms: 0, + cache_scope: CacheScope::Private, + meta, + }; + result.set_server_info(server_info); result } @@ -1238,6 +1278,20 @@ impl DiscoverResult { } } +impl ServerPeerInfo { + /// Create peer information from a discovery result and the selected version. + pub fn from_discover_result(protocol_version: ProtocolVersion, result: DiscoverResult) -> Self { + let server_info = result.server_info(); + Self { + protocol_version, + capabilities: result.capabilities, + server_info, + instructions: result.instructions, + meta: result.meta, + } + } +} + #[allow(clippy::derivable_impls)] impl Default for ServerInfo { fn default() -> Self { diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index e779593fe..0a7121e4a 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -374,9 +374,10 @@ impl schemars::JsonSchema for MetaObject { /// - `io.modelcontextprotocol/clientCapabilities` (SEP-2575) /// - `io.modelcontextprotocol/logLevel` (SEP-2575) /// -/// The 2026-07-28 schema defines required per-request metadata; earlier -/// protocol versions do not know these keys. All keys therefore stay optional -/// at runtime and in the generated (version-shared) JSON schema — use +/// The 2026-07-28 draft schema requires the protocol-version and +/// client-capabilities keys; client-info is optional. Earlier protocol versions +/// do not know them. All keys therefore stay optional at runtime and in the +/// generated (version-shared) JSON schema — use /// [`RequestMetaObject::missing_required_keys`] to validate a request against /// the negotiated protocol version. /// @@ -395,10 +396,9 @@ impl RequestMetaObject { const META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities"; const META_KEY_LOG_LEVEL: &str = "io.modelcontextprotocol/logLevel"; - /// Request `_meta` keys validated for the 2026-07-28 protocol. - pub const DRAFT_REQUIRED_KEYS: [&str; 3] = [ + /// Request `_meta` keys the 2026-07-28 draft schema marks as required. + pub const DRAFT_REQUIRED_KEYS: [&str; 2] = [ Self::META_KEY_PROTOCOL_VERSION, - Self::META_KEY_CLIENT_INFO, Self::META_KEY_CLIENT_CAPABILITIES, ]; @@ -523,9 +523,6 @@ impl RequestMetaObject { if self.protocol_version().is_none() { missing.push(Self::META_KEY_PROTOCOL_VERSION); } - if self.client_info().is_none() { - missing.push(Self::META_KEY_CLIENT_INFO); - } if self.client_capabilities().is_none() { missing.push(Self::META_KEY_CLIENT_CAPABILITIES); } @@ -838,7 +835,6 @@ mod tests { fn treats_malformed_values_as_missing() { let meta: RequestMetaObject = serde_json::from_value(serde_json::json!({ "io.modelcontextprotocol/protocolVersion": 123, - "io.modelcontextprotocol/clientInfo": "not an implementation", "io.modelcontextprotocol/clientCapabilities": null, })) .unwrap(); diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index b23a1496d..9fc1f641a 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -25,7 +25,7 @@ use crate::{ NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, ProtocolVersion, ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Reference, RequestId, RequestMetaObject, RootsListChangedNotification, - ServerInfo, ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, + ServerJsonRpcMessage, ServerNotification, ServerPeerInfo, ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, SubscriptionFilter, SubscriptionsListenRequest, SubscriptionsListenRequestParams, SubscriptionsListenResult, UnsubscribeRequest, UnsubscribeRequestParams, UpdateTaskParams, @@ -213,7 +213,7 @@ impl ServiceRole for RoleClient { type PeerResp = ServerResult; type PeerNot = ServerNotification; type Info = ClientInfo; - type PeerInfo = ServerInfo; + type PeerInfo = ServerPeerInfo; type InitializeError = ClientInitializeError; const IS_CLIENT: bool = true; @@ -776,7 +776,7 @@ where let ServerResult::InitializeResult(initialize_result) = response else { return Err(ClientInitializeError::ExpectedInitResult(Some(response))); }; - peer.set_peer_info(initialize_result); + peer.set_peer_info(initialize_result.into()); // send notification let notification = ClientJsonRpcMessage::notification( @@ -846,13 +846,10 @@ where server_supported: result.supported_versions, }); }; - peer.set_peer_info(ServerInfo { - protocol_version: selected.clone(), - capabilities: result.capabilities, - server_info: result.server_info, - instructions: result.instructions, - meta: result.meta, - }); + peer.set_peer_info(ServerPeerInfo::from_discover_result( + selected.clone(), + result, + )); peer.set_client_request_metadata(ClientRequestMetadata { protocol_version: selected, client_info: client_info.client_info.clone(), @@ -2197,13 +2194,10 @@ mod tests { let peer = disconnected_peer(); let meta = RequestMetaObject::default(); let key = discover_cache_key(); - let expected = DiscoverResult::new( - vec![ProtocolVersion::default()], - Default::default(), - crate::model::Implementation::from_build_env(), - ) - .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::Public); + let expected = DiscoverResult::new(vec![ProtocolVersion::default()], Default::default()) + .with_server_info(crate::model::Implementation::from_build_env()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::Public); peer.cache_response( key, ServerResult::DiscoverResult(expected.clone()), diff --git a/crates/rmcp/tests/test_client_initialization.rs b/crates/rmcp/tests/test_client_initialization.rs index 6c7984c5d..960e1cf53 100644 --- a/crates/rmcp/tests/test_client_initialization.rs +++ b/crates/rmcp/tests/test_client_initialization.rs @@ -51,6 +51,14 @@ async fn client_initialization_accepts_stringified_numeric_response_id() { .serve(client_transport) .await .expect("client should accept stringified initialize response ID"); + assert!( + client + .peer_info() + .expect("peer info should be retained") + .server_info + .is_some(), + "initialize always provides a server implementation identity" + ); client.cancel().await.expect("cancel client"); server_task.await.expect("server task"); } diff --git a/crates/rmcp/tests/test_client_lifecycle_modes.rs b/crates/rmcp/tests/test_client_lifecycle_modes.rs index 375364e88..4b75c3550 100644 --- a/crates/rmcp/tests/test_client_lifecycle_modes.rs +++ b/crates/rmcp/tests/test_client_lifecycle_modes.rs @@ -36,11 +36,13 @@ async fn discover_startup_accepts_stringified_numeric_response_id() { }; server .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - Implementation::new("discover-server", "1.0.0"), - )), + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), RequestId::String(response_id.to_string().into()), )) .await @@ -60,6 +62,110 @@ async fn discover_startup_accepts_stringified_numeric_response_id() { server_task.await.expect("server task"); } +#[tokio::test] +#[allow(deprecated)] +async fn discover_startup_accepts_missing_optional_server_info() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let (rejection_observed_tx, rejection_observed_rx) = tokio::sync::oneshot::channel(); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(discover_request) = + server.receive().await.expect("expected discover request") + else { + panic!("expected discover request"); + }; + let mut result = DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::builder().enable_tools().build(), + ); + result.instructions = Some("discovery instructions".into()); + result.meta = Some(rmcp::model::MetaObject::new()); + result + .meta + .as_mut() + .expect("metadata") + .0 + .insert("example.test/key".into(), serde_json::json!(7)); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(result), + discover_request.id, + )) + .await + .expect("send discover response"); + + server + .send(ServerJsonRpcMessage::request( + rmcp::model::ServerRequest::CreateMessageRequest( + rmcp::model::CreateMessageRequest::new( + rmcp::model::CreateMessageRequestParams::new( + vec![rmcp::model::SamplingMessage::user_text("unsolicited")], + 16, + ), + ), + ), + RequestId::Number(99), + )) + .await + .expect("send unsolicited server request"); + let Some(ClientJsonRpcMessage::Error(error)) = server.receive().await else { + panic!("expected unsolicited server request to be rejected"); + }; + assert_eq!(error.error.code, ErrorCode::INVALID_PARAMS); + rejection_observed_tx + .send(()) + .expect("signal observed rejection"); + + let ClientJsonRpcMessage::Request(request) = + server.receive().await.expect("expected normal request") + else { + panic!("expected normal request"); + }; + assert_eq!( + request.request.get_meta().protocol_version(), + Some(ProtocolVersion::V_2026_07_28) + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(Default::default()), + request.id, + )) + .await + .expect("send list tools response"); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("missing optional server info should not fail discovery"); + let peer_info = client.peer_info().expect("peer info should be retained"); + assert_eq!(peer_info.protocol_version, ProtocolVersion::V_2026_07_28); + assert!(peer_info.capabilities.tools.is_some()); + assert_eq!(peer_info.server_info, None); + assert_eq!( + peer_info.instructions.as_deref(), + Some("discovery instructions") + ); + assert_eq!( + peer_info + .meta + .as_ref() + .and_then(|meta| meta.0.get("example.test/key")), + Some(&serde_json::json!(7)) + ); + rejection_observed_rx + .await + .expect("server should observe association rejection"); + client.list_tools(None).await.expect("list tools"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + #[tokio::test] async fn high_level_server_accepts_discover_startup_without_initialize() { let (server_transport, client_transport) = tokio::io::duplex(4096); @@ -103,11 +209,13 @@ async fn discover_startup_omits_initialize() { server .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - Implementation::new("discover-server", "1.0.0"), - )), + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), request.id, )) .await @@ -267,11 +375,13 @@ async fn discover_startup_retries_a_mutually_supported_version() { ); server .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - Implementation::new("discover-server", "1.0.0"), - )), + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), second.id, )) .await @@ -326,11 +436,13 @@ async fn discover_startup_retries_current_version_once_when_server_reports_it_su ); server .send(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(DiscoverResult::new( - vec![ProtocolVersion::V_2026_07_28], - ServerCapabilities::default(), - Implementation::new("discover-server", "1.0.0"), - )), + ServerResult::DiscoverResult( + DiscoverResult::new( + vec![ProtocolVersion::V_2026_07_28], + ServerCapabilities::default(), + ) + .with_server_info(Implementation::new("discover-server", "1.0.0")), + ), second.id, )) .await diff --git a/crates/rmcp/tests/test_message_schema.rs b/crates/rmcp/tests/test_message_schema.rs index 867b9ef6a..027a2cc86 100644 --- a/crates/rmcp/tests/test_message_schema.rs +++ b/crates/rmcp/tests/test_message_schema.rs @@ -121,6 +121,13 @@ mod tests { let schema = settings .into_generator() .into_root_schema_for::(); + let schema_value = + serde_json::to_value(&schema).expect("Failed to serialize server schema"); + let discover_result = &schema_value["definitions"]["DiscoverResult"]; + assert!( + discover_result["properties"].get("serverInfo").is_none(), + "DiscoverResult serverInfo belongs in namespaced _meta" + ); let schema_str = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema"); compare_schemas( diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index fab0954c8..760d9e18c 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -2255,7 +2255,6 @@ "additionalProperties": true, "required": [ "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/clientCapabilities" ] }, diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index fab0954c8..760d9e18c 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -2255,7 +2255,6 @@ "additionalProperties": true, "required": [ "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/clientCapabilities" ] }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index a254b9802..fb6876ead 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -759,14 +759,6 @@ } ] }, - "serverInfo": { - "description": "Information about the server implementation.", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, "supportedVersions": { "description": "Protocol versions implemented by this server.", "type": "array", @@ -785,7 +777,6 @@ "resultType", "supportedVersions", "capabilities", - "serverInfo", "ttlMs", "cacheScope" ] diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index a254b9802..fb6876ead 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -759,14 +759,6 @@ } ] }, - "serverInfo": { - "description": "Information about the server implementation.", - "allOf": [ - { - "$ref": "#/definitions/Implementation" - } - ] - }, "supportedVersions": { "description": "Protocol versions implemented by this server.", "type": "array", @@ -785,7 +777,6 @@ "resultType", "supportedVersions", "capabilities", - "serverInfo", "ttlMs", "cacheScope" ] diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs index cbdd6ddf4..33332c2b7 100644 --- a/crates/rmcp/tests/test_mrtr_behavior.rs +++ b/crates/rmcp/tests/test_mrtr_behavior.rs @@ -316,7 +316,7 @@ where let client = serve_directly::( MrtrClient, client_transport, - Some(client_peer_info), + Some(client_peer_info.into()), ); let result = body(client).await; @@ -580,7 +580,7 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re let client = serve_directly::( MrtrClient, client_transport, - Some(server_info(ProtocolVersion::V_2026_07_28)), + Some(server_info(ProtocolVersion::V_2026_07_28).into()), ); let result = client diff --git a/crates/rmcp/tests/test_server_discover.rs b/crates/rmcp/tests/test_server_discover.rs index 0d4a037b2..f2ca3df20 100644 --- a/crates/rmcp/tests/test_server_discover.rs +++ b/crates/rmcp/tests/test_server_discover.rs @@ -103,8 +103,9 @@ fn discover_result_accepts_server_info_in_namespaced_metadata() { panic!("expected discovery response, not a tool-call result"); }; - assert_eq!(result.server_info.name, "conformance-mock-server"); - assert_eq!(result.server_info.version, "1.0.0"); + let server_info = result.server_info().expect("server info should be present"); + assert_eq!(server_info.name, "conformance-mock-server"); + assert_eq!(server_info.version, "1.0.0"); let metadata = result.meta.expect("discovery metadata should be preserved"); assert_eq!( @@ -121,25 +122,42 @@ fn discover_result_accepts_server_info_in_namespaced_metadata() { } #[test] -fn discover_result_serializes_top_level_server_info() { - let result = DiscoverResult::new( +fn discover_result_serializes_server_info_in_namespaced_metadata() { + let mut result = DiscoverResult::new( vec![ProtocolVersion::V_2026_07_28], rmcp::model::ServerCapabilities::default(), - rmcp::model::Implementation::new("test-server", "1.0.0"), ); + result.meta = Some(rmcp::model::MetaObject( + json!({ + "io.modelcontextprotocol/serverInfo": { + "name": "stale-server", + "version": "0.1.0" + }, + "unrelated": { "preserved": true } + }) + .as_object() + .expect("metadata is an object") + .clone(), + )); + result.set_server_info(rmcp::model::Implementation::new("test-server", "1.0.0")); let serialized = serde_json::to_value(result).expect("serialize discovery result"); + assert!(serialized.get("serverInfo").is_none()); assert_eq!( - serialized["serverInfo"], + serialized["_meta"]["io.modelcontextprotocol/serverInfo"], json!({ "name": "test-server", "version": "1.0.0" }) ); + assert_eq!( + serialized["_meta"]["unrelated"], + json!({ "preserved": true }) + ); } #[test] -fn discover_result_prefers_top_level_server_info_over_namespaced_metadata() { +fn discover_result_ignores_legacy_top_level_server_info() { let result: DiscoverResult = serde_json::from_value(json!({ "resultType": "complete", "supportedVersions": ["2026-07-28"], @@ -160,8 +178,11 @@ fn discover_result_prefers_top_level_server_info_over_namespaced_metadata() { })) .expect("top-level server info should remain supported"); - assert_eq!(result.server_info.name, "top-level-server"); - assert_eq!(result.server_info.version, "2.0.0"); + let server_info = result + .server_info() + .expect("namespaced server info is present"); + assert_eq!(server_info.name, "metadata-server"); + assert_eq!(server_info.version, "1.0.0"); assert_eq!( result .meta @@ -172,7 +193,7 @@ fn discover_result_prefers_top_level_server_info_over_namespaced_metadata() { } #[test] -fn discover_result_requires_valid_top_level_or_namespaced_server_info() { +fn discover_result_allows_missing_or_malformed_optional_server_info() { let result = json!({ "resultType": "complete", "supportedVersions": ["2026-07-28"], @@ -182,7 +203,9 @@ fn discover_result_requires_valid_top_level_or_namespaced_server_info() { "_meta": { "unrelated": true } }); - assert!(serde_json::from_value::(result).is_err()); + let result = + serde_json::from_value::(result).expect("server info metadata is optional"); + assert_eq!(result.server_info(), None); let malformed_server_info = json!({ "resultType": "complete", @@ -193,7 +216,9 @@ fn discover_result_requires_valid_top_level_or_namespaced_server_info() { "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "missing-version" } } }); - assert!(serde_json::from_value::(malformed_server_info).is_err()); + let result = serde_json::from_value::(malformed_server_info) + .expect("opaque metadata should not prevent deserialization"); + assert_eq!(result.server_info(), None); } #[test] diff --git a/crates/rmcp/tests/test_server_discover_client.rs b/crates/rmcp/tests/test_server_discover_client.rs index adbe577ed..3b309ddca 100644 --- a/crates/rmcp/tests/test_server_discover_client.rs +++ b/crates/rmcp/tests/test_server_discover_client.rs @@ -70,8 +70,8 @@ async fn client_discover_helper_returns_typed_result() { .expect("discover should succeed"); assert_eq!( - result.server_info, - Implementation::new("discovery-server", "1.0.0") + result.server_info(), + Some(Implementation::new("discovery-server", "1.0.0")) ); client.cancel().await.expect("client should cancel"); } diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs index 3dbafb1db..7a42f06a0 100644 --- a/crates/rmcp/tests/test_server_discover_http.rs +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -122,9 +122,11 @@ async fn discover_returns_server_metadata_without_session() { "resultType": "complete", "supportedVersions": ["2025-11-25"], "capabilities": { "tools": {} }, - "serverInfo": { - "name": "discovery-server", - "version": "1.0.0" + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "discovery-server", + "version": "1.0.0" + } }, "instructions": "Use the tools carefully", "ttlMs": 0, @@ -327,6 +329,38 @@ async fn discover_rejects_missing_client_capabilities() { cancellation_token.cancel(); } +#[tokio::test] +async fn discover_accepts_missing_optional_client_info() { + let (client, url, cancellation_token) = spawn_server(true).await; + let body = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2025-11-25", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2025-11-25") + .json(&body) + .send() + .await + .expect("request should send"); + + assert_eq!(response.status(), 200); + let body: serde_json::Value = response.json().await.expect("response should be JSON"); + assert!(body.get("result").is_some()); + + cancellation_token.cancel(); +} + #[tokio::test] async fn discover_error_uses_http_400_when_sse_is_configured() { let (client, url, cancellation_token) = spawn_server(false).await; diff --git a/crates/rmcp/tests/test_subscriptions.rs b/crates/rmcp/tests/test_subscriptions.rs index 3f33765c5..a268a4b0e 100644 --- a/crates/rmcp/tests/test_subscriptions.rs +++ b/crates/rmcp/tests/test_subscriptions.rs @@ -262,8 +262,8 @@ impl rmcp::service::Service for MalformedAcknowledgmentServer { context: RequestContext, ) -> Result { match request { - ClientRequest::DiscoverRequest(_) => { - Ok(ServerResult::DiscoverResult(DiscoverResult::new( + ClientRequest::DiscoverRequest(_) => Ok(ServerResult::DiscoverResult( + DiscoverResult::new( vec![ProtocolVersion::V_2026_07_28], ServerCapabilities::builder() .enable_tools() @@ -271,9 +271,9 @@ impl rmcp::service::Service for MalformedAcknowledgmentServer { .enable_prompts() .enable_prompts_list_changed() .build(), - Implementation::new("malformed-ack-server", "1.0.0"), - ))) - } + ) + .with_server_info(Implementation::new("malformed-ack-server", "1.0.0")), + )), ClientRequest::SubscriptionsListenRequest(_) => { let mut acknowledgment = SubscriptionsAcknowledgedNotification::new( SubscriptionsAcknowledgedNotificationParams::new( diff --git a/crates/rmcp/tests/test_subscriptions_model.rs b/crates/rmcp/tests/test_subscriptions_model.rs index b0c8e51c3..fe9bbdf42 100644 --- a/crates/rmcp/tests/test_subscriptions_model.rs +++ b/crates/rmcp/tests/test_subscriptions_model.rs @@ -215,7 +215,6 @@ fn subscription_schemas_mark_only_draft_required_fields_as_required() { request_schema["properties"]["_meta"]["required"], json!([ "io.modelcontextprotocol/protocolVersion", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/clientCapabilities" ]) ); diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index 89c48738d..a9f68c139 100644 --- a/examples/clients/src/progress_client.rs +++ b/examples/clients/src/progress_client.rs @@ -163,7 +163,10 @@ async fn test_stdio_transport(records: u32) -> Result<()> { // Initialize let server_info = service.peer_info(); if let Some(info) = server_info { - tracing::info!("Connected to server: {:?}", info.server_info.name); + tracing::info!( + "Connected to server: {:?}", + info.server_info.as_ref().map(|server| &server.name) + ); } // List tools @@ -214,7 +217,10 @@ async fn test_http_transport(http_url: &str, records: u32) -> Result<()> { // Initialize let server_info = client.peer_info(); if let Some(info) = server_info { - tracing::info!("Connected to server: {:?}", info.server_info.name); + tracing::info!( + "Connected to server: {:?}", + info.server_info.as_ref().map(|server| &server.name) + ); } // List tools From 29c068d22bb0032a26524384a7576a0aee17b6ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:14:38 -0400 Subject: [PATCH 290/333] chore: release v3.0.0-beta.5 (#1070) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f2a45e278..5f696dcdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.0.0-beta.4", path = "./crates/rmcp" } -rmcp-macros = { version = "3.0.0-beta.4", path = "./crates/rmcp-macros" } +rmcp = { version = "3.0.0-beta.5", path = "./crates/rmcp" } +rmcp-macros = { version = "3.0.0-beta.5", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.0.0-beta.4" +version = "3.0.0-beta.5" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 28c40f4a1..4e78be6b2 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0-beta.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.4...rmcp-macros-v3.0.0-beta.5) - 2026-07-28 + +### Other + +- prepare for stable 3.0 release ([#1073](https://github.com/modelcontextprotocol/rust-sdk/pull/1073)) + ## [3.0.0-beta.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.1...rmcp-macros-v3.0.0-beta.2) - 2026-07-24 ### Fixed diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index ccbffab29..6c57c0cbc 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0-beta.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.4...rmcp-v3.0.0-beta.5) - 2026-07-28 + +### Fixed + +- [**breaking**] remove server_info from DiscoverResult ([#1065](https://github.com/modelcontextprotocol/rust-sdk/pull/1065)) +- preserve transient OAuth discovery HTTP errors ([#1071](https://github.com/modelcontextprotocol/rust-sdk/pull/1071)) +- [**breaking**] preserve OAuth discovery transport errors ([#1069](https://github.com/modelcontextprotocol/rust-sdk/pull/1069)) +- gate client handler bounds for local ([#1068](https://github.com/modelcontextprotocol/rust-sdk/pull/1068)) + +### Other + +- prepare for stable 3.0 release ([#1073](https://github.com/modelcontextprotocol/rust-sdk/pull/1073)) +- RFC 9728 resource is used instead of base url when possible ([#962](https://github.com/modelcontextprotocol/rust-sdk/pull/962)) +- [**breaking**] remove deprecated v3 APIs ([#1066](https://github.com/modelcontextprotocol/rust-sdk/pull/1066)) + ## [3.0.0-beta.4](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.3...rmcp-v3.0.0-beta.4) - 2026-07-28 ### Fixed From 9334c97f0d6e177546fc33593148aa3f942cb1f8 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:34:00 -0400 Subject: [PATCH 291/333] fix: recognize 2026 MCP methods (#1076) --- crates/rmcp/src/transport/async_rw.rs | 42 ++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/async_rw.rs b/crates/rmcp/src/transport/async_rw.rs index 46d5deaa3..e18fd4fce 100644 --- a/crates/rmcp/src/transport/async_rw.rs +++ b/crates/rmcp/src/transport/async_rw.rs @@ -249,12 +249,13 @@ const UTF8_BOM: &[u8; 3] = b"\xEF\xBB\xBF"; /// Check if a method is a standard MCP method (request, response, or notification). /// This includes both requests and notifications defined in the MCP specification. /// -/// Based on MCP specification 2025-06-18: https://modelcontextprotocol.io/specification/2025-06-18 +/// Based on MCP specification 2026-07-28: https://modelcontextprotocol.io/specification/2026-07-28 fn is_standard_method(method: &str) -> bool { matches!( method, "initialize" | "ping" + | "server/discover" | "prompts/get" | "prompts/list" | "resources/list" @@ -262,9 +263,11 @@ fn is_standard_method(method: &str) -> bool { | "resources/subscribe" | "resources/unsubscribe" | "resources/templates/list" + | "subscriptions/listen" | "tools/call" | "tools/list" | "completion/complete" + | "elicitation/create" | "logging/setLevel" | "roots/list" | "sampling/createMessage" @@ -282,6 +285,7 @@ fn is_standard_notification(method: &str) -> bool { | "notifications/resources/list_changed" | "notifications/resources/updated" | "notifications/roots/list_changed" + | "notifications/subscriptions/acknowledged" | "notifications/tools/list_changed" ) } @@ -582,6 +586,9 @@ mod test { assert!(is_standard_notification("notifications/tools/list_changed")); assert!(is_standard_notification("notifications/message")); assert!(is_standard_notification("notifications/roots/list_changed")); + assert!(is_standard_notification( + "notifications/subscriptions/acknowledged" + )); // Test that non-standard notifications are not recognized assert!(!is_standard_notification("notifications/stderr")); @@ -590,6 +597,39 @@ mod test { assert!(!is_standard_notification("some/other/method")); } + #[test] + fn test_2026_07_28_standard_method_check() { + for method in [ + "elicitation/create", + "server/discover", + "subscriptions/listen", + ] { + assert!(is_standard_method(method), "{method} should be standard"); + } + } + + #[test] + fn test_current_standard_notification_is_not_ignored() { + let method = "notifications/subscriptions/acknowledged"; + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + }); + + assert!(!should_ignore_notification(¬ification, method)); + } + + #[test] + fn test_unknown_notification_is_ignored() { + let method = "notifications/custom"; + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + }); + + assert!(should_ignore_notification(¬ification, method)); + } + #[test] fn test_compatibility_function() { // Test the compatibility function directly From 4e361b715fc70b8a09f0a8aeaedc160712a3472d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:44:03 -0400 Subject: [PATCH 292/333] chore: release v3.0.0 (#1077) * chore: release v3.0.0-beta.6 * chore: release v3.0.0 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Alex Hancock --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 6 ++++++ crates/rmcp/CHANGELOG.md | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5f696dcdc..e1f4fb9f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.0.0-beta.5", path = "./crates/rmcp" } -rmcp-macros = { version = "3.0.0-beta.5", path = "./crates/rmcp-macros" } +rmcp = { version = "3.0.0", path = "./crates/rmcp" } +rmcp-macros = { version = "3.0.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.0.0-beta.5" +version = "3.0.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 4e78be6b2..eebe9dd26 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.5...rmcp-macros-v3.0.0) - 2026-07-28 + +### Other + +- release stable 3.0.0 + ## [3.0.0-beta.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.4...rmcp-macros-v3.0.0-beta.5) - 2026-07-28 ### Other diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 6c57c0cbc..5d3add15e 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.5...rmcp-v3.0.0) - 2026-07-28 + +### Fixed + +- recognize 2026 MCP methods ([#1076](https://github.com/modelcontextprotocol/rust-sdk/pull/1076)) + ## [3.0.0-beta.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.4...rmcp-v3.0.0-beta.5) - 2026-07-28 ### Fixed From cb50ae7890d8a5daacae1a4ad95f395f06733c07 Mon Sep 17 00:00:00 2001 From: mrcs64 Date: Wed, 29 Jul 2026 19:34:33 +0200 Subject: [PATCH 293/333] fix: stamp server info on graceful subscription results (#1078) * fix: stamp server info on graceful subscription results * fix: expose subscription server info metadata --------- Co-authored-by: mrcs64 <9069178+mrcs64@users.noreply.github.com> --- crates/rmcp/src/handler/server.rs | 10 +++-- crates/rmcp/src/model.rs | 43 +++++++++++++------ .../server_json_rpc_message_schema.json | 8 ++++ ...erver_json_rpc_message_schema_current.json | 8 ++++ crates/rmcp/tests/test_subscriptions.rs | 8 ++++ crates/rmcp/tests/test_subscriptions_model.rs | 24 +++++++++-- .../test_subscriptions_streamable_http.rs | 19 +++++--- 7 files changed, 94 insertions(+), 26 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 415c1a3ba..61f414963 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -154,7 +154,8 @@ impl Service for H { McpError::method_not_found::(), ); }; - let advertised = requested.supported_by(&self.get_info().capabilities); + let server_info = self.get_info(); + let advertised = requested.supported_by(&server_info.capabilities); let handler_accepted = requested.intersection(&candidate); let accepted = handler_accepted.intersection(&advertised); if accepted != handler_accepted { @@ -168,15 +169,16 @@ impl Service for H { "subscription filter reduced to advertised server capabilities" ); } + let server_implementation = server_info.server_info; let subscription_id = context.id.clone(); let subscription = SubscriptionContext::establish(context, requested, accepted).await?; // The 2026-07-28 schema defines a final result for graceful // server teardown; explicit stdio cancellation remains a notification. self.listen(subscription).await.map(|()| { - ServerResult::SubscriptionsListenResult( - SubscriptionsListenResult::complete(subscription_id), - ) + let mut result = SubscriptionsListenResult::complete(subscription_id); + result.meta.set_server_info(server_implementation); + ServerResult::SubscriptionsListenResult(result) }) } } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 14aa31d2a..06ed855e1 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1199,9 +1199,20 @@ pub struct DiscoverResult { pub meta: Option, } -impl DiscoverResult { - const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo"; +const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo"; + +fn server_info_from_meta(meta: &MetaObject) -> Option { + meta.get(SERVER_INFO_META_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) +} + +fn set_server_info_on_meta(meta: &mut MetaObject, server_info: Implementation) { + let server_info = + serde_json::to_value(server_info).expect("Implementation serialization cannot fail"); + meta.insert(SERVER_INFO_META_KEY.to_owned(), server_info); +} +impl DiscoverResult { /// Create a non-cacheable private discovery result. pub fn new(supported_versions: Vec, capabilities: ServerCapabilities) -> Self { Self { @@ -1217,21 +1228,12 @@ impl DiscoverResult { /// Return the server implementation information stored in result metadata. pub fn server_info(&self) -> Option { - self.meta - .as_ref()? - .0 - .get(Self::SERVER_INFO_META_KEY) - .and_then(|value| serde_json::from_value(value.clone()).ok()) + server_info_from_meta(self.meta.as_ref()?) } /// Store server implementation information in result metadata. pub fn set_server_info(&mut self, server_info: Implementation) { - let server_info = - serde_json::to_value(server_info).expect("Implementation serialization cannot fail"); - self.meta - .get_or_insert_default() - .0 - .insert(Self::SERVER_INFO_META_KEY.to_owned(), server_info); + set_server_info_on_meta(self.meta.get_or_insert_default(), server_info); } /// Store server implementation information in result metadata. @@ -2174,6 +2176,16 @@ impl SubscriptionsListenResultMeta { subscription_id.into_json_value(), ); } + + /// Return the server implementation information stored in result metadata. + pub fn server_info(&self) -> Option { + server_info_from_meta(&self.0) + } + + /// Store server implementation information in result metadata. + pub fn set_server_info(&mut self, server_info: Implementation) { + set_server_info_on_meta(&mut self.0, server_info); + } } impl<'de> Deserialize<'de> for SubscriptionsListenResultMeta { @@ -2212,9 +2224,14 @@ impl schemars::JsonSchema for SubscriptionsListenResultMeta { fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { let subscription_id = generator.subschema_for::(); + let server_info = generator.subschema_for::(); schemars::json_schema!({ "type": "object", "properties": { + "io.modelcontextprotocol/serverInfo": { + "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", + "allOf": [server_info], + }, "io.modelcontextprotocol/subscriptionId": subscription_id, }, "required": ["io.modelcontextprotocol/subscriptionId"], diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index fb6876ead..ca0ef5611 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -3450,6 +3450,14 @@ "SubscriptionsListenResultMeta": { "type": "object", "properties": { + "io.modelcontextprotocol/serverInfo": { + "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, "io.modelcontextprotocol/subscriptionId": { "$ref": "#/definitions/NumberOrString" } diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index fb6876ead..ca0ef5611 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -3450,6 +3450,14 @@ "SubscriptionsListenResultMeta": { "type": "object", "properties": { + "io.modelcontextprotocol/serverInfo": { + "description": "Identifies the server software producing the response. Servers SHOULD include this field on every response unless specifically configured not to do so.", + "allOf": [ + { + "$ref": "#/definitions/Implementation" + } + ] + }, "io.modelcontextprotocol/subscriptionId": { "$ref": "#/definitions/NumberOrString" } diff --git a/crates/rmcp/tests/test_subscriptions.rs b/crates/rmcp/tests/test_subscriptions.rs index a268a4b0e..8ceef93cd 100644 --- a/crates/rmcp/tests/test_subscriptions.rs +++ b/crates/rmcp/tests/test_subscriptions.rs @@ -51,6 +51,7 @@ impl ServerHandler for ToolsOnlyServer { .enable_tool_list_changed() .build(), ) + .with_server_info(Implementation::new("tools-only-server", "1.0.0")) } fn accepted_subscription_filter( @@ -440,6 +441,13 @@ async fn listen_exposes_acknowledged_filter_and_graceful_result() -> anyhow::Res result.meta.subscription_id().as_ref(), Some(subscription.id()) ); + assert_eq!( + result + .meta + .server_info() + .expect("graceful result should contain valid server info"), + Implementation::new("tools-only-server", "1.0.0") + ); client.cancel().await?; Ok(()) diff --git a/crates/rmcp/tests/test_subscriptions_model.rs b/crates/rmcp/tests/test_subscriptions_model.rs index fe9bbdf42..8d79fb119 100644 --- a/crates/rmcp/tests/test_subscriptions_model.rs +++ b/crates/rmcp/tests/test_subscriptions_model.rs @@ -1,7 +1,7 @@ use rmcp::model::{ - ClientJsonRpcMessage, ClientRequest, GetMeta, JsonRpcNotification, JsonRpcRequest, - NotificationMetaObject, RequestId, RequestMetaObject, ServerJsonRpcMessage, ServerNotification, - SubscriptionFilter, SubscriptionsAcknowledgedNotification, + ClientJsonRpcMessage, ClientRequest, GetMeta, Implementation, JsonRpcNotification, + JsonRpcRequest, NotificationMetaObject, RequestId, RequestMetaObject, ServerJsonRpcMessage, + ServerNotification, SubscriptionFilter, SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequest, SubscriptionsListenRequestParams, SubscriptionsListenResult, SubscriptionsListenResultMeta, }; @@ -155,6 +155,7 @@ fn acknowledged_notification_round_trips_numeric_subscription_id_and_metadata() #[test] fn listen_result_requires_matching_string_subscription_id_and_preserves_metadata() { let mut meta = SubscriptionsListenResultMeta::new(RequestId::String("subscription-2".into())); + meta.set_server_info(Implementation::new("test-server", "1.0.0")); meta.insert("com.example/result".into(), json!({ "reason": "shutdown" })); let result = SubscriptionsListenResult::new(meta); @@ -165,6 +166,10 @@ fn listen_result_requires_matching_string_subscription_id_and_preserves_metadata "resultType": "complete", "_meta": { "io.modelcontextprotocol/subscriptionId": "subscription-2", + "io.modelcontextprotocol/serverInfo": { + "name": "test-server", + "version": "1.0.0", + }, "com.example/result": { "reason": "shutdown", }, @@ -178,6 +183,10 @@ fn listen_result_requires_matching_string_subscription_id_and_preserves_metadata round_trip.meta.subscription_id(), Some(RequestId::String("subscription-2".into())) ); + assert_eq!( + round_trip.meta.server_info(), + Some(Implementation::new("test-server", "1.0.0")) + ); assert_eq!( round_trip.meta.get("com.example/result"), Some(&json!({ "reason": "shutdown" })) @@ -232,5 +241,14 @@ fn subscription_schemas_mark_only_draft_required_fields_as_required() { acknowledgment_schema["properties"]["_meta"]["$ref"], "#/$defs/NotificationMetaObject" ); + let result_meta_schema = &result_schema["$defs"]["SubscriptionsListenResultMeta"]; + assert_eq!( + result_meta_schema["properties"]["io.modelcontextprotocol/serverInfo"]["allOf"][0]["$ref"], + "#/$defs/Implementation" + ); + assert_eq!( + result_meta_schema["required"], + json!(["io.modelcontextprotocol/subscriptionId"]) + ); assert_eq!(result_schema["required"], json!(["resultType", "_meta"])); } diff --git a/crates/rmcp/tests/test_subscriptions_streamable_http.rs b/crates/rmcp/tests/test_subscriptions_streamable_http.rs index 8acfe2873..bfb931371 100644 --- a/crates/rmcp/tests/test_subscriptions_streamable_http.rs +++ b/crates/rmcp/tests/test_subscriptions_streamable_http.rs @@ -18,8 +18,8 @@ use std::{ use rmcp::{ ClientLifecycleMode, ClientServiceExt, ServerHandler, model::{ - ClientInfo, ClientRequest, ListToolsRequest, ProtocolVersion, RequestMetaObject, - ServerCapabilities, ServerInfo, ServerNotification, SubscriptionFilter, + ClientInfo, ClientRequest, Implementation, ListToolsRequest, ProtocolVersion, + RequestMetaObject, ServerCapabilities, ServerInfo, ServerNotification, SubscriptionFilter, }, service::{PeerRequestOptions, SubscriptionContext, SubscriptionEnd}, transport::{ @@ -59,6 +59,7 @@ impl ServerHandler for HttpSubscriptionServer { .enable_tool_list_changed() .build(), ) + .with_server_info(Implementation::new("http-subscription-server", "1.0.0")) } fn accepted_subscription_filter( @@ -217,10 +218,16 @@ async fn modern_http_graceful_close_returns_final_listen_result() -> anyhow::Res assert!(subscription.next().await?.is_some()); assert!(subscription.next().await?.is_none()); - assert!(matches!( - subscription.end(), - Some(SubscriptionEnd::Graceful(_)) - )); + let Some(SubscriptionEnd::Graceful(result)) = subscription.end() else { + panic!("expected graceful final result"); + }; + assert_eq!( + result + .meta + .server_info() + .expect("graceful result should contain valid server info"), + Implementation::new("http-subscription-server", "1.0.0") + ); client.cancel().await?; server_ct.cancel(); From 596a7e1e0d3293f2d15e3918fe329261bc12b797 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:47:19 -0400 Subject: [PATCH 294/333] fix: negotiate stateless initialize versions (#1080) --- .../transport/streamable_http_server/tower.rs | 65 ++++++++++++++-- .../tests/test_stateless_protocol_version.rs | 75 ++++++++++++++++--- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 36e9c6b98..a869bd1b4 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -28,10 +28,13 @@ use crate::{ ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode, ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest, InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, - ProtocolVersion, RequestId, ServerJsonRpcMessage, + ProtocolVersion, RequestId, ServerInfo, ServerJsonRpcMessage, ServerResult, }, serve_server, - service::{serve_directly_with_ct, uses_legacy_lifecycle}, + service::{ + NotificationContext, RequestContext, Service, negotiate_protocol_version, + serve_directly_with_ct, uses_legacy_lifecycle, + }, transport::{ OneshotTransport, TransportAdapterIdentity, common::{ @@ -257,6 +260,49 @@ fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> b } } +struct NegotiatingStatelessHttpService(S); + +impl> Service for NegotiatingStatelessHttpService { + async fn handle_request( + &self, + request: ClientRequest, + context: RequestContext, + ) -> Result { + let requested_protocol_version = + if let ClientRequest::InitializeRequest(initialize) = &request { + Some(initialize.params.protocol_version.clone()) + } else { + None + }; + let peer = context.peer.clone(); + let mut response = self.0.handle_request(request, context).await?; + if let (Some(requested), ServerResult::InitializeResult(result)) = + (requested_protocol_version, &mut response) + { + result.protocol_version = + negotiate_protocol_version(&requested, result.protocol_version.clone()); + if let Some(peer_info) = peer.peer_info() { + let mut peer_info = (*peer_info).clone(); + peer_info.protocol_version = result.protocol_version.clone(); + peer.set_peer_info(peer_info); + } + } + Ok(response) + } + + async fn handle_notification( + &self, + notification: ClientNotification, + context: NotificationContext, + ) -> Result<(), ErrorData> { + self.0.handle_notification(notification, context).await + } + + fn get_info(&self) -> ServerInfo { + self.0.get_info() + } +} + #[expect( clippy::result_large_err, reason = "BoxResponse is intentionally large; matches other handlers in this file" @@ -1044,7 +1090,12 @@ where // disconnect can cancel the in-flight handler (#857), as in the // non-negotiated stateless path below. let request_ct = CancellationToken::new(); - let service = serve_directly_with_ct(service, transport, peer_info, request_ct.clone()); + let service = serve_directly_with_ct( + NegotiatingStatelessHttpService(service), + transport, + peer_info, + request_ct.clone(), + ); tokio::spawn(async move { let _ = service.waiting().await; }); @@ -1789,8 +1840,12 @@ where // unpersisted response can cancel the in-flight handler on // disconnect (#857). let request_ct = CancellationToken::new(); - let service = - serve_directly_with_ct(service, transport, peer_info, request_ct.clone()); + let service = serve_directly_with_ct( + NegotiatingStatelessHttpService(service), + transport, + peer_info, + request_ct.clone(), + ); tokio::spawn(async move { // on service created let _ = service.waiting().await; diff --git a/crates/rmcp/tests/test_stateless_protocol_version.rs b/crates/rmcp/tests/test_stateless_protocol_version.rs index 1804b791b..3923daed7 100644 --- a/crates/rmcp/tests/test_stateless_protocol_version.rs +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -4,30 +4,51 @@ #![cfg(not(feature = "local"))] use rmcp::{ - model::ProtocolVersion, + ErrorData, RoleServer, ServerHandler, + model::{ + InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities, ServerInfo, + }, + service::RequestContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, }; use tokio_util::sync::CancellationToken; -mod common; -use common::calculator::Calculator; +#[derive(Clone)] +struct OverridingInitialize; -fn stateless_json_config() -> StreamableHttpServerConfig { +impl ServerHandler for OverridingInitialize { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::default()) + } + + async fn initialize( + &self, + _request: InitializeRequestParams, + _context: RequestContext, + ) -> Result { + Ok(self.get_info()) + } +} + +fn stateless_sse_config() -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() .with_legacy_session_mode(false) - .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new()) } +fn stateless_json_config() -> StreamableHttpServerConfig { + stateless_sse_config().with_json_response(true) +} + async fn spawn_server( config: StreamableHttpServerConfig, ) -> (reqwest::Client, String, CancellationToken) { let ct = config.cancellation_token.clone(); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(OverridingInitialize), Default::default(), config); let router = axum::Router::new().nest_service("/mcp", service); let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -65,11 +86,27 @@ async fn post_init(client: &reqwest::Client, url: &str, body_version: &str) -> s .await .expect("send request"); assert!(resp.status().is_success(), "HTTP {}", resp.status()); - resp.json().await.expect("parse JSON") + let is_json = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/json")); + if is_json { + resp.json().await.expect("parse JSON") + } else { + let body = resp.text().await.expect("read SSE body"); + let data = body + .lines() + .find_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .filter(|data| !data.is_empty()) + .expect("SSE response contains data"); + serde_json::from_str(data).expect("parse SSE data") + } } #[tokio::test] -async fn stateless_init_echoes_known_version() { +async fn stateless_json_init_echoes_known_versions_when_handler_overrides_initialize() { let (client, url, ct) = spawn_server(stateless_json_config()).await; for version in ProtocolVersion::KNOWN_VERSIONS { @@ -85,14 +122,30 @@ async fn stateless_init_echoes_known_version() { } #[tokio::test] -async fn stateless_init_unknown_version_falls_back_to_latest() { +async fn stateless_sse_init_echoes_known_versions_when_handler_overrides_initialize() { + let (client, url, ct) = spawn_server(stateless_sse_config()).await; + + for version in ProtocolVersion::KNOWN_VERSIONS { + let resp = post_init(&client, &url, version.as_str()).await; + assert_eq!( + resp["result"]["protocolVersion"], + version.as_str(), + "known version {version} should be echoed back" + ); + } + + ct.cancel(); +} + +#[tokio::test] +async fn stateless_json_init_preserves_handler_fallback_for_unknown_version() { let (client, url, ct) = spawn_server(stateless_json_config()).await; let resp = post_init(&client, &url, "1999-01-01").await; assert_eq!( resp["result"]["protocolVersion"], ProtocolVersion::LATEST.as_str(), - "unknown version should fall back to LATEST" + "unknown version should preserve the handler's fallback" ); ct.cancel(); From f3c786706a04fbd2cbeac1b4dea8d80b37a1f900 Mon Sep 17 00:00:00 2001 From: mrcs64 Date: Wed, 29 Jul 2026 22:02:49 +0200 Subject: [PATCH 295/333] fix: return header mismatch for missing protocol header (#1083) Co-authored-by: mrcs64 <9069178+mrcs64@users.noreply.github.com> --- .../transport/streamable_http_server/tower.rs | 4 +-- .../test_streamable_http_protocol_version.rs | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index a869bd1b4..3eb593aab 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -473,9 +473,9 @@ fn validate_request_protocol_version_meta( .get(HEADER_MCP_PROTOCOL_VERSION) .and_then(|value| value.to_str().ok()) else { - return Err(invalid_request_jsonrpc_response( + return Err(header_mismatch_jsonrpc_response( Some(request.id.clone()), - "Invalid Request: request _meta protocolVersion requires MCP-Protocol-Version header", + "request _meta protocolVersion requires MCP-Protocol-Version header", )); }; if header_version != meta_version.as_str() { diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index cf63624ce..c6806c759 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -185,3 +185,35 @@ async fn stateful_rejected_initial_posts_do_not_create_sessions() -> anyhow::Res ct.cancel(); Ok(()) } + +#[tokio::test] +async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("Mcp-Method", "tools/list") + .body(body) + .send() + .await + .expect("send non-initialize request"); + + assert_eq!(response.status(), 400); + + let body: serde_json::Value = response.json().await?; + assert_eq!(body["jsonrpc"], "2.0"); + assert_eq!(body["id"], 1); + assert_eq!(body["error"]["code"], -32020); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("requires MCP-Protocol-Version header")), + "expected missing protocol header message, got: {body}" + ); + + ct.cancel(); + Ok(()) +} From d82c94aa8ede23d7ee8c9caf539a92bd1c057e78 Mon Sep 17 00:00:00 2001 From: thomas Date: Wed, 29 Jul 2026 13:28:47 -0700 Subject: [PATCH 296/333] fix(auth): use discovered resource for token refresh (#1084) --- crates/rmcp/src/transport/auth.rs | 106 +++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 957a5fdc6..6f8987924 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2180,7 +2180,7 @@ impl AuthorizationManager { let mut refresh_request = oauth_client .exchange_refresh_token(&refresh_token_value) // RFC 8707: the resource indicator is required on token requests, including refreshes - .add_extra_param("resource", self.base_url.to_string()); + .add_extra_param("resource", self.oauth_resource().await); let mut refresh_scopes = stored_credentials.granted_scopes; self.add_offline_access_if_supported(&mut refresh_scopes); for scope in refresh_scopes { @@ -4154,6 +4154,110 @@ mod tests { ); } + #[tokio::test] + async fn refresh_token_uses_discovered_protected_resource() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"] + }), + ), + http_response( + 200, + serde_json::json!({ + "access_token": "initial-access-token", + "token_type": "bearer", + "refresh_token": "initial-refresh-token", + "expires_in": 3600 + }), + ), + http_response( + 200, + serde_json::json!({ + "access_token": "refreshed-access-token", + "token_type": "bearer", + "refresh_token": "refreshed-refresh-token", + "expires_in": 3600 + }), + ), + ]); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + manager.configure_client_id("test-client-id").unwrap(); + + let authorization_url = manager.get_authorization_url(&[]).await.unwrap(); + let authorization_params: HashMap = Url::parse(&authorization_url) + .unwrap() + .query_pairs() + .into_owned() + .collect(); + let state = authorization_params.get("state").unwrap(); + + manager + .exchange_code_for_token("authorization-code", state) + .await + .unwrap(); + manager.refresh_token().await.unwrap(); + + let token_requests: Vec> = client + .requests() + .into_iter() + .filter(|request| request.uri == "https://auth.example.com/token") + .map(|request| { + url::form_urlencoded::parse(&request.body) + .into_owned() + .collect() + }) + .collect(); + + assert_eq!(token_requests.len(), 2); + assert_eq!( + token_requests[0].get("grant_type").map(String::as_str), + Some("authorization_code") + ); + assert_eq!( + token_requests[1].get("grant_type").map(String::as_str), + Some("refresh_token") + ); + assert_eq!( + [ + authorization_params.get("resource").map(String::as_str), + token_requests[0].get("resource").map(String::as_str), + token_requests[1].get("resource").map(String::as_str), + ], + [Some("https://mcp.example.com"); 3], + "authorization, code exchange, and refresh must use the discovered resource audience" + ); + } + #[tokio::test] async fn protected_resource_metadata_supports_authorization_server_path_insertion() { let client = RecordingOAuthHttpClient::with_responses(vec![ From 3bb3c8dbce8ff4c1f8c9e91ebe03ca5cd42f3e81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:53:31 -0400 Subject: [PATCH 297/333] chore: release v3.0.1 (#1081) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp/CHANGELOG.md | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e1f4fb9f0..f883663d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.0.0", path = "./crates/rmcp" } -rmcp-macros = { version = "3.0.0", path = "./crates/rmcp-macros" } +rmcp = { version = "3.0.1", path = "./crates/rmcp" } +rmcp-macros = { version = "3.0.1", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.0.0" +version = "3.0.1" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 5d3add15e..e94dd0473 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0...rmcp-v3.0.1) - 2026-07-29 + +### Fixed + +- *(auth)* use discovered resource for token refresh ([#1084](https://github.com/modelcontextprotocol/rust-sdk/pull/1084)) +- return header mismatch for missing protocol header ([#1083](https://github.com/modelcontextprotocol/rust-sdk/pull/1083)) +- negotiate stateless initialize versions ([#1080](https://github.com/modelcontextprotocol/rust-sdk/pull/1080)) +- stamp server info on graceful subscription results ([#1078](https://github.com/modelcontextprotocol/rust-sdk/pull/1078)) + ## [3.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0-beta.5...rmcp-v3.0.0) - 2026-07-28 ### Fixed From 1d0473ba75235fad28425c670d8404094fc60482 Mon Sep 17 00:00:00 2001 From: camillelawrence Date: Wed, 29 Jul 2026 19:59:47 -0400 Subject: [PATCH 298/333] feat: SEP-2260 stream-based enforcement of client receive-side request association (#1055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(service): express SEP-2260 receive-side association as an enum Replaces the has_pending_outbound_request bool on enforce_peer_request_association with PeerRequestAssociation, so a stream-separating transport can report per-request association (#1033). Behavior-preserving: the event loop still passes the coarse signal as Unknown. * feat(transport): record inbound stream origin on streamable HTTP client (#1033) The worker attaches an InboundStreamOrigin extension to each inbound server request: Unassociated for the standalone GET stream, OutboundRequest(id) for a POST's SSE stream. Mirror of the OriginatingRequestId marker used by the server side. * feat(service): enforce SEP-2260 client receive-side check per stream origin (#1033) The event loop maps InboundStreamOrigin plus the in-flight responder pool to PeerRequestAssociation: restricted requests arriving on the standalone GET stream are now rejected with -32602 even while unrelated outbound requests are in flight. * test: end-to-end SEP-2260 stream-based enforcement over streamable HTTP (#1033) * docs: cross-link SEP-2260 stream markers * test: origin marker survives SSE resumption per SEP-2260 (#1033) A POST SSE stream resumed via GET + Last-Event-ID (SEP-1699) reconnects beneath execute_sse_stream, so requests replayed after a resume keep their OutboundRequest origin. Pins the layering invariant: hoisting reconnection above the marker attach point would wrongly reject associated requests with -32602. * docs: trim SEP-2260 comments to spec rationale * docs: align SEP-2577 expect reason with sibling suppressions * test: harden SEP-2260 stream-enforcement e2e (#1033) Detect handler invocation via a channel asserted empty instead of a panic in a spawned task (swallowed, cannot fail the test); surface scripted-server misuse as transport errors rather than panics in the transport task; bound the tail awaits with 5s timeouts. Correct the header comment: a 2026-07-28 server minting a session id is not spec-legal (SEP-2567 removes sessions and the GET endpoint) — the scripted server is deliberately non-conforming, which is the point of receive-side enforcement. * test: adapt SEP-2260 association tests to ServerPeerInfo Rebase onto 3.0.0: RoleClient::PeerInfo is now ServerPeerInfo (#1065) and its constructor takes the protocol version directly. * chore: add 'Copy' macro to new PeerRequestAssociation Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- crates/rmcp/src/service.rs | 127 ++++++- crates/rmcp/src/service/client.rs | 98 +++++- .../src/transport/streamable_http_client.rs | 216 +++++++++++- .../tests/test_sep_2260_stream_enforcement.rs | 309 ++++++++++++++++++ 4 files changed, 734 insertions(+), 16 deletions(-) create mode 100644 crates/rmcp/tests/test_sep_2260_stream_enforcement.rs diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index b1b2c2b7c..f4fa24c07 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -168,12 +168,31 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { fn enforce_peer_request_association( _peer_request: &Self::PeerReq, _peer_info: Option<&Self::PeerInfo>, - _has_pending_outbound_request: bool, + _association: PeerRequestAssociation, ) -> Result<(), McpError> { Ok(()) } } +/// How an inbound peer request relates to this side's in-flight outbound +/// requests (SEP-2260). +/// +/// SEP-2260 defines no wire field for association, so only stream-separating +/// transports (streamable HTTP) can observe it; other transports yield +/// [`Self::Unknown`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +pub enum PeerRequestAssociation { + /// Arrived on the response stream of an in-flight outbound request. + Associated, + /// Arrived on a stream tied to no in-flight outbound request (e.g. the + /// streamable HTTP standalone GET stream). + Unassociated, + /// The transport cannot distinguish streams; only the coarse in-flight + /// signal is available. + Unknown { has_pending_outbound_request: bool }, +} + pub(crate) fn uses_legacy_lifecycle( protocol_version: Option<&ProtocolVersion>, uses_discover_lifecycle: bool, @@ -182,6 +201,25 @@ pub(crate) fn uses_legacy_lifecycle( && protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28) } +pub(crate) fn peer_request_association( + request: &Req, + local_responder_pool: &std::collections::HashMap, +) -> PeerRequestAssociation { + match request.extensions().get::() { + None => PeerRequestAssociation::Unknown { + has_pending_outbound_request: !local_responder_pool.is_empty(), + }, + Some(InboundStreamOrigin::Unassociated) => PeerRequestAssociation::Unassociated, + Some(InboundStreamOrigin::OutboundRequest(id)) => { + if local_responder_pool.contains_key(id) { + PeerRequestAssociation::Associated + } else { + PeerRequestAssociation::Unassociated + } + } + } +} + tokio::task_local! { pub(crate) static ORIGINATING_REQUEST: RequestId; } @@ -205,10 +243,26 @@ pub(crate) fn in_request_handler_scope() -> bool { /// outside a handler they return an `invalid_request` error. The association /// is task-local and does not cross `tokio::spawn`, so use the task manager /// for long-running work. +/// +/// The client receive-side mirror is [`InboundStreamOrigin`]. #[derive(Debug, Clone, PartialEq, Eq)] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct OriginatingRequestId(pub RequestId); +/// Marker in an inbound request's non-serialized [`Extensions`] recording +/// which HTTP response stream it arrived on: the receive-side mirror of +/// [`OriginatingRequestId`]. Never on the wire (SEP-2260 defines no wire +/// field); when absent, the coarse in-flight check applies. +#[derive(Debug, Clone, PartialEq, Eq)] +#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")] +pub enum InboundStreamOrigin { + /// The standalone GET stream, or a POST response stream not tied to an + /// outbound request. + Unassociated, + /// The SSE response stream of the POST that carried this outbound request. + OutboundRequest(RequestId), +} + pub type TxJsonRpcMessage = JsonRpcMessage<::Req, ::Resp, ::Not>; pub type RxJsonRpcMessage = JsonRpcMessage< @@ -1448,7 +1502,7 @@ where if let Err(error) = R::enforce_peer_request_association( &request, peer.peer_info().as_deref(), - !local_responder_pool.is_empty(), + peer_request_association(&request, &local_responder_pool), ) { tracing::warn!(%id, message = %error.message, "rejected peer request"); // send directly: the sink proxy path would drop the @@ -1729,4 +1783,73 @@ mod sep2260_marker_tests { let request = send_and_capture(None).await; assert!(request.extensions().get::().is_none()); } + + #[test] + #[expect( + deprecated, + reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" + )] + fn peer_request_association_maps_stream_origin() { + use std::collections::HashMap; + + use crate::model::{ + CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerRequest, + }; + + fn sampling(origin: Option) -> ServerRequest { + let mut request = CreateMessageRequest::new(CreateMessageRequestParams::new( + vec![SamplingMessage::user_text("hi")], + 16, + )); + if let Some(origin) = origin { + request.extensions.insert(origin); + } + ServerRequest::CreateMessageRequest(request) + } + + let empty: HashMap = HashMap::new(); + let in_flight: HashMap = HashMap::from([(RequestId::Number(7), ())]); + + // No marker (stdio): coarse signal. + assert_eq!( + peer_request_association(&sampling(None), &in_flight), + PeerRequestAssociation::Unknown { + has_pending_outbound_request: true + } + ); + assert_eq!( + peer_request_association(&sampling(None), &empty), + PeerRequestAssociation::Unknown { + has_pending_outbound_request: false + } + ); + // Standalone GET stream: unassociated even with requests in flight. + assert_eq!( + peer_request_association( + &sampling(Some(InboundStreamOrigin::Unassociated)), + &in_flight + ), + PeerRequestAssociation::Unassociated + ); + // Originating POST stream of an in-flight request: associated. + assert_eq!( + peer_request_association( + &sampling(Some(InboundStreamOrigin::OutboundRequest( + RequestId::Number(7) + ))), + &in_flight + ), + PeerRequestAssociation::Associated + ); + // Stream of a request that is no longer in flight: unassociated. + assert_eq!( + peer_request_association( + &sampling(Some(InboundStreamOrigin::OutboundRequest( + RequestId::Number(8) + ))), + &in_flight + ), + PeerRequestAssociation::Unassociated + ); + } } diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 9fc1f641a..114b958ba 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -238,14 +238,13 @@ impl ServiceRole for RoleClient { } } - // SEP-2260: with no outbound request in flight there is nothing the - // server request could be associated with, so reject it. With one in - // flight we cannot tell which request it belongs to (no wire field), so - // we accept — an under-approximation of the spec's SHOULD. + // SEP-2260: reject restricted server requests that arrived unassociated + // with any in-flight outbound request. Without stream separation + // (`Unknown`) the coarse in-flight check under-approximates the SHOULD. fn enforce_peer_request_association( peer_request: &Self::PeerReq, peer_info: Option<&Self::PeerInfo>, - has_pending_outbound_request: bool, + association: PeerRequestAssociation, ) -> Result<(), ErrorData> { let restricted = matches!( peer_request, @@ -258,13 +257,24 @@ impl ServiceRole for RoleClient { } let strict = peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28); - if strict && !has_pending_outbound_request { - return Err(ErrorData::invalid_params( + if !strict { + return Ok(()); + } + let associated = match association { + PeerRequestAssociation::Associated => true, + PeerRequestAssociation::Unassociated => false, + PeerRequestAssociation::Unknown { + has_pending_outbound_request, + } => has_pending_outbound_request, + }; + if associated { + Ok(()) + } else { + Err(ErrorData::invalid_params( "SEP-2260: server-to-client requests must be associated with an in-flight client request", None, - )); + )) } - Ok(()) } async fn invalidate_response_cache(peer: &Peer, notification: &Self::PeerNot) { @@ -2209,3 +2219,73 @@ mod tests { assert_eq!(peer.discover(meta).await.unwrap(), expected); } } + +#[cfg(test)] +mod sep2260_association_tests { + use super::*; + use crate::{ + model::{ + CreateMessageRequest, CreateMessageRequestParams, SamplingMessage, ServerCapabilities, + }, + service::PeerRequestAssociation, + }; + + fn sampling_request() -> ServerRequest { + ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16), + )) + } + + fn server_info(version: ProtocolVersion) -> ServerPeerInfo { + ServerPeerInfo::new(version, ServerCapabilities::default()) + } + + fn enforce( + info: &ServerPeerInfo, + association: PeerRequestAssociation, + ) -> Result<(), ErrorData> { + RoleClient::enforce_peer_request_association(&sampling_request(), Some(info), association) + } + + #[test] + fn strict_rejects_unassociated() { + let info = server_info(ProtocolVersion::V_2026_07_28); + let err = enforce(&info, PeerRequestAssociation::Unassociated).unwrap_err(); + assert_eq!(err.code, crate::model::ErrorCode::INVALID_PARAMS); + } + + #[test] + fn strict_accepts_associated() { + let info = server_info(ProtocolVersion::V_2026_07_28); + assert!(enforce(&info, PeerRequestAssociation::Associated).is_ok()); + } + + #[test] + fn strict_unknown_falls_back_to_coarse_check() { + let info = server_info(ProtocolVersion::V_2026_07_28); + assert!( + enforce( + &info, + PeerRequestAssociation::Unknown { + has_pending_outbound_request: true + } + ) + .is_ok() + ); + assert!( + enforce( + &info, + PeerRequestAssociation::Unknown { + has_pending_outbound_request: false + } + ) + .is_err() + ); + } + + #[test] + fn legacy_protocol_accepts_even_unassociated() { + let info = server_info(ProtocolVersion::V_2025_11_25); + assert!(enforce(&info, PeerRequestAssociation::Unassociated).is_ok()); + } +} diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index c43fb7dd5..fe563ee3f 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -19,10 +19,11 @@ use super::common::client_side_sse::{ use crate::{ RoleClient, model::{ - ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetMeta, + ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, GetMeta, InitializedNotification, JsonObject, ProtocolVersion, RequestId, ServerJsonRpcMessage, ServerResult, }, + service::InboundStreamOrigin, transport::{ common::{client_side_sse::SseAutoReconnectStream, mcp_headers}, worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport}, @@ -635,6 +636,7 @@ impl StreamableHttpClientWorker { + Send + 'static, sse_worker_tx: tokio::sync::mpsc::Sender, + origin: InboundStreamOrigin, close_on_response: bool, ct: CancellationToken, ) -> Result<(), StreamableHttpError> { @@ -649,9 +651,14 @@ impl StreamableHttpClientWorker { break; } }; - let Some(message) = message.transpose()? else { + let Some(mut message) = message.transpose()? else { break; }; + // SEP-2260: mark inbound requests with the stream they arrived on + // for the client receive-side association check. + if let ServerJsonRpcMessage::Request(request) = &mut message { + request.request.extensions_mut().insert(origin.clone()); + } let is_response = matches!( message, ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) @@ -719,6 +726,7 @@ impl StreamableHttpClientWorker { Self::execute_sse_stream( sse_stream, sse_worker_tx, + InboundStreamOrigin::Unassociated, false, transport_task_ct.child_token(), ) @@ -1283,9 +1291,18 @@ impl Worker for StreamableHttpClientWorker { ); } let stream_tx = sse_worker_tx.clone(); + let origin = match &stream_request_id { + Some(id) => { + InboundStreamOrigin::OutboundRequest( + id.clone(), + ) + } + None => InboundStreamOrigin::Unassociated, + }; streams.spawn(async move { let result = Self::execute_sse_stream( - sse_stream, stream_tx, true, stream_ct, + sse_stream, stream_tx, origin, true, + stream_ct, ) .await; (stream_request_id, result) @@ -1342,9 +1359,13 @@ impl Worker for StreamableHttpClientWorker { .insert(request_id.clone(), stream_ct.clone()); } let stream_tx = sse_worker_tx.clone(); + let origin = match &stream_request_id { + Some(id) => InboundStreamOrigin::OutboundRequest(id.clone()), + None => InboundStreamOrigin::Unassociated, + }; streams.spawn(async move { let result = Self::execute_sse_stream( - sse_stream, stream_tx, true, stream_ct, + sse_stream, stream_tx, origin, true, stream_ct, ) .await; (stream_request_id, result) @@ -1769,7 +1790,66 @@ mod tests { use serde_json::json; use super::*; - use crate::model::{ListToolsResult, NumberOrString, ServerResult, Tool}; + use crate::{ + model::{ + GetExtensions, ListToolsResult, NumberOrString, ServerRequest, ServerResult, Tool, + }, + service::InboundStreamOrigin, + }; + + #[expect( + deprecated, + reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" + )] + fn sampling_request_message(id: i64) -> ServerJsonRpcMessage { + use crate::model::{CreateMessageRequest, CreateMessageRequestParams, SamplingMessage}; + ServerJsonRpcMessage::request( + ServerRequest::CreateMessageRequest(CreateMessageRequest::new( + CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16), + )), + NumberOrString::Number(id), + ) + } + + #[tokio::test] + async fn execute_sse_stream_marks_inbound_requests_with_origin() { + for origin in [ + InboundStreamOrigin::Unassociated, + InboundStreamOrigin::OutboundRequest(RequestId::Number(3)), + ] { + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + NumberOrString::Number(1), + ); + let stream = futures::stream::iter([Ok(sampling_request_message(9)), Ok(response)]); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + StreamableHttpClientWorker::::execute_sse_stream( + stream, + tx, + origin.clone(), + false, + CancellationToken::new(), + ) + .await + .expect("stream completes"); + + let ServerJsonRpcMessage::Request(request) = + rx.recv().await.expect("request forwarded") + else { + panic!("expected request first"); + }; + assert_eq!( + request.request.extensions().get::(), + Some(&origin), + "inbound requests must carry their stream origin" + ); + // Responses are correlated by JSON-RPC id; no marker needed or added. + assert!(matches!( + rx.recv().await.expect("response forwarded"), + ServerJsonRpcMessage::Response(_) + )); + } + } type ReconnectAttempt = (Option, Option); @@ -1868,6 +1948,132 @@ mod tests { ); } + #[derive(Clone, Default)] + struct ResumedRequestClient { + reconnects: Arc>>, + } + + impl StreamableHttpClient for ResumedRequestClient { + type Error = std::io::Error; + + async fn post_message( + &self, + _uri: Arc, + _message: ClientJsonRpcMessage, + _session_id: Option>, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + Err(StreamableHttpError::UnexpectedServerResponse( + "unexpected POST".into(), + )) + } + + async fn delete_session( + &self, + _uri: Arc, + _session_id: Arc, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + Ok(()) + } + + async fn get_stream( + &self, + _uri: Arc, + session_id: Option>, + last_event_id: Option, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + self.reconnects + .lock() + .expect("lock reconnects") + .push((session_id.map(|id| id.to_string()), last_event_id)); + let request = sampling_request_message(9); + let response = ServerJsonRpcMessage::response( + ServerResult::ListToolsResult(ListToolsResult::default()), + NumberOrString::Number(1), + ); + // Stay open after the response, like a live connection, so the + // post-response drain in `execute_sse_stream` doesn't trigger + // further reconnects. + Ok(futures::stream::iter([request, response].map(|message| { + Ok(Sse { + event: None, + data: Some(serde_json::to_string(&message).expect("serialize message")), + id: None, + retry: None, + }) + })) + .chain(futures::stream::pending()) + .boxed()) + } + } + + /// SEP-1699 resumes a broken POST SSE stream via GET + Last-Event-ID + /// beneath `execute_sse_stream`, so the SEP-2260 origin marker must span + /// resumes; if reconnection were hoisted above the marker attach point, + /// replayed associated requests would be wrongly rejected with -32602. + #[tokio::test] + async fn resumed_post_stream_requests_keep_outbound_origin() { + let initial = futures::stream::iter([Ok(Sse { + event: None, + data: None, + id: Some("e1".into()), + retry: Some(0), + })]) + .boxed(); + let client = ResumedRequestClient::default(); + let reconnects = client.reconnects.clone(); + let sse_stream = + StreamableHttpClientWorker::::response_sse_to_jsonrpc( + initial, + None, + client, + Arc::from("http://localhost/mcp"), + None, + HashMap::new(), + DEFAULT_MAX_SSE_EVENT_SIZE, + Arc::new(ExponentialBackoff { + max_times: Some(1), + base_duration: Duration::ZERO, + }), + ); + + let origin = InboundStreamOrigin::OutboundRequest(RequestId::Number(3)); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + StreamableHttpClientWorker::::execute_sse_stream( + sse_stream, + tx, + origin.clone(), + true, + CancellationToken::new(), + ) + .await + .expect("stream completes"); + + assert_eq!( + reconnects.lock().expect("lock reconnects").as_slice(), + &[(None, Some("e1".into()))], + "the request must arrive on the resumed connection" + ); + let ServerJsonRpcMessage::Request(request) = rx.recv().await.expect("request forwarded") + else { + panic!("expected request first"); + }; + assert_eq!( + request.request.extensions().get::(), + Some(&origin), + "origin marker must survive SSE resumption" + ); + assert!(matches!( + rx.recv().await.expect("response forwarded"), + ServerJsonRpcMessage::Response(_) + )); + } + fn tool(name: &'static str, annotation: serde_json::Value) -> Tool { let schema = json!({ "type": "object", diff --git a/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs b/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs new file mode 100644 index 000000000..36595e05d --- /dev/null +++ b/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs @@ -0,0 +1,309 @@ +//! SEP-2260 follow-up (#1033): stream-based receive-side enforcement. +//! +//! Scripted streamable HTTP "server": answers a legacy initialize with +//! protocol 2026-07-28 AND a session id. That combination is NOT +//! spec-compliant: the 2026-07-28 revision removes protocol-level sessions +//! and the standalone GET stream (SEP-2567; transports spec: "do not mint +//! or echo session IDs"). Receive-side enforcement (#1033) exists precisely +//! to protect the client from non-conforming servers, and rmcp's client +//! tolerates the session id and opens the standalone GET stream — so this +//! is the reachable path where the client has BOTH a GET stream and strict +//! SEP-2260 enforcement. +#![cfg(all( + feature = "client", + feature = "transport-streamable-http-client", + not(feature = "local") +))] +#![expect( + deprecated, + reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" +)] + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use futures::{StreamExt, stream::BoxStream}; +use http::{HeaderName, HeaderValue}; +use rmcp::{ + ClientHandler, + model::{ + ClientInfo, ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, + ProtocolVersion, SamplingMessage, ServerCapabilities, ServerInfo, ServerJsonRpcMessage, + }, + service::{ClientLifecycleMode, RequestContext, RoleClient, serve_client_with_lifecycle}, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpClientTransport, StreamableHttpClientTransportConfig, + StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use serde_json::{Value, json}; +use sse_stream::{Error as SseError, Sse}; +use tokio::sync::{Mutex, mpsc}; + +fn to_sse(message: Value) -> Result { + Ok(Sse { + event: None, + data: Some(message.to_string()), + id: None, + retry: None, + }) +} + +fn message_stream(rx: mpsc::Receiver) -> BoxStream<'static, Result> { + tokio_stream::wrappers::ReceiverStream::new(rx) + .map(to_sse) + .boxed() +} + +/// Scripted server: initialize -> JSON init result (2026-07-28 + session); +/// first non-initialize request POST -> SSE stream fed by `post_stream`; +/// everything else -> Accepted. Every message the client POSTs is forwarded +/// to `posted`. +#[derive(Clone)] +struct ScriptedServer { + get_stream: Arc>>>, + post_stream: Arc>>>, + posted: mpsc::UnboundedSender, +} + +impl StreamableHttpClient for ScriptedServer { + type Error = std::io::Error; + + async fn post_message( + &self, + _uri: Arc, + message: ClientJsonRpcMessage, + _session_id: Option>, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result> { + let value = serde_json::to_value(&message).expect("serialize client message"); + // Receiver drop is normal at test teardown; never panic in the transport task. + let _ = self.posted.send(value.clone()); + if value["method"] == "initialize" { + let mut info = ServerInfo::new(ServerCapabilities::default()); + info.protocol_version = ProtocolVersion::V_2026_07_28; + let response = ServerJsonRpcMessage::response( + rmcp::model::ServerResult::InitializeResult(info), + serde_json::from_value(value["id"].clone()).expect("request id"), + ); + return Ok(StreamableHttpPostResponse::Json( + response, + Some("scripted-session".into()), + )); + } + if matches!(message, ClientJsonRpcMessage::Request(_)) { + // Fail as a transport error rather than panicking: this code runs + // in the transport task, where a panic is swallowed and shows up + // only as an opaque timeout in the test. + let rx = self.post_stream.lock().await.take().ok_or_else(|| { + StreamableHttpError::Client(std::io::Error::other( + "scripted server expects exactly one non-initialize request POST", + )) + })?; + return Ok(StreamableHttpPostResponse::Sse(message_stream(rx), None)); + } + Ok(StreamableHttpPostResponse::Accepted) + } + + async fn delete_session( + &self, + _uri: Arc, + _session_id: Arc, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + Ok(()) + } + + async fn get_stream( + &self, + _uri: Arc, + _session_id: Option>, + _last_event_id: Option, + _auth_header: Option, + _custom_headers: HashMap, + ) -> Result>, StreamableHttpError> { + match self.get_stream.lock().await.take() { + Some(rx) => Ok(message_stream(rx)), + // Reconnect after the scripted stream ends: stay silent. + None => Ok(futures::stream::pending().boxed()), + } + } +} + +#[derive(Clone)] +struct SamplingClient { + // Every invocation is recorded here. Tests assert on the receiver (the + // negative test asserts it stays empty); a panic in this handler would + // run in a spawned task and be silently swallowed. + sampled: mpsc::UnboundedSender, +} + +impl ClientHandler for SamplingClient { + async fn create_message( + &self, + params: CreateMessageRequestParams, + _context: RequestContext, + ) -> Result { + let _ = self.sampled.send(params); + Ok(CreateMessageResult::new( + SamplingMessage::assistant_text("pong"), + "test-model".to_string(), + )) + } + + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +fn sampling_request(id: u32) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "sampling/createMessage", + "params": { + "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }], + "maxTokens": 16 + } + }) +} + +fn tools_list_response(id: &Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": { "tools": [] } }) +} + +async fn next_posted(posted: &mut mpsc::UnboundedReceiver) -> Value { + tokio::time::timeout(Duration::from_secs(5), posted.recv()) + .await + .expect("posted message within 5s") + .expect("channel open") +} + +struct Harness { + client: rmcp::service::RunningService, + /// Every message the client POSTs to the scripted server. + posted: mpsc::UnboundedReceiver, + /// Feeds the standalone GET stream. + get_tx: mpsc::Sender, + /// Feeds the SSE stream of the in-flight tools/list POST. + post_tx: mpsc::Sender, + /// In-flight tools/list call (response withheld until the test releases it). + call: tokio::task::JoinHandle>, + tools_list_id: Value, + /// Sampling params seen by the client handler. + sampled: mpsc::UnboundedReceiver, +} + +/// Drive startup + one in-flight tools/list. +async fn setup() -> Harness { + let (get_tx, get_rx) = mpsc::channel(8); + let (post_tx, post_rx) = mpsc::channel(8); + let (posted_tx, mut posted_rx) = mpsc::unbounded_channel(); + let (sampled_tx, sampled_rx) = mpsc::unbounded_channel(); + let server = ScriptedServer { + get_stream: Arc::new(Mutex::new(Some(get_rx))), + post_stream: Arc::new(Mutex::new(Some(post_rx))), + posted: posted_tx, + }; + let transport = StreamableHttpClientTransport::with_client( + server, + StreamableHttpClientTransportConfig::with_uri("http://scripted/mcp"), + ); + let client = serve_client_with_lifecycle( + SamplingClient { + sampled: sampled_tx, + }, + transport, + ClientLifecycleMode::Initialize, + ) + .await + .expect("initialize against scripted server"); + + // initialize + notifications/initialized already posted during startup. + assert_eq!(next_posted(&mut posted_rx).await["method"], "initialize"); + assert_eq!( + next_posted(&mut posted_rx).await["method"], + "notifications/initialized" + ); + + // Unrelated outbound request, kept in flight (response withheld). + let peer = client.peer().clone(); + let call = tokio::spawn(async move { peer.list_tools(None).await }); + let tools_list = next_posted(&mut posted_rx).await; + assert_eq!(tools_list["method"], "tools/list"); + let tools_list_id = tools_list["id"].clone(); + + Harness { + client, + posted: posted_rx, + get_tx, + post_tx, + call, + tools_list_id, + sampled: sampled_rx, + } +} + +/// #1033 scenario 1: a restricted request on the standalone GET stream while +/// an unrelated outbound request is in flight must be rejected with -32602. +/// (The coarse check from #1029 incorrectly accepted this.) +#[tokio::test] +async fn restricted_request_on_get_stream_rejected_while_unrelated_request_in_flight() +-> anyhow::Result<()> { + let mut h = setup().await; + + h.get_tx.send(sampling_request(100)).await?; + + let rejection = next_posted(&mut h.posted).await; + assert_eq!( + rejection["id"], 100, + "reply to the sampling request: {rejection}" + ); + assert_eq!( + rejection["error"]["code"], -32602, + "SEP-2260: GET-stream request must be rejected even with an unrelated \ + request in flight, got {rejection}" + ); + + h.post_tx + .send(tools_list_response(&h.tools_list_id)) + .await?; + tokio::time::timeout(Duration::from_secs(5), h.call).await???; + // Rejected means rejected: the handler must not ALSO have been invoked. + assert!( + h.sampled.try_recv().is_err(), + "handler must not see the rejected sampling request" + ); + tokio::time::timeout(Duration::from_secs(5), h.client.cancel()).await??; + Ok(()) +} + +/// Positive twin: the same restricted request arriving on the SSE stream of +/// the originating POST is dispatched to the handler and answered. +#[tokio::test] +async fn restricted_request_on_originating_post_stream_is_dispatched() -> anyhow::Result<()> { + let mut h = setup().await; + + h.post_tx.send(sampling_request(200)).await?; + + let response = next_posted(&mut h.posted).await; + assert_eq!( + response["id"], 200, + "reply to the sampling request: {response}" + ); + assert_eq!( + response["result"]["model"], "test-model", + "request on the originating POST stream must reach the handler, got {response}" + ); + tokio::time::timeout(Duration::from_secs(5), h.sampled.recv()) + .await? + .expect("handler invoked"); + + h.post_tx + .send(tools_list_response(&h.tools_list_id)) + .await?; + tokio::time::timeout(Duration::from_secs(5), h.call).await???; + tokio::time::timeout(Duration::from_secs(5), h.client.cancel()).await??; + Ok(()) +} From 51c8ee60e337d06777c6cd05f210a2943266597b Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 30 Jul 2026 11:36:12 -0400 Subject: [PATCH 299/333] chore(conformance): meeting requirements for tier 1 (#1087) * chore(conformance): meeting requirements for tier 1 * chore: address PR feedback --- DEPENDENCY_POLICY.md | 65 +++++++ README.md | 423 +++++++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 146 +++++++-------- VERSIONING.md | 66 +++++++ 4 files changed, 617 insertions(+), 83 deletions(-) create mode 100644 DEPENDENCY_POLICY.md create mode 100644 VERSIONING.md diff --git a/DEPENDENCY_POLICY.md b/DEPENDENCY_POLICY.md new file mode 100644 index 000000000..d201e9922 --- /dev/null +++ b/DEPENDENCY_POLICY.md @@ -0,0 +1,65 @@ +# Dependency Policy + +This document describes how the Rust MCP SDK (`rmcp`, `rmcp-macros`) selects, +updates, and maintains its dependencies. It applies to all crates in the workspace. + +## Goals + +- Keep dependencies current and secure without churn that destabilizes the public API. +- Minimize the dependency footprint we impose on downstream users. +- Make dependency updates reviewable and traceable. + +## Choosing dependencies + +Before adding a new dependency we consider: + +- **Necessity** — whether the functionality is worth an added dependency, or is small + enough to implement directly. +- **Maintenance & trust** — active maintenance, a healthy release history, and a + permissive, compatible license (the workspace is `Apache-2.0`). +- **Footprint** — transitive dependency count and compile-time cost. +- **Optionality** — anything not needed by every user should sit behind a Cargo + feature so downstream builds stay lean. The SDK is heavily feature-gated for this + reason (transports, `auth`, `schemars`, `reqwest`, etc.). + +Version requirements are specified with caret (`^`, the Cargo default) constraints so +compatible upgrades are picked up automatically. + +## Automated updates + +Dependency updates are automated with [Dependabot](https://docs.github.com/en/code-security/dependabot), +configured in [`.github/dependabot.yml`](.github/dependabot.yml): + +| Ecosystem | Cadence | PR label | +| -------------- | ------- | ----------------- | +| Cargo crates | Weekly | `T-dependencies` | +| GitHub Actions | Daily | `T-CI` | + +Dependabot opens a limited number of PRs at a time (currently 3 per ecosystem) to +keep the review queue manageable. Update PRs run the full CI suite (build, format, +clippy, tests, and conformance) and must be green before merge. + +## Review and merge + +- Patch and minor updates that pass CI are reviewed and merged promptly by maintainers. +- Major (breaking) updates are evaluated individually; they may require code changes + and are scheduled deliberately rather than merged automatically. +- Any dependency change that affects the **public API** (for example a type re-exported + from a dependency) is treated as a potential breaking change and follows + [`VERSIONING.md`](VERSIONING.md). + +## Security updates + +- Security fixes take priority over routine updates and are expedited. +- Vulnerabilities in the SDK itself are handled per [`SECURITY.md`](SECURITY.md) via + GitHub's private Security Advisory process. +- Advisories affecting our dependencies (e.g. via the [RustSec Advisory Database](https://rustsec.org/)) + are addressed as soon as a fixed version is available; if a released version of the + SDK is impacted, a patched release is published and, where warranted, the affected + versions are yanked from crates.io. + +## Minimum Supported Rust Version + +Dependency upgrades must not raise the SDK's declared MSRV (`rust-version` in +`Cargo.toml`) without an explicit, documented MSRV bump — which is itself treated as a +breaking change per [`VERSIONING.md`](VERSIONING.md). diff --git a/README.md b/README.md index dd646404a..da7019f37 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ below. For the full MCP specification, see - [Resources](#resources) - [Prompts](#prompts) - [Sampling](#sampling) +- [Elicitation](#elicitation) - [Roots](#roots) - [Logging](#logging) - [Completions](#completions) @@ -38,6 +39,10 @@ below. For the full MCP specification, see - [Caching](#caching) - [Standard HTTP Headers](#standard-http-headers) - [Stateless Streamable HTTP](#stateless-streamable-http) +- [Transports](#transports) +- [Pagination](#pagination) +- [Capability & Protocol Version Negotiation](#capability--protocol-version-negotiation) +- [JSON Schema 2020-12](#json-schema-2020-12) - [Examples](#examples) - [OAuth Support](#oauth-support) - [Related Resources](#related-resources) @@ -254,6 +259,75 @@ impl ServerHandler for Calculator {} See [`crates/rmcp-macros`](crates/rmcp-macros/README.md) for full macro documentation. +#### Tool result content types + +Beyond a plain `String`, tools can return images, audio, embedded resources, and +mixed content. Build a `CallToolResult` from a `Vec`: + +```rust,ignore +use rmcp::model::{CallToolResult, ContentBlock, ResourceContents}; + +#[tool(description = "Render a chart")] +async fn chart(&self) -> Result { + let png_base64 = render_png(); // base64-encoded bytes + + Ok(CallToolResult::success(vec![ + // Text + ContentBlock::text("Here is your chart:"), + // Image — base64 data + MIME type + ContentBlock::image(png_base64, "image/png"), + // Audio — base64 data + MIME type + // ContentBlock::audio(wav_base64, "audio/wav"), + // Embedded resource — inline text (or ResourceContents::blob for binary) + ContentBlock::resource(ResourceContents::text( + "chart source data", + "chart://last/data.csv", + )), + ])) +} +# fn render_png() -> String { String::new() } +``` + +Image and audio data are base64 strings with a MIME type. For embedded +resources, `ResourceContents::text(..)` inlines text and +`ResourceContents::blob(base64, uri)` inlines binary. + +#### Error handling + +Two failure modes, chosen by **whose problem it is**: + +- **Tool-level error** — `Ok(CallToolResult::error(vec![...]))`. The tool ran but + failed in a way the caller should see (no rows matched, upstream 500). The + client renders your `content`, so the message reaches the user. Use this for + almost every "the tool ran and didn't work" case. +- **Protocol error** — `Err(McpError)` with a JSON-RPC code (e.g. + `McpError::invalid_params(..)`). Use this when the server can't route or process + the request at all; clients render these opaquely, so the caller does **not** + see your message. + +```rust,ignore +use rmcp::model::{CallToolResult, ContentBlock}; +use rmcp::ErrorData as McpError; + +#[tool(description = "Look up a record")] +async fn lookup(&self, Parameters(args): Parameters) -> Result { + // Malformed request — the server can't run anything → protocol error. + if args.query.is_empty() { + return Err(McpError::invalid_params("query must be non-empty", None)); + } + + // Tool ran, no result → tool-level error the user should see. + let rows = self.run_query(&args.query).await; + if rows.is_empty() { + return Ok(CallToolResult::error(vec![ContentBlock::text( + format!("no rows matched '{}'", args.query), + )])); + } + + Ok(CallToolResult::success(vec![ContentBlock::text(format_rows(&rows))])) +} +``` + ### Client-side ```rust,ignore @@ -328,6 +402,16 @@ impl ServerHandler for MyServer { "memo://insights" => Ok(ReadResourceResult::new(vec![ ResourceContents::text("Analysis results...", &request.uri), ])), + // Binary resource — base64-encode the bytes and return a blob. + "file:///logo.png" => { + use base64::{Engine, prelude::BASE64_STANDARD}; + let bytes = std::fs::read("logo.png").unwrap_or_default(); + let blob = BASE64_STANDARD.encode(bytes); + Ok(ReadResourceResult::new(vec![ + ResourceContents::blob(blob, &request.uri) + .with_mime_type("image/png"), + ])) + } _ => Err(McpError::resource_not_found( "resource_not_found", Some(json!({ "uri": request.uri })), @@ -487,6 +571,30 @@ Prompt functions support several return types: - `GetPromptResult` -- messages with an optional description - `Result` -- either of the above, with error handling +#### Image and embedded-resource content + +A `PromptMessage` can also carry an image or embedded resource. Use the +dedicated constructors (image/audio require the `base64` feature): + +```rust,ignore +use rmcp::model::{PromptMessage, Role}; + +// Image content — raw bytes are base64-encoded for you. +let screenshot: &[u8] = load_png(); +let msg = PromptMessage::new_image(Role::User, screenshot, "image/png", None, None); + +// Embedded resource — inline a text resource by URI. Pass `Some(text)` for a +// text resource, or `None` for a blob resource. +let msg = PromptMessage::new_resource( + Role::User, + "file:///spec.md".to_string(), + Some("text/markdown".to_string()), + Some("# Specification\n...".to_string()), + None, None, None, +); +# fn load_png() -> &'static [u8] { &[] } +``` + ### Client-side ```rust @@ -589,6 +697,140 @@ impl ClientHandler for MyClient { --- +## Elicitation + +Elicitation lets a server pause mid-operation to ask the user for input, in one +of two modes: **form mode** (structured fields with a JSON Schema) or **URL +mode** (send the user to a web page and wait for completion). + +**MCP Spec:** [Elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation) + +### Server-side (form mode) + +Define a struct deriving `JsonSchema`, mark it `elicit_safe!`, and call +`elicit::()` on the peer. Schema validation, defaults, and enum choices all +come from the type. + +```rust,ignore +use rmcp::{elicit_safe, model::*, service::{RequestContext, RoleServer}}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[schemars(description = "User information")] +pub struct UserInfo { + #[schemars(description = "User's name")] + pub name: String, + // Optional field; omitted if the user doesn't provide it. + #[serde(default)] + #[schemars(description = "Preferred greeting")] + pub greeting: Option, +} + +// Whitelist the type for elicitation (schema-validated on both ends). +elicit_safe!(UserInfo); + +#[tool(description = "Greet the user")] +async fn greet(&self, ctx: RequestContext) -> Result { + // Returns Ok(Some(UserInfo)) if the user accepts. + // Decline and cancel are returned as ElicitationError variants. + match ctx.peer.elicit::("Please provide your name").await { + Ok(Some(info)) => Ok(CallToolResult::success(vec![ContentBlock::text( + format!("Hello, {}!", info.name), + )])), + Ok(None) => Ok(CallToolResult::success(vec![ContentBlock::text( + "No name provided.", + )])), + Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text( + format!("Elicitation failed: {e}"), + )])), + } +} +``` + +#### Enum values + +Enum fields become a choice list. `schemars` needs two hints to inline and type +the enum correctly: + +```rust,ignore +#[derive(Debug, Serialize, Deserialize, JsonSchema, Default)] +#[schemars(inline)] // inline the enum into the parent schema +#[schemars(extend("type" = "string"))] // schemars omits `type` for enums; add it +enum Priority { + #[schemars(title = "Low priority")] + #[default] + Low, + #[schemars(title = "High priority")] + High, +} +``` + +See [`examples/servers/src/elicitation_enum_inference.rs`](examples/servers/src/elicitation_enum_inference.rs) +for single-select, multi-select, titled, and defaulted enum forms. + +### Server-side (URL mode) + +For flows a form can't capture (OAuth consent, a payment page), send the user to +a URL. `elicit_url` returns the user's `ElicitationAction` rather than typed data: + +```rust,ignore +use rmcp::model::ElicitationAction; +use url::Url; + +let action = ctx.peer.elicit_url( + "Please complete setup in your browser", + Url::parse("https://example.com/setup").unwrap(), + "setup-123", // a unique elicitation id +).await?; + +match action { + ElicitationAction::Accept => { /* user consented */ } + ElicitationAction::Decline => { /* user declined */ } + ElicitationAction::Cancel => { /* user aborted */ } +} +``` + +### Client-side + +Implement `ClientHandler::create_elicitation()`, matching on the request variant +to handle form vs. URL mode: + +```rust,ignore +use rmcp::{ClientHandler, model::*, service::{RequestContext, RoleClient}}; + +impl ClientHandler for MyClient { + async fn create_elicitation( + &self, + request: ElicitRequestParams, + _context: RequestContext, + ) -> Result { + match request { + ElicitRequestParams::FormElicitationParams { message, .. } => { + // Show `message` + the requested schema, collect input, then: + Ok(ElicitResult { + action: ElicitationAction::Accept, + content: Some(rmcp::object!({ "name": "Ada" })), + meta: None, + }) + } + ElicitRequestParams::UrlElicitationParams { url, .. } => { + // Open `url`, wait for the user, then report the action. + let _ = url; + Ok(ElicitResult { action: ElicitationAction::Accept, content: None, meta: None }) + } + } + } +} +``` + +On completion the client sends a `notifications/elicitation/response` +notification to release the waiting server-side `elicit_url` call. + +**Example:** [`examples/servers/src/elicitation_stdio.rs`](examples/servers/src/elicitation_stdio.rs) (form + URL), [`examples/servers/src/elicitation_enum_inference.rs`](examples/servers/src/elicitation_enum_inference.rs) (enum forms) + +--- + ## Roots > **Deprecated (SEP-2577):** Roots is deprecated and will be removed in a future release. It remains fully functional for now. See [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577). @@ -1239,6 +1481,187 @@ let client = ClientInfo::default().serve(transport).await?; --- +## Transports + +A transport moves JSON-RPC messages between client and server. Any `Transport` +impl can be passed to `.serve(..)`; `rmcp` ships the common ones behind Cargo +features. + +**MCP Spec:** [Transports](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) + +| Transport | Feature(s) | Notes | +| ---------------------------- | -------------------------------------------- | ----- | +| **stdio** | `transport-io` (client + server) | Communicate over `stdin`/`stdout`; the standard way to launch local MCP servers as child processes. | +| **Child process** (client) | `transport-child-process` | Spawn a server binary and talk to it over its stdio. | +| **Streamable HTTP** (server) | `transport-streamable-http-server` | The current HTTP transport. Exposes a Tower service you can mount on any router. | +| **Streamable HTTP** (client) | `transport-streamable-http-client-reqwest` | HTTP client transport built on `reqwest`. | +| **Worker / in-process** | `transport-worker` | For embedding or testing without real I/O. | + +### stdio + +```rust,ignore +use rmcp::{ServiceExt, transport::stdio}; + +// Server: serve over stdin/stdout. +let server = MyServer.serve(stdio()).await?; +server.waiting().await?; +``` + +```rust,ignore +use rmcp::{ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}}; +use tokio::process::Command; + +// Client: launch a server binary and talk to it over its stdio. +let transport = TokioChildProcess::new(Command::new("uvx").configure(|cmd| { + cmd.arg("mcp-server-git"); +}))?; +let client = ().serve(transport).await?; +``` + +### Streamable HTTP + +`StreamableHttpService` is a Tower service — mount it on any `axum`/`hyper` +router (see [Stateless Streamable HTTP](#stateless-streamable-http) for the full +server example). The client transport connects with a single URI: + +```rust,ignore +use rmcp::transport::StreamableHttpClientTransport; + +let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); +let client = ClientInfo::default().serve(transport).await?; +``` + +#### A note on SSE + +Streamable HTTP responses arrive as either a single `application/json` body or a +`text/event-stream` (Server-Sent Events) stream when the server pushes +notifications or requests before the result. `rmcp` handles both automatically +(SSE parsing lives behind the `client-side-sse` feature). There is no separate +"SSE transport" to configure — it's an implementation detail of Streamable HTTP. + +> The standalone HTTP+SSE transport from `2024-11-05` is superseded by Streamable +> HTTP. For server-to-client streaming under `2026-07-28`, see +> [Subscriptions](#subscriptions). + +--- + +## Pagination + +List operations (`tools/list`, `prompts/list`, `resources/list`, +`resources/templates/list`) are paginated via a `next_cursor`. The `list_all_*` +helpers walk every page for you: + +```rust,ignore +// Fetches all pages transparently. +let tools = client.list_all_tools().await?; +let prompts = client.list_all_prompts().await?; +let resources = client.list_all_resources().await?; +``` + +To page manually, call the single-page method and follow `next_cursor` until +it's `None`: + +```rust,ignore +use rmcp::model::PaginatedRequestParams; + +let mut cursor = None; +loop { + let page = client + .list_tools(Some(PaginatedRequestParams { meta: None, cursor })) + .await?; + for tool in &page.tools { + // handle each tool + } + cursor = page.next_cursor; + if cursor.is_none() { + break; + } +} +``` + +On the server, return a `next_cursor` from your `list_*` handler when more pages +remain (`None` when complete). + +**MCP Spec:** [Pagination](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination) + +--- + +## Capability & Protocol Version Negotiation + +### Capabilities + +At initialization, client and server exchange **capabilities** so each side +knows what the other supports. Declare yours with the `ServerCapabilities` +builder in `get_info()`: + +```rust,ignore +use rmcp::model::{ServerCapabilities, ServerInfo}; + +fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_prompts() + .enable_resources() + .enable_resources_subscribe() + .enable_tool_list_changed() + .enable_logging() + .build(), + ) +} +``` + +Clients do the same via `ClientCapabilities::builder()`. Macros like +`#[tool_handler]` / `#[prompt_handler]` set the relevant flags automatically. +After connecting, read the peer's capabilities via `peer.peer_info()`. + +### Protocol version + +MCP is versioned by date. `rmcp` negotiates automatically on connect — the +client offers a preferred `ProtocolVersion` and falls back to one the server +supports: + +```rust,ignore +use rmcp::model::ProtocolVersion; + +ProtocolVersion::LATEST; // newest stable version this SDK defaults to +ProtocolVersion::V_2026_07_28; // a specific version constant +ProtocolVersion::KNOWN_VERSIONS; // every version this SDK understands +``` + +Version-specific behavior (SEP-2243 headers, SEP-2567 stateless serving, the +SEP-2575 subscription model) is gated on the negotiated version, so older clients +keep working while newer ones opt in. + +**MCP Spec:** [Versioning and Compatibility](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) + +--- + +## JSON Schema 2020-12 + +Deriving `schemars::JsonSchema` on your parameter and result types generates +[JSON Schema draft 2020-12](https://json-schema.org/) — the dialect the MCP spec +requires — for the tool's `inputSchema` and `outputSchema`. + +```rust,ignore +use rmcp::schemars; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct SearchParams { + /// Full-text query. + query: String, + /// Maximum number of results. + #[serde(default)] + limit: Option, +} +``` + +Field names, types, and doc comments flow into the schema — no manual authoring +needed. As of `2026-07-28` (SEP-2106), `outputSchema` may be any JSON Schema type +(not only `object`) and `structuredContent` may be any JSON value. + +--- + ## Examples See [examples](examples/README.md). diff --git a/ROADMAP.md b/ROADMAP.md index 60c35b4b9..c3a00376e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,116 +1,96 @@ # RMCP Roadmap -This roadmap tracks the path to SEP-1730 Tier 1 for the Rust MCP SDK. +This roadmap tracks the path to [SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730) Tier 1 for the Rust MCP SDK. -Spec 2025-11-25 (suite 0.1.16): Server 100% (30/30) · Client 100% (18/18) -Spec 2026-07-28 (suite 0.2.0-alpha.9): Server 97.5% (39/40) · Client 90.6% (29/32) +**Status (2026-07-29):** conformance is 100% across every date-versioned suite, and +the stable **v3.0.0** release has shipped. The remaining Tier 1 work is documentation +coverage and two governance documents. -Extension scenarios are reported separately below because they are -informational and do not count toward SDK tiering. +| Suite (date-versioned) | Server | Client | +| ---------------------- | ------------- | ------------- | +| 2025-11-25 | 100% (30/30) | 100% | +| 2026-07-28 | 100% (30/30) | 100% | ---- +Only date-versioned scenarios count toward SDK tiering. `draft` (2026-07-28 draft) +and `extension` scenarios are informational and reported separately below. -## Target spec: 2026-07-28 (release 2026-07-28) +--- -All 2026-07-28 work carries the `2026-07-28` label and the -[`2026-07-28 spec` milestone](https://github.com/modelcontextprotocol/rust-sdk/milestone/3). -Per-scenario conformance status is tracked in the epic issue: -[#977 — Tracking: 2026-07-28 spec conformance](https://github.com/modelcontextprotocol/rust-sdk/issues/977). +## Conformance -### Versioned-spec conformance (baseline 2026-07-21, suite `0.2.0-alpha.9`) +Per-scenario status for the current spec is tracked in the epic issue +[#977 — Tracking: 2026-07-28 spec conformance](https://github.com/modelcontextprotocol/rust-sdk/issues/977), +under the [`2026-07-28 spec` milestone](https://github.com/modelcontextprotocol/rust-sdk/milestone/3). -- Server: 1 expected failure: `json-schema-2020-12` -- Client: 3 expected failures: `tools_call`, `auth/scope-step-up`, and `auth/authorization-server-migration` -- CI: runs the complete `2026-07-28` versioned-spec suites with a strict baseline; an unlisted failure or a listed scenario that starts passing fails the build +CI (`.github/workflows/conformance.yml`) runs the full `2025-11-25` and `2026-07-28` +server and client suites on every push and PR. Both suites are fully green. -### Extension conformance (informational) +### Informational (not scored for tiering) -Extension-tagged scenarios are excluded by `--spec-version` filters, so CI -runs them in separate server and client steps with -`conformance/expected-failures-extensions.yaml`. +Extension-tagged scenarios are excluded by `--spec-version` filters, so CI runs them +in separate steps against `conformance/expected-failures-extensions.yaml`: -- SEP-2663 Tasks server: 9 expected failures; `tasks-status-notifications` is currently skipped by the upstream harness; tracked in #868 -- Client extensions: `auth/client-credentials-basic` and `auth/client-credentials-jwt` pass; `auth/enterprise-managed-authorization` is an expected failure +| Scenario | Tag | Status | +| --------------------------------------- | ---------- | ------ | +| `auth/client-credentials-basic` | extension | ✅ Pass | +| `auth/client-credentials-jwt` | extension | ✅ Pass | +| `auth/enterprise-managed-authorization` | extension | ❌ Expected failure (not implemented by the conformance client) | +| `auth/wif-jwt-bearer` | 2026-07-28 draft | ❌ Expected failure (WIF / SEP-1933, draft) | +| `tasks-*` (SEP-2663) | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped | ### Spec features without conformance scenarios -Conformance alone does not cover the full spec surface. Feature work tracked via the milestone: +Conformance does not cover the entire spec surface. Remaining feature work tracked via +the milestone: - SEP-2567 sessionless MCP via explicit state handles (#870) - SEP-2260 server requests must associate with a client request (#873) - SEP-2549 follow-up: client-side TTL-honoring cache (#974) -(SEP-2575 discovery & negotiation is covered by the `server-stateless` conformance scenario; -implementation is in review — #869, PRs #973, #943.) +--- -### Release +## Tier 1 — remaining work -The 2026-07-28 implementation ships as **v3.0.0** (release PR #964): MRTR, SEP-2549 cache hints, -SEP-2243 standard headers, and the SEP-2106 relaxations are merged but unreleased — tiering and -relegation are evaluated against the latest stable release, so cutting v3.0.0 with the remaining -conformance fixes is on the critical path. Migration guide (draft, kept current until release): -[discussion #969](https://github.com/modelcontextprotocol/rust-sdk/discussions/969). +Conformance, stable release, labels, issue triage, and spec-tracking already meet the +Tier 1 bar. What's left: ---- +### Documentation (Tier 1 requires all non-experimental features documented with examples) -## Tier 1 (non-conformance requirements) +The README now documents core primitives comprehensively with linked examples. ### Governance & Policy -- [ ] Create `VERSIONING.md` — document semver scheme, what constitutes a breaking change, and how breaking changes are communicated -- [ ] Publish a dependency update policy (Tier 1 requires a published policy) -- [ ] Cut v3.0.0 (#964) including all conformance fixes (tier relegation is evaluated against the latest stable release) - -### Documentation (26/48 → 48/48 features with prose + examples) - -#### Undocumented features (14) - -- [ ] Tools — image results -- [ ] Tools — audio results -- [ ] Tools — embedded resources -- [ ] Prompts — embedded resources -- [ ] Prompts — image content -- [ ] Elicitation — URL mode -- [ ] Elicitation — default values -- [ ] Elicitation — complete notification -- [ ] Ping -- [ ] SSE transport — legacy (client) -- [ ] SSE transport — legacy (server) -- [ ] Pagination -- [ ] Protocol version negotiation -- [ ] JSON Schema 2020-12 support *(upgrade from partial)* - -#### Partially documented features (7) - -- [ ] Tools — error handling *(add dedicated prose + example)* -- [ ] Resources — reading binary *(add dedicated example)* -- [ ] Elicitation — form mode *(add prose docs, not just example README)* -- [ ] Elicitation — schema validation *(add prose docs)* -- [ ] Elicitation — enum values *(add prose docs)* -- [ ] Capability negotiation *(add dedicated prose explaining the builder API)* -- [ ] Protocol version negotiation *(document version negotiation behavior)* +- [ ] Add `VERSIONING.md` — document the semver scheme, what constitutes a breaking + change, and how breaking changes are communicated (migration guides are linked + from the README but the policy itself is not yet written down). +- [ ] Add `DEPENDENCY_POLICY.md` — a published dependency update policy (Dependabot is + configured in `.github/dependabot.yml`, but Tier 1 requires a written, findable policy). +- [ ] Re-triage mislabeled `P0` issues — #869 / #871 / #872 are SEP *feature* + implementation tasks, not critical bugs; they should not carry `P0`. Reserving + `P0` for genuine critical bugs keeps the SEP-1730 critical-bug-resolution metric + accurate. ---- +### Nice-to-have (scorecard hygiene) -## Completed - -- [x] 2025-11-25 server conformance 100% (30 scenarios + pending `json-schema-2020-12`, `server-sse-polling`) -- [x] 2025-11-25 client conformance 100% (18 scenarios + legacy `auth/2025-03-26-*`) -- [x] SEP-2322 MRTR (14 server scenarios + `sep-2322-client-request-state`) -- [x] SEP-2164 resource not found -- [x] Cache hints (`caching`) -- [x] `http-header-validation` -- [x] Issue triage labels (bug, enhancement, needs confirmation, needs repro, ready for work, P0–P3) +- [ ] Add a top-level `CHANGELOG.md` (release notes are currently managed by release-plz). +- [ ] Add a top-level `CONTRIBUTING.md` (contributor docs currently live at `docs/CONTRIBUTE.MD`). --- -## Informational (not scored for tiering) - -These extension scenarios are tracked but do not count toward tier advancement: +## Completed -| Scenario | Tag | Status | -|---|---|---| -| `auth/client-credentials-jwt` | extension | ✅ Passed | -| `auth/client-credentials-basic` | extension | ✅ Passed | -| `auth/enterprise-managed-authorization` | extension | ❌ Failed — scenario is not implemented by the conformance client | -| `tasks-*` | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped scenario | +- [x] **v3.0.0 stable released** (2026-07-28) — MRTR, SEP-2549 cache hints, SEP-2243 + standard headers, SEP-2575 stateless MCP, and SEP-2106 relaxations +- [x] 2025-11-25 server conformance 100% (30/30) +- [x] 2025-11-25 client conformance 100% +- [x] 2026-07-28 server conformance 100% (30/30 dated) +- [x] 2026-07-28 client conformance 100% (dated) +- [x] SEP-2322 MRTR (server scenarios + `sep-2322-client-request-state`) +- [x] SEP-2575 Make MCP Stateless (`server-stateless`) +- [x] SEP-2164 resource not found +- [x] SEP-2549 cache hints (`caching`) +- [x] SEP-2243 HTTP standardization (`http-header-validation`, standard headers) +- [x] DNS rebinding protection +- [x] Full SEP-1730 issue-triage label taxonomy (bug, enhancement, question, + needs confirmation, needs repro, ready for work, good first issue, help wanted, P0–P3) +- [x] `SECURITY.md` and Dependabot configuration diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 000000000..e3117e025 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,66 @@ +# Versioning Policy + +`rmcp` and `rmcp-macros` follow [Semantic Versioning 2.0.0](https://semver.org/), +the standard versioning scheme for the Rust/Cargo ecosystem. Given a version +`MAJOR.MINOR.PATCH`: + +- **MAJOR** — incremented for breaking changes to the public API. +- **MINOR** — incremented for new, backwards-compatible functionality. +- **PATCH** — incremented for backwards-compatible bug fixes. + +The `rmcp` and `rmcp-macros` crates are versioned together and released with the +same version number. + +## What counts as a breaking change + +We treat a change as breaking if it would cause code that compiled against the +previous release to fail to compile, or to change behavior in a way callers could +not reasonably anticipate. This follows the Cargo SemVer guidelines +([The Cargo Book — SemVer Compatibility](https://doc.rust-lang.org/cargo/reference/semver.html)). +Examples include: + +- Removing or renaming a public item (function, type, trait, module, variant, field). +- Changing a public function signature, trait method, or trait bound. +- Adding a required method to a public trait, or a field to a struct that callers + construct directly. (Public structs and enums are marked `#[non_exhaustive]` where + practical so that additive changes remain non-breaking.) +- Raising the Minimum Supported Rust Version (MSRV) — see below. + +Additive changes — new functions, new types, new enum variants on +`#[non_exhaustive]` enums, new optional Cargo features — are **not** breaking and +ship in MINOR releases. + +### Cargo features + +The public API is feature-gated. SemVer compatibility is evaluated against the +crate's **default features** and all features except `local`. + +## Pre-1.0 crates in the workspace + +The core crates (`rmcp`, `rmcp-macros`) are `>= 1.0.0` and follow the rules above. +Any workspace crate still in `0.x` follows Cargo's pre-1.0 convention, where the +`MINOR` version acts as the breaking-change signal (`0.MINOR.PATCH`). + +## Minimum Supported Rust Version (MSRV) + +The MSRV is declared via `rust-version` in `Cargo.toml`. Raising the MSRV is +considered a breaking change and is only done in a MINOR or MAJOR release with a +note in the release notes. + +## How breaking changes are communicated + +- Release automation is handled by [release-plz](https://release-plz.dev/), which + derives version bumps from [Conventional Commits](https://www.conventionalcommits.org/) + (`feat!:` / `fix!:` and `BREAKING CHANGE:` footers trigger a MAJOR bump) and runs + `cargo-semver-checks` to catch unintended API breaks. +- Every release is published as a tagged [GitHub Release](https://github.com/modelcontextprotocol/rust-sdk/releases) + with generated notes. +- Significant migrations (for example the 2.x → 3.x upgrade) are accompanied by a + migration guide linked from the release notes and the README. + +## Yanking + +A published version that is later found to be broken or to contain a security issue +may be [yanked](https://doc.rust-lang.org/cargo/commands/cargo-yank.html) from +crates.io. Yanking prevents new dependents from selecting the version but does not +delete it; a fixed PATCH release is published alongside. From d272389fd409b5f36d2abd44a21fa6badede6d78 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:44:37 -0400 Subject: [PATCH 300/333] fix: honor supported_protocol_versions when negotiating initialize (#1093) --- crates/rmcp/src/handler/server.rs | 11 +++ crates/rmcp/src/handler/server/router.rs | 11 ++- crates/rmcp/src/service.rs | 36 ++++++- crates/rmcp/src/service/server.rs | 17 +++- .../transport/streamable_http_server/tower.rs | 11 ++- .../test_protocol_version_negotiation.rs | 94 ++++++++++++++++++- .../tests/test_stateless_protocol_version.rs | 82 +++++++++++++++- 7 files changed, 246 insertions(+), 16 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 61f414963..6dc7883ed 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -300,6 +300,10 @@ impl Service for H { fn get_info(&self) -> ::Info { self.get_info() } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + ServerHandler::supported_protocol_versions(self) + } } macro_rules! server_handler_methods { @@ -321,10 +325,17 @@ macro_rules! server_handler_methods { info.protocol_version = negotiate_protocol_version( &request.protocol_version, info.protocol_version, + &self.supported_protocol_versions(), ); std::future::ready(Ok(info)) } /// Return the protocol versions supported by this server. + /// + /// Defaults to every version this SDK knows. Override it to narrow the + /// set to the revisions the server actually implements: the returned + /// list is advertised by [`Self::discover`], bounds what `initialize` + /// negotiation may agree to, and is what per-request versions are + /// validated against. fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) } diff --git a/crates/rmcp/src/handler/server/router.rs b/crates/rmcp/src/handler/server/router.rs index e934137b8..c49c40894 100644 --- a/crates/rmcp/src/handler/server/router.rs +++ b/crates/rmcp/src/handler/server/router.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{borrow::Cow, sync::Arc}; use prompt::{IntoPromptRoute, PromptRoute}; use tool::{IntoToolRoute, ToolRoute}; @@ -6,7 +6,10 @@ use tool::{IntoToolRoute, ToolRoute}; use super::ServerHandler; use crate::{ RoleServer, Service, - model::{ClientNotification, ClientRequest, ListPromptsResult, ListToolsResult, ServerResult}, + model::{ + ClientNotification, ClientRequest, ListPromptsResult, ListToolsResult, ProtocolVersion, + ServerResult, + }, service::NotificationContext, }; @@ -155,6 +158,10 @@ where .list_changed = Some(true); info } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + ServerHandler::supported_protocol_versions(&self.service) + } } #[cfg(test)] diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index f4fa24c07..20fd2e981 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -1,4 +1,4 @@ -use std::sync::OnceLock; +use std::{borrow::Cow, sync::OnceLock}; use futures::FutureExt; #[cfg(not(feature = "local"))] @@ -284,6 +284,19 @@ pub trait Service: Send + Sync + 'static { context: NotificationContext, ) -> impl Future> + MaybeSendFuture + '_; fn get_info(&self) -> R::Info; + /// The protocol versions this service can speak, bounding what `initialize` + /// negotiation may agree to. + /// + /// Servers normally override + /// [`ServerHandler::supported_protocol_versions`] instead of this method; + /// the blanket `Service` impl forwards to it. This method exists so the + /// transport and handshake layers, which see only a `Service`, can read the + /// list and avoid agreeing to a version the server cannot serve. + /// + /// [`ServerHandler::supported_protocol_versions`]: crate::handler::server::ServerHandler::supported_protocol_versions + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } } #[cfg(feature = "local")] @@ -299,6 +312,12 @@ pub trait Service: 'static { context: NotificationContext, ) -> impl Future> + MaybeSendFuture + '_; fn get_info(&self) -> R::Info; + /// The protocol versions this service can speak. + /// + /// See the non-`local` variant of this trait for details. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } } pub trait ServiceExt: Service + Sized { @@ -350,6 +369,10 @@ impl Service for Box> { fn get_info(&self) -> R::Info { DynService::get_info(self.as_ref()) } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + DynService::supported_protocol_versions(self.as_ref()) + } } #[cfg(not(feature = "local"))] @@ -365,6 +388,10 @@ pub trait DynService: Send + Sync { context: NotificationContext, ) -> MaybeBoxFuture<'_, Result<(), McpError>>; fn get_info(&self) -> R::Info; + /// See [`Service::supported_protocol_versions`]. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } } #[cfg(feature = "local")] @@ -380,6 +407,10 @@ pub trait DynService { context: NotificationContext, ) -> MaybeBoxFuture<'_, Result<(), McpError>>; fn get_info(&self) -> R::Info; + /// See [`Service::supported_protocol_versions`]. + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) + } } impl> DynService for S { @@ -400,6 +431,9 @@ impl> DynService for S { fn get_info(&self) -> R::Info { self.get_info() } + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Service::supported_protocol_versions(self) + } } use std::{ diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 938896ea6..8efbd34be 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -460,12 +460,18 @@ where } } -/// Echoes the client-requested version if known; otherwise returns `server_fallback`. +/// Echoes the client-requested version if the server supports it; otherwise +/// returns `server_fallback`. +/// +/// `server_supported` comes from [`Service::supported_protocol_versions`], so a +/// server that narrows that list is never made to answer `initialize` with a +/// version it cannot serve. pub(crate) fn negotiate_protocol_version( client_requested: &ProtocolVersion, server_fallback: ProtocolVersion, + server_supported: &[ProtocolVersion], ) -> ProtocolVersion { - if ProtocolVersion::KNOWN_VERSIONS.contains(client_requested) { + if server_supported.contains(client_requested) { client_requested.clone() } else { tracing::warn!( @@ -578,8 +584,11 @@ where return Err(ServerInitializeError::InitializeFailed(e)); } }; - init_response.protocol_version = - negotiate_protocol_version(&requested_protocol_version, init_response.protocol_version); + init_response.protocol_version = negotiate_protocol_version( + &requested_protocol_version, + init_response.protocol_version, + &service.supported_protocol_versions(), + ); // Update peer_info so context.protocol_version() reflects the negotiated // version in all subsequent request handlers. negotiated_peer_info.protocol_version = init_response.protocol_version.clone(); diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 3eb593aab..c98a5865e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -279,8 +279,11 @@ impl> Service for NegotiatingStatelessHttpSer if let (Some(requested), ServerResult::InitializeResult(result)) = (requested_protocol_version, &mut response) { - result.protocol_version = - negotiate_protocol_version(&requested, result.protocol_version.clone()); + result.protocol_version = negotiate_protocol_version( + &requested, + result.protocol_version.clone(), + &self.0.supported_protocol_versions(), + ); if let Some(peer_info) = peer.peer_info() { let mut peer_info = (*peer_info).clone(); peer_info.protocol_version = result.protocol_version.clone(); @@ -301,6 +304,10 @@ impl> Service for NegotiatingStatelessHttpSer fn get_info(&self) -> ServerInfo { self.0.get_info() } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + self.0.supported_protocol_versions() + } } #[expect( diff --git a/crates/rmcp/tests/test_protocol_version_negotiation.rs b/crates/rmcp/tests/test_protocol_version_negotiation.rs index 44a314e68..e91ecf97a 100644 --- a/crates/rmcp/tests/test_protocol_version_negotiation.rs +++ b/crates/rmcp/tests/test_protocol_version_negotiation.rs @@ -4,9 +4,12 @@ #![cfg(not(feature = "local"))] #![cfg(feature = "client")] +use std::borrow::Cow; + use rmcp::{ - ClientHandler, ServerHandler, ServiceExt, - model::{ClientInfo, ProtocolVersion, ServerInfo}, + ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt, + model::{ClientInfo, InitializeRequestParams, InitializeResult, ProtocolVersion, ServerInfo}, + service::RequestContext, }; #[derive(Debug, Clone, Default)] @@ -18,6 +21,52 @@ impl ServerHandler for EchoServer { } } +/// Every known version except `2026-07-28`, standing in for a server that has +/// not implemented that revision. +const NARROWED_VERSIONS: &[ProtocolVersion] = &[ + ProtocolVersion::V_2024_11_05, + ProtocolVersion::V_2025_03_26, + ProtocolVersion::V_2025_06_18, + ProtocolVersion::V_2025_11_25, +]; + +#[derive(Debug, Clone, Default)] +struct NarrowedServer; + +impl ServerHandler for NarrowedServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(NARROWED_VERSIONS) + } +} + +/// Narrows the supported versions *and* overrides `initialize`, so the +/// handler's own answer never runs the default negotiation. The handshake layer +/// must still honor the narrowed list. +#[derive(Debug, Clone, Default)] +struct NarrowedOverridingServer; + +impl ServerHandler for NarrowedOverridingServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(NARROWED_VERSIONS) + } + + async fn initialize( + &self, + _request: InitializeRequestParams, + _context: RequestContext, + ) -> Result { + Ok(self.get_info()) + } +} + #[derive(Debug, Clone)] struct VersionedClient { protocol_version: ProtocolVersion, @@ -32,10 +81,17 @@ impl ClientHandler for VersionedClient { } async fn negotiated_version(client_version: ProtocolVersion) -> ProtocolVersion { + negotiated_version_with(EchoServer, client_version).await +} + +async fn negotiated_version_with( + server: S, + client_version: ProtocolVersion, +) -> ProtocolVersion { let (server_transport, client_transport) = tokio::io::duplex(4096); tokio::spawn(async move { - let _ = EchoServer + let _ = server .serve(server_transport) .await .expect("server should start") @@ -81,3 +137,35 @@ async fn unknown_version_falls_back_to_latest() { "unknown version should fall back to LATEST" ); } + +#[tokio::test] +async fn narrowed_server_still_echoes_versions_it_supports() { + for version in NARROWED_VERSIONS { + let negotiated = negotiated_version_with(NarrowedServer, version.clone()).await; + assert_eq!( + negotiated, *version, + "supported version {version} should be echoed back" + ); + } +} + +#[tokio::test] +async fn narrowed_server_does_not_agree_to_version_it_excludes() { + let negotiated = negotiated_version_with(NarrowedServer, ProtocolVersion::V_2026_07_28).await; + assert_eq!( + negotiated, + ProtocolVersion::V_2025_11_25, + "a version outside supported_protocol_versions should not be echoed back" + ); +} + +#[tokio::test] +async fn narrowed_server_caps_even_when_it_overrides_initialize() { + let negotiated = + negotiated_version_with(NarrowedOverridingServer, ProtocolVersion::V_2026_07_28).await; + assert_eq!( + negotiated, + ProtocolVersion::V_2025_11_25, + "the handshake layer should not raise the version above what the server supports" + ); +} diff --git a/crates/rmcp/tests/test_stateless_protocol_version.rs b/crates/rmcp/tests/test_stateless_protocol_version.rs index 3923daed7..02222ec2c 100644 --- a/crates/rmcp/tests/test_stateless_protocol_version.rs +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -1,8 +1,12 @@ //! Tests for protocol version negotiation in stateless HTTP mode. //! -//! Known versions are echoed back; unknown versions fall back to LATEST. +//! Supported versions are echoed back; unknown versions, and versions outside +//! the server's `supported_protocol_versions`, fall back to the handler's own +//! version. #![cfg(not(feature = "local"))] +use std::borrow::Cow; + use rmcp::{ ErrorData, RoleServer, ServerHandler, model::{ @@ -15,7 +19,7 @@ use rmcp::{ }; use tokio_util::sync::CancellationToken; -#[derive(Clone)] +#[derive(Clone, Default)] struct OverridingInitialize; impl ServerHandler for OverridingInitialize { @@ -32,6 +36,38 @@ impl ServerHandler for OverridingInitialize { } } +/// Every known version except `2026-07-28`, standing in for a server that has +/// not implemented that revision. +const NARROWED_VERSIONS: &[ProtocolVersion] = &[ + ProtocolVersion::V_2024_11_05, + ProtocolVersion::V_2025_03_26, + ProtocolVersion::V_2025_06_18, + ProtocolVersion::V_2025_11_25, +]; + +/// Overrides `initialize`, so the handler-side default negotiation never runs, +/// *and* narrows the supported versions. +#[derive(Clone, Default)] +struct NarrowedOverridingInitialize; + +impl ServerHandler for NarrowedOverridingInitialize { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::default()) + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(NARROWED_VERSIONS) + } + + async fn initialize( + &self, + _request: InitializeRequestParams, + _context: RequestContext, + ) -> Result { + Ok(self.get_info()) + } +} + fn stateless_sse_config() -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() .with_legacy_session_mode(false) @@ -45,10 +81,16 @@ fn stateless_json_config() -> StreamableHttpServerConfig { async fn spawn_server( config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken) { + spawn_server_of::(config).await +} + +async fn spawn_server_of( + config: StreamableHttpServerConfig, ) -> (reqwest::Client, String, CancellationToken) { let ct = config.cancellation_token.clone(); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(OverridingInitialize), Default::default(), config); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(H::default()), Default::default(), config); let router = axum::Router::new().nest_service("/mcp", service); let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -150,3 +192,35 @@ async fn stateless_json_init_preserves_handler_fallback_for_unknown_version() { ct.cancel(); } + +#[tokio::test] +async fn stateless_json_init_echoes_versions_the_server_narrowed_to() { + let (client, url, ct) = + spawn_server_of::(stateless_json_config()).await; + + for version in NARROWED_VERSIONS { + let resp = post_init(&client, &url, version.as_str()).await; + assert_eq!( + resp["result"]["protocolVersion"], + version.as_str(), + "supported version {version} should be echoed back" + ); + } + + ct.cancel(); +} + +#[tokio::test] +async fn stateless_json_init_does_not_agree_to_version_outside_supported_list() { + let (client, url, ct) = + spawn_server_of::(stateless_json_config()).await; + + let resp = post_init(&client, &url, ProtocolVersion::V_2026_07_28.as_str()).await; + assert_eq!( + resp["result"]["protocolVersion"], + ProtocolVersion::V_2025_11_25.as_str(), + "a version outside supported_protocol_versions should not be echoed back" + ); + + ct.cancel(); +} From 58b136f44fb671aed1154c12351b0daac910c312 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:45:01 -0400 Subject: [PATCH 301/333] fix: require metadata for modern HTTP requests (#1089) --- .../transport/streamable_http_server/tower.rs | 22 ++- .../test_streamable_http_protocol_version.rs | 165 +++++++++++++++++- 2 files changed, 179 insertions(+), 8 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index c98a5865e..f574ea99e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -467,19 +467,27 @@ fn validate_request_protocol_version_meta( return Ok(()); } let is_discover = matches!(&request.request, ClientRequest::DiscoverRequest(_)); - let Some(meta_version) = request.request.get_meta().protocol_version() else { - if is_discover { + let meta = request.request.get_meta(); + let header_version = headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()); + let Some(meta_version) = meta.protocol_version() else { + let requires_request_metadata = is_discover + || header_version + .is_some_and(|version| version >= ProtocolVersion::V_2026_07_28.as_str()); + if requires_request_metadata { + let missing = meta.missing_required_keys(&ProtocolVersion::V_2026_07_28); return Err(invalid_params_jsonrpc_response( Some(request.id.clone()), - "Invalid params: server/discover requires protocolVersion in request _meta", + format!( + "Invalid params: request _meta is missing or has malformed required fields: {}", + missing.join(", ") + ), )); } return Ok(()); }; - let Some(header_version) = headers - .get(HEADER_MCP_PROTOCOL_VERSION) - .and_then(|value| value.to_str().ok()) - else { + let Some(header_version) = header_version else { return Err(header_mismatch_jsonrpc_response( Some(request.id.clone()), "request _meta protocolVersion requires MCP-Protocol-Version header", diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index c6806c759..f1bf1912b 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -1,10 +1,11 @@ #![cfg(not(feature = "local"))] -//! Regression tests for the `MCP-Protocol-Version` header / initialize body consistency check. +//! Streamable HTTP protocol-version and request-metadata validation tests. use std::sync::Arc; use rmcp::transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }; +use serde_json::{Value, json}; use tokio_util::sync::CancellationToken; mod common; @@ -91,6 +92,31 @@ async fn post_non_initialize(client: &reqwest::Client, url: &str) -> reqwest::Re .expect("send non-initialize request") } +async fn post_modern_request( + client: &reqwest::Client, + url: &str, + method: &str, + name: Option<&str>, + params: Value, +) -> reqwest::Response { + let mut request = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", method) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + })); + if let Some(name) = name { + request = request.header("Mcp-Name", name); + } + request.send().await.expect("send modern request") +} + #[tokio::test] async fn stateless_init_rejects_when_header_older_than_body() -> anyhow::Result<()> { let (client, url, ct) = spawn_server(stateless_json_config()).await; @@ -217,3 +243,140 @@ async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow:: ct.cancel(); Ok(()) } + +#[tokio::test] +async fn stateless_tools_list_rejects_missing_request_meta() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request(&client, &url, "tools/list", None, json!({})).await; + + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), + "expected error message to mention protocolVersion, got: {body}" + ); + ct.cancel(); +} + +#[tokio::test] +async fn stateless_tools_call_rejects_missing_request_meta() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request( + &client, + &url, + "tools/call", + Some("sum"), + json!({ + "name": "sum", + "arguments": { + "a": 1, + "b": 2 + } + }), + ) + .await; + + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), + "expected error message to mention protocolVersion, got: {body}" + ); + ct.cancel(); +} + +#[tokio::test] +async fn stateless_request_rejects_missing_meta_protocol_version() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request( + &client, + &url, + "tools/list", + None, + json!({ + "_meta": { + "io.modelcontextprotocol/clientInfo": { + "name": "test", + "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + }), + ) + .await; + + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")), + "expected error message to mention protocolVersion, got: {body}" + ); + ct.cancel(); +} + +#[tokio::test] +async fn stateless_request_rejects_missing_meta_client_capabilities() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request( + &client, + &url, + "tools/list", + None, + json!({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test", + "version": "1.0" + } + } + }), + ) + .await; + + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("io.modelcontextprotocol/clientCapabilities")), + "expected error message to mention clientCapabilities, got: {body}" + ); + ct.cancel(); +} + +#[tokio::test] +async fn stateless_request_accepts_missing_optional_meta_client_info() { + let (client, url, ct) = spawn_server(stateless_json_config()).await; + + let response = post_modern_request( + &client, + &url, + "tools/list", + None, + json!({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + }), + ) + .await; + + assert_eq!(response.status(), 200); + ct.cancel(); +} From 983a1374ff1792a76b3d028979bfb44443e56d60 Mon Sep 17 00:00:00 2001 From: mrcs64 Date: Thu, 30 Jul 2026 22:00:02 +0200 Subject: [PATCH 302/333] feat: add strict stateless protocol metadata validation (#1091) * feat: add strict stateless protocol metadata validation * fix: clarify strict metadata compatibility boundaries --------- Co-authored-by: mrcs64 <9069178+mrcs64@users.noreply.github.com> --- README.md | 7 + .../transport/streamable_http_server/tower.rs | 115 +++++ .../test_streamable_http_protocol_version.rs | 477 +++++++++++++++++- 3 files changed, 596 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index da7019f37..79d7232ab 100644 --- a/README.md +++ b/README.md @@ -1462,6 +1462,13 @@ let router = axum::Router::new().nest_service("/mcp", service); > Because there is no per-session state, the `service_factory` runs per request. > Keep shared state (DB pools, caches) in a `Clone` handle captured by the > closure; don't rely on in-memory state surviving between requests. +> +> Modern-only servers can additionally call +> `with_stateless_protocol_metadata_required(true)` to reject the compatibility +> fallback for requests missing their per-request protocol signals. rmcp clients +> negotiated below `2026-07-28` do not attach that body metadata and will be +> rejected, so pair this option with a `supported_protocol_versions` +> implementation that advertises only `2026-07-28` and later. ### Client-side diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index f574ea99e..f1fef585f 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -127,6 +127,32 @@ pub struct StreamableHttpServerConfig { /// chunked transfer encoding, or HTTP version. Oversized payloads receive /// a `413 Payload Too Large` response. pub max_request_body_bytes: usize, + /// Require stateless JSON-RPC request POSTs to carry per-request protocol + /// signals before handler dispatch. + /// + /// Non-initialize requests must carry `MCP-Protocol-Version`; ordinary + /// non-discovery requests must also carry + /// `_meta.io.modelcontextprotocol/protocolVersion`. `server/discover` + /// retains its existing request-metadata validation. For `2026-07-28` + /// requests, the server handler continues to require the remaining + /// per-request metadata, including `clientCapabilities`. Initialize, + /// notifications, and other message kinds retain their existing rules. + /// + /// This option applies to requests routed statelessly. Set + /// `legacy_session_mode` to `false` to ensure every request uses that path. + /// Legacy session routing and its error precedence remain unchanged. + /// + /// The validator checks metadata presence rather than applying a version + /// allowlist. However, rmcp clients negotiated below `2026-07-28` do not + /// attach per-request protocol metadata, so enabling this option rejects + /// their ordinary requests. Servers using this option should normally + /// override + /// [`ServerHandler::supported_protocol_versions`](crate::ServerHandler::supported_protocol_versions) + /// to advertise only `2026-07-28` and later. + /// + /// Default is `false`, preserving today's legacy behavior where an absent + /// header is treated as protocol version `2025-03-26`. + pub stateless_protocol_metadata_required: bool, } impl std::fmt::Debug for dyn SessionStore { @@ -147,6 +173,7 @@ impl Default for StreamableHttpServerConfig { allowed_origins: vec![], session_store: None, max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + stateless_protocol_metadata_required: false, } } } @@ -206,6 +233,18 @@ impl StreamableHttpServerConfig { self.max_request_body_bytes = bytes; self } + + /// Require per-request protocol signals on stateless JSON-RPC request + /// POSTs. + /// + /// See [`StreamableHttpServerConfig::stateless_protocol_metadata_required`]. + pub fn with_stateless_protocol_metadata_required( + mut self, + stateless_protocol_metadata_required: bool, + ) -> Self { + self.stateless_protocol_metadata_required = stateless_protocol_metadata_required; + self + } } #[expect( @@ -504,6 +543,77 @@ fn validate_request_protocol_version_meta( Ok(()) } +/// When `stateless_protocol_metadata_required` is enabled in stateless mode, +/// every non-initialize Streamable HTTP JSON-RPC request POST must carry the +/// `MCP-Protocol-Version` HTTP header. A missing header is rejected with +/// HTTP 400 / JSON-RPC `-32020` before handler dispatch. `server/discover` +/// is included so the seam aligns with the per-POST header contract; its +/// body-metadata rule is preserved unchanged. +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_required_protocol_header( + config: &StreamableHttpServerConfig, + headers: &HeaderMap, + message: &ClientJsonRpcMessage, +) -> Result<(), BoxResponse> { + if !config.stateless_protocol_metadata_required { + return Ok(()); + } + let ClientJsonRpcMessage::Request(request) = message else { + // Notifications, response messages, and error messages are exempt. + return Ok(()); + }; + if matches!(&request.request, ClientRequest::InitializeRequest(_)) { + // Initialize keeps its own header-matching rule. + return Ok(()); + } + if headers.contains_key(HEADER_MCP_PROTOCOL_VERSION) { + return Ok(()); + } + Err(header_mismatch_jsonrpc_response( + Some(request.id.clone()), + "Missing MCP-Protocol-Version header for request requiring per-request protocol metadata", + )) +} + +/// When `stateless_protocol_metadata_required` is enabled in stateless mode, +/// every non-initialize, non-discover Streamable HTTP JSON-RPC request must +/// carry `io.modelcontextprotocol/protocolVersion` in `_meta`. A missing entry +/// is rejected with HTTP 400 / JSON-RPC `-32602` (invalid_params). `initialize`, +/// `server/discover` (whose body-metadata rule is already enforced by +/// `validate_request_protocol_version_meta`), notifications, and other message +/// kinds are exempt. +#[expect( + clippy::result_large_err, + reason = "BoxResponse is intentionally large; matches other handlers in this file" +)] +fn validate_required_protocol_meta( + config: &StreamableHttpServerConfig, + message: &ClientJsonRpcMessage, +) -> Result<(), BoxResponse> { + if !config.stateless_protocol_metadata_required { + return Ok(()); + } + let ClientJsonRpcMessage::Request(request) = message else { + return Ok(()); + }; + if matches!( + &request.request, + ClientRequest::InitializeRequest(_) | ClientRequest::DiscoverRequest(_) + ) { + return Ok(()); + } + if request.request.get_meta().protocol_version().is_some() { + return Ok(()); + } + Err(invalid_params_jsonrpc_response( + Some(request.id.clone()), + "Invalid params: request requires protocolVersion in request _meta", + )) +} + fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { let ServerJsonRpcMessage::Error(error) = message else { return http::StatusCode::OK; @@ -1809,6 +1919,10 @@ where // Stateless mode: // - on initialize: the header (if present) must match `params.protocolVersion` // - on every other request: the header must name a known version. + // + // The opt-in seam applies only here so legacy session routing and + // its error precedence remain unchanged. + validate_required_protocol_header(&self.config, &part.headers, &message)?; let has_per_request_version = message_has_per_request_protocol_version(&message); match &message { ClientJsonRpcMessage::Request(req) => { @@ -1829,6 +1943,7 @@ where // Validate SEP-2243 standard headers against the body validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?; validate_request_protocol_version_meta(&part.headers, &message)?; + validate_required_protocol_meta(&self.config, &message)?; let service = self .get_service() .map_err(internal_error_response("get service"))?; diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index f1bf1912b..fcbeb41a0 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -1,9 +1,15 @@ #![cfg(not(feature = "local"))] //! Streamable HTTP protocol-version and request-metadata validation tests. -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; -use rmcp::transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, +use rmcp::{ + ServerHandler, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, }; use serde_json::{Value, json}; use tokio_util::sync::CancellationToken; @@ -380,3 +386,468 @@ async fn stateless_request_accepts_missing_optional_meta_client_info() { assert_eq!(response.status(), 200); ct.cancel(); } + +// --------------------------------------------------------------------------- +// Opt-in seam: `with_stateless_protocol_metadata_required(true)` +// --------------------------------------------------------------------------- +// In stateless mode, every non-initialize Streamable HTTP JSON-RPC request +// POST must carry the `MCP-Protocol-Version` HTTP header (missing → HTTP 400 / +// JSON-RPC `-32020` HeaderMismatch before dispatch). Every non-initialize, +// non-discover request must additionally carry a per-request +// `io.modelcontextprotocol/protocolVersion` in `_meta` (missing → HTTP 400 / +// JSON-RPC `-32602`). The existing rule that requires `server/discover` to +// carry `_meta.protocolVersion` is preserved unchanged. +// +// Non-dispatch is proven with an explicit invocation counter (`AtomicUsize`). + +#[derive(Clone)] +struct CountingServer { + lists: Arc, +} + +impl CountingServer { + fn new() -> (Self, Arc) { + let lists = Arc::new(AtomicUsize::new(0)); + ( + Self { + lists: lists.clone(), + }, + lists, + ) + } +} + +impl ServerHandler for CountingServer { + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo::new( + rmcp::model::ServerCapabilities::builder() + .enable_tools() + .build(), + ) + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + + Send + + '_ { + self.lists.fetch_add(1, Ordering::SeqCst); + std::future::ready(Ok(rmcp::model::ListToolsResult::default())) + } +} + +async fn spawn_counting( + config: StreamableHttpServerConfig, +) -> (reqwest::Client, String, CancellationToken, Arc) { + let ct = config.cancellation_token.clone(); + let (server, lists) = CountingServer::new(); + let service: StreamableHttpService = + StreamableHttpService::new(move || Ok(server.clone()), Default::default(), config); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = tcp_listener.local_addr().unwrap(); + + tokio::spawn({ + let ct = ct.clone(); + async move { + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + } + }); + + ( + reqwest::Client::new(), + format!("http://{addr}/mcp"), + ct, + lists, + ) +} + +fn modern_required_config() -> StreamableHttpServerConfig { + StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_stateless_protocol_metadata_required(true) + .with_cancellation_token(CancellationToken::new()) +} + +async fn post_seam( + client: &reqwest::Client, + url: &str, + body: &str, + header_version: Option<&str>, + extra_headers: &[(&str, &str)], +) -> reqwest::Response { + let mut req = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(body.to_owned()); + if let Some(h) = header_version { + req = req.header("MCP-Protocol-Version", h); + } + for (k, v) in extra_headers { + req = req.header(*k, *v); + } + req.send().await.expect("send request") +} + +// With the seam disabled, explicit stateless mode still dispatches a request +// without protocol metadata, preserving backwards compatibility. +#[tokio::test] +async fn seam_disabled_preserves_stateless_compatibility() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(stateless_json_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#; + let response = post_seam(&client, &url, body, None, &[]).await; + assert_eq!(response.status(), 200); + assert_eq!( + lists.load(Ordering::SeqCst), + 1, + "seam-disabled stateless config must dispatch exactly once" + ); + + ct.cancel(); + Ok(()) +} + +// With legacy compatibility enabled, the seam applies only to requests that +// are routed statelessly: an established legacy session remains unchanged, +// while a self-identifying modern request is still enforced. +#[tokio::test] +async fn seam_mixed_mode_enforces_only_stateless_routed_requests() -> anyhow::Result<()> { + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(true) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_stateless_protocol_metadata_required(true) + .with_cancellation_token(CancellationToken::new()); + let (client, url, ct, lists) = spawn_counting(config).await; + + let legacy_body = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; + let unknown_response = post_seam( + &client, + &url, + legacy_body, + None, + &[("Mcp-Session-Id", "unknown-session")], + ) + .await; + assert_eq!( + unknown_response.status(), + reqwest::StatusCode::NOT_FOUND, + "the stateless seam must not shadow the legacy unknown-session boundary" + ); + assert_eq!(lists.load(Ordering::SeqCst), 0); + + let initialize = post_init(&client, &url, None, "2025-11-25").await; + assert_eq!(initialize.status(), 200); + let session_id = initialize + .headers() + .get("Mcp-Session-Id") + .and_then(|value| value.to_str().ok()) + .expect("legacy initialize response must include a session id") + .to_owned(); + + let legacy_response = post_seam( + &client, + &url, + legacy_body, + None, + &[("Mcp-Session-Id", &session_id)], + ) + .await; + assert_eq!(legacy_response.status(), 200); + let _ = legacy_response.bytes().await?; + assert_eq!( + lists.load(Ordering::SeqCst), + 1, + "legacy session routing must remain unchanged" + ); + + let delete_response = client + .delete(&url) + .header("Mcp-Session-Id", &session_id) + .send() + .await?; + assert_eq!(delete_response.status(), reqwest::StatusCode::ACCEPTED); + + let terminated_response = post_seam( + &client, + &url, + legacy_body, + None, + &[("Mcp-Session-Id", &session_id)], + ) + .await; + assert_eq!( + terminated_response.status(), + reqwest::StatusCode::NOT_FOUND, + "the stateless seam must not shadow the legacy terminated-session boundary" + ); + assert_eq!(lists.load(Ordering::SeqCst), 1); + + let modern_body = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#; + let modern_response = post_seam( + &client, + &url, + modern_body, + Some("2026-07-28"), + &[("Mcp-Method", "tools/list")], + ) + .await; + assert_eq!(modern_response.status(), 400); + let payload: serde_json::Value = modern_response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + assert_eq!( + lists.load(Ordering::SeqCst), + 1, + "stateless metadata rejection must happen before another handler dispatch" + ); + + ct.cancel(); + Ok(()) +} + +// Both signals absent → missing header / -32020 before dispatch. +#[tokio::test] +async fn seam_opt_in_rejects_missing_header_before_dispatch() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let without_meta = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; + let response = post_seam(&client, &url, without_meta, None, &[]).await; + assert_eq!(response.status(), 400, "missing header must yield HTTP 400"); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32020); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "handler invocation counter must stay at 0 when the header is missing" + ); + + ct.cancel(); + Ok(()) +} + +// Header present but `_meta.protocolVersion` absent → -32602 before +// dispatch. Counter must equal 0. +#[tokio::test] +async fn seam_opt_in_rejects_missing_meta_before_dispatch() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":4,"method":"tools/list","params":{}}"#; + let response = post_seam( + &client, + &url, + body, + Some("2026-07-28"), + &[("Mcp-Method", "tools/list")], + ) + .await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "handler invocation counter must stay at 0 when _meta.protocolVersion is missing" + ); + + ct.cancel(); + Ok(()) +} + +// A 2026 request with protocolVersion but no clientCapabilities reaches the +// existing inline-metadata validator and is rejected before method dispatch. +#[tokio::test] +async fn seam_opt_in_rejects_missing_client_capabilities_before_dispatch() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":5,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}"#; + let response = post_seam( + &client, + &url, + body, + Some("2026-07-28"), + &[("Mcp-Method", "tools/list")], + ) + .await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "handler invocation counter must stay at 0 when clientCapabilities is missing" + ); + + ct.cancel(); + Ok(()) +} + +// Both signals present at the current version, plus the routing header +// required by `validate_standard_headers` for `>= STANDARD_HEADERS`. The +// seam must dispatch and the handler must run exactly once. +#[tokio::test] +async fn seam_opt_in_dispatches_when_header_and_meta_present() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":6,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; + let response = post_seam( + &client, + &url, + body, + Some("2026-07-28"), + &[("Mcp-Method", "tools/list")], + ) + .await; + assert_eq!(response.status(), 200); + let payload: serde_json::Value = response.json().await?; + assert!(payload.get("result").is_some()); + assert_eq!( + lists.load(Ordering::SeqCst), + 1, + "handler invocation counter must equal 1 on successful dispatch" + ); + + ct.cancel(); + Ok(()) +} + +// rmcp clients negotiated below 2026-07-28 send the protocol header but do +// not attach per-request protocol metadata. Requiring the metadata therefore +// rejects their real request shape even if the handler supports that version. +#[tokio::test] +async fn seam_opt_in_rejects_older_rmcp_client_request_shape() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}"#; + let response = post_seam(&client, &url, body, Some("2025-11-25"), &[]).await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "an older client request without per-request metadata must not dispatch" + ); + + ct.cancel(); + Ok(()) +} + +// When the 2026+ header is present but `Mcp-Method` is missing, the +// existing `validate_standard_headers` rule must fire first with -32020, +// before the seam's -32602 missing-meta check. +#[tokio::test] +async fn seam_opt_in_preserves_standard_header_precedence() -> anyhow::Result<()> { + let (client, url, ct, lists) = spawn_counting(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}"#; + let response = post_seam(&client, &url, body, Some("2026-07-28"), &[]).await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!( + payload["error"]["code"], -32020, + "standard-headers routing-header error must precede the seam's meta check" + ); + assert_eq!( + lists.load(Ordering::SeqCst), + 0, + "handler invocation counter must stay at 0 when a routing header is missing" + ); + + ct.cancel(); + Ok(()) +} + +// `initialize` is exempt from the new required-header check while retaining +// its existing optional-header and header/body consistency rules. +#[tokio::test] +async fn seam_opt_in_preserves_initialize_rules() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(modern_required_config()).await; + + let response = post_init(&client, &url, None, "2025-11-25").await; + assert_eq!(response.status(), 200); + + let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await; + assert_eq!(response.status(), 200); + + let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32600); + + ct.cancel(); + Ok(()) +} + +// `notifications/initialized` returns HTTP 202 (Accepted) without +// requiring any protocol metadata, even under the seam. +#[tokio::test] +async fn seam_opt_in_notifications_return_202() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(modern_required_config()).await; + + let body = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; + let response = post_seam(&client, &url, body, None, &[]).await; + assert_eq!( + response.status(), + reqwest::StatusCode::ACCEPTED, + "notifications must surface HTTP 202 regardless of metadata" + ); + + ct.cancel(); + Ok(()) +} + +// `server/discover` is still subject to the new HTTP-header precheck. +#[tokio::test] +async fn seam_opt_in_discover_rejects_missing_header() -> anyhow::Result<()> { + let (client, url, ct) = spawn_server(modern_required_config()).await; + + let with_meta = r#"{"jsonrpc":"2.0","id":8,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#; + let without_meta = r#"{"jsonrpc":"2.0","id":9,"method":"server/discover","params":{}}"#; + for body in [with_meta, without_meta] { + let response = post_seam( + &client, + &url, + body, + None, + &[("Mcp-Method", "server/discover")], + ) + .await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32020); + } + + let response = post_seam( + &client, + &url, + without_meta, + Some("2025-11-25"), + &[("Mcp-Method", "server/discover")], + ) + .await; + assert_eq!(response.status(), 400); + let payload: serde_json::Value = response.json().await?; + assert_eq!(payload["error"]["code"], -32602); + + let response = post_seam( + &client, + &url, + with_meta, + Some("2025-11-25"), + &[("Mcp-Method", "server/discover")], + ) + .await; + assert_eq!(response.status(), 200); + + ct.cancel(); + Ok(()) +} From 570c478962edc450c73454691009270d71f4b2a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:00:37 -0400 Subject: [PATCH 303/333] chore(deps): bump taiki-e/install-action from 2 to 2.85.2 (#1099) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2 to 2.85.2. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2...v2.85.2) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dd9604df..44853e4c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@v2.85.2 with: tool: cargo-semver-checks @@ -130,7 +130,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-public-api - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@v2.85.2 with: tool: cargo-public-api From def31f0f515a83cd1c714d5c19b40cc439fe44cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:00:58 -0400 Subject: [PATCH 304/333] chore(deps): bump github/codeql-action from 4 to 4.37.3 (#1100) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0ae4e6360..9c6838a7c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,13 +24,13 @@ jobs: uses: actions/checkout@v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.37.3 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@v4.37.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.37.3 From 3240b6e7828ed4146041d32dd0ce4ced7c04e411 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 30 Jul 2026 16:31:41 -0400 Subject: [PATCH 305/333] docs: complete Tier 1 feature docs and finalize roadmap (#1101) --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++----- ROADMAP.md | 85 ++++++++++++++++++++++++++++++++---------------------- 2 files changed, 121 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 79d7232ab..83ad17a4a 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,8 @@ use rmcp::model::{CallToolResult, ContentBlock, ResourceContents}; #[tool(description = "Render a chart")] async fn chart(&self) -> Result { - let png_base64 = render_png(); // base64-encoded bytes + let png_base64 = render_png(); // base64-encoded image bytes + let wav_base64 = render_wav(); // base64-encoded audio bytes Ok(CallToolResult::success(vec![ // Text @@ -277,7 +278,7 @@ async fn chart(&self) -> Result { // Image — base64 data + MIME type ContentBlock::image(png_base64, "image/png"), // Audio — base64 data + MIME type - // ContentBlock::audio(wav_base64, "audio/wav"), + ContentBlock::audio(wav_base64, "audio/wav"), // Embedded resource — inline text (or ResourceContents::blob for binary) ContentBlock::resource(ResourceContents::text( "chart source data", @@ -286,6 +287,7 @@ async fn chart(&self) -> Result { ])) } # fn render_png() -> String { String::new() } +# fn render_wav() -> String { String::new() } ``` Image and audio data are base64 strings with a MIME type. For embedded @@ -412,6 +414,18 @@ impl ServerHandler for MyServer { .with_mime_type("image/png"), ])) } + // Template-expanded URI — the client fills in `{user_id}` from the + // `users://{user_id}/profile` template declared in + // `list_resource_templates`, and the server reads the concrete URI. + uri if uri.starts_with("users://") && uri.ends_with("/profile") => { + let user_id = uri + .trim_start_matches("users://") + .trim_end_matches("/profile"); + Ok(ReadResourceResult::new(vec![ResourceContents::text( + format!(r#"{{"id": "{user_id}", "name": "User {user_id}"}}"#), + uri, + )])) + } _ => Err(McpError::resource_not_found( "resource_not_found", Some(json!({ "uri": request.uri })), @@ -424,8 +438,12 @@ impl ServerHandler for MyServer { _request: Option, _context: RequestContext, ) -> Result { + // Declare a URI template with a `{user_id}` parameter. Clients expand it + // (e.g. `users://42/profile`) and pass the concrete URI to `read_resource`. Ok(ListResourceTemplatesResult { - resource_templates: vec![], + resource_templates: vec![ + ResourceTemplate::new("users://{user_id}/profile", "user-profile"), + ], next_cursor: None, meta: None, }) @@ -446,8 +464,12 @@ let result = client.read_resource( ReadResourceRequestParams::new("file:///config.json"), ).await?; -// List resource templates +// List resource templates, then read a resource through one by expanding its +// parameters into a concrete URI (`users://{user_id}/profile` → `users://42/profile`). let templates = client.list_all_resource_templates().await?; +let profile = client.read_resource( + ReadResourceRequestParams::new("users://42/profile"), +).await?; ``` ### Notifications @@ -1000,6 +1022,7 @@ impl ServerHandler for MyServer { _context: RequestContext, ) -> Result { let values = match &request.r#ref { + // Completion for a prompt argument (`ref/prompt`). Reference::Prompt(prompt_ref) if prompt_ref.name == "sql_query" => { match request.argument.name.as_str() { "operation" => vec!["SELECT", "INSERT", "UPDATE", "DELETE"], @@ -1020,6 +1043,17 @@ impl ServerHandler for MyServer { _ => vec![], } } + // Completion for a resource-template argument (`ref/resource`). The + // `uri` identifies the template (e.g. `users://{user_id}/profile`) + // and `argument.name` is the template variable being completed. + Reference::Resource(resource_ref) + if resource_ref.uri == "users://{user_id}/profile" => + { + match request.argument.name.as_str() { + "user_id" => vec!["1", "2", "42"], + _ => vec![], + } + } _ => vec![], }; @@ -1041,12 +1075,22 @@ impl ServerHandler for MyServer { ```rust use rmcp::model::*; +// Completion for a prompt argument. let result = client.complete(CompleteRequestParams::new( Reference::for_prompt("sql_query"), ArgumentInfo::new("operation", "SEL"), )).await?; // result.completion.values contains suggestions like ["SELECT"] + +// Completion for a resource-template argument: reference the template by URI +// and complete one of its variables (`user_id`). +let resource_completion = client.complete(CompleteRequestParams::new( + Reference::for_resource("users://{user_id}/profile"), + ArgumentInfo::new("user_id", "4"), +)).await?; + +// resource_completion.completion.values contains suggestions like ["42"] ``` **Example:** [`examples/servers/src/completion_stdio.rs`](examples/servers/src/completion_stdio.rs) @@ -1538,7 +1582,7 @@ let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/m let client = ClientInfo::default().serve(transport).await?; ``` -#### A note on SSE +#### Server-Sent Events (SSE) Streamable HTTP responses arrive as either a single `application/json` body or a `text/event-stream` (Server-Sent Events) stream when the server pushes @@ -1546,9 +1590,28 @@ notifications or requests before the result. `rmcp` handles both automatically (SSE parsing lives behind the `client-side-sse` feature). There is no separate "SSE transport" to configure — it's an implementation detail of Streamable HTTP. -> The standalone HTTP+SSE transport from `2024-11-05` is superseded by Streamable -> HTTP. For server-to-client streaming under `2026-07-28`, see -> [Subscriptions](#subscriptions). +#### Legacy HTTP+SSE transport (`2024-11-05`) — intentionally not provided + +The standalone two-endpoint **HTTP+SSE transport** defined in protocol revision +`2024-11-05` (a separate `GET` SSE channel plus a `POST` message endpoint) is a +**deliberate non-goal** for `rmcp`. It was [replaced by Streamable HTTP in the +`2025-03-26` revision](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports), +and `rmcp` targets current spec revisions (`2025-11-25` and `2026-07-28`), so it +ships **no legacy HTTP+SSE client or server transport**. + +What to use instead: + +- **New client/server code** — use [Streamable HTTP](#streamable-http). It carries + the same SSE streaming semantics over a single endpoint and is the transport all + supported spec revisions expect. +- **Server-to-client streaming** (push notifications, resource updates) — this is + built into Streamable HTTP; see [Subscriptions](#subscriptions). +- **Talking to a legacy `2024-11-05`-only server** — front it with a proxy that + speaks Streamable HTTP, or pin a dependency to a release that predates the + transport's removal. `rmcp` will not add the legacy transport back. + +This is a supported-surface decision, not a missing feature: every transport +`rmcp` implements is listed in the [Transports](#transports) table above. --- diff --git a/ROADMAP.md b/ROADMAP.md index c3a00376e..27679a1c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,9 +2,24 @@ This roadmap tracks the path to [SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730) Tier 1 for the Rust MCP SDK. -**Status (2026-07-29):** conformance is 100% across every date-versioned suite, and -the stable **v3.0.0** release has shipped. The remaining Tier 1 work is documentation -coverage and two governance documents. +**Status (2026-07-30): all SEP-1730 Tier 1 requirements are met.** Conformance is +100% across every date-versioned suite, the stable **v3.0.1** release has shipped, +issue triage and critical-bug (P0) resolution are within the Tier 1 SLAs, the +governance documents (`VERSIONING.md`, `DEPENDENCY_POLICY.md`, this `ROADMAP.md`) are +published, and all 48 non-experimental features are documented with examples. This +document now serves as the ongoing tracker for spec conformance and SDK health. + +| SEP-1730 Tier 1 requirement | Status | Evidence | +| --------------------------------------------------- | ------ | -------- | +| Server conformance 100% (date-versioned) | ✅ | 30/30 — see below | +| Client conformance 100% (date-versioned) | ✅ | 39/39 scored — see below | +| Issue triage ≥90% within 2 business days | ✅ | 95.2% (20/21) | +| All P0 bugs resolved within 7 days | ✅ | 0 open; last P0 (#741) resolved in 3 days | +| Stable release ≥1.0.0 (no pre-release suffix) | ✅ | `rmcp-v3.0.1` (see tooling note below) | +| Clear versioning + breaking-change policy | ✅ | [`VERSIONING.md`](VERSIONING.md) | +| All non-experimental features documented w/ examples| ✅ | 48/48 in [`README.md`](README.md) | +| Published dependency update policy | ✅ | [`DEPENDENCY_POLICY.md`](DEPENDENCY_POLICY.md) + [`.github/dependabot.yml`](.github/dependabot.yml) | +| Published roadmap tracking spec components | ✅ | this document | | Suite (date-versioned) | Server | Client | | ---------------------- | ------------- | ------------- | @@ -14,6 +29,12 @@ coverage and two governance documents. Only date-versioned scenarios count toward SDK tiering. `draft` (2026-07-28 draft) and `extension` scenarios are informational and reported separately below. +> **Tooling note — `stable_release`:** the SEP-1730 `tier-check` CLI may report +> `stable_release` as failing because it does not parse the workspace tag prefix +> `rmcp-v` (as in `rmcp-v3.0.1`). `rmcp-v3.0.1` is a genuine stable, non-pre-release +> release ([Releases](https://github.com/modelcontextprotocol/rust-sdk/releases)); the +> flag is a tooling artifact, not an unmet requirement. + --- ## Conformance @@ -49,48 +70,42 @@ the milestone: --- -## Tier 1 — remaining work - -Conformance, stable release, labels, issue triage, and spec-tracking already meet the -Tier 1 bar. What's left: - -### Documentation (Tier 1 requires all non-experimental features documented with examples) - -The README now documents core primitives comprehensively with linked examples. - -### Governance & Policy - -- [ ] Add `VERSIONING.md` — document the semver scheme, what constitutes a breaking - change, and how breaking changes are communicated (migration guides are linked - from the README but the policy itself is not yet written down). -- [ ] Add `DEPENDENCY_POLICY.md` — a published dependency update policy (Dependabot is - configured in `.github/dependabot.yml`, but Tier 1 requires a written, findable policy). -- [ ] Re-triage mislabeled `P0` issues — #869 / #871 / #872 are SEP *feature* - implementation tasks, not critical bugs; they should not carry `P0`. Reserving - `P0` for genuine critical bugs keeps the SEP-1730 critical-bug-resolution metric - accurate. - -### Nice-to-have (scorecard hygiene) - -- [ ] Add a top-level `CHANGELOG.md` (release notes are currently managed by release-plz). -- [ ] Add a top-level `CONTRIBUTING.md` (contributor docs currently live at `docs/CONTRIBUTE.MD`). - ---- - ## Completed +### Tier 1 requirements + - [x] **v3.0.0 stable released** (2026-07-28) — MRTR, SEP-2549 cache hints, SEP-2243 - standard headers, SEP-2575 stateless MCP, and SEP-2106 relaxations + standard headers, SEP-2575 stateless MCP, and SEP-2106 relaxations; **v3.0.1** + is the current stable release - [x] 2025-11-25 server conformance 100% (30/30) - [x] 2025-11-25 client conformance 100% - [x] 2026-07-28 server conformance 100% (30/30 dated) - [x] 2026-07-28 client conformance 100% (dated) +- [x] Issue triage ≥90% within 2 business days (95.2%, 20/21) with the full SEP-1730 + label taxonomy (bug, enhancement, question, needs confirmation, needs repro, + ready for work, good first issue, help wanted, P0–P3) +- [x] All P0 bugs resolved within 7 days (0 open; #741 resolved in 3 days). #815 was + reclassified from `P0` to `T-security` — it was a CVE/advisory-coordination task + for an already-shipped fix (PR #764, released in v1.4.0), not a critical-bug fix +- [x] `VERSIONING.md` — semver scheme, breaking-change definition, and communication policy +- [x] `DEPENDENCY_POLICY.md` — published dependency update policy (with `.github/dependabot.yml`) +- [x] `SECURITY.md` and Dependabot configuration +- [x] All 48 non-experimental features documented with examples in the README + (closed the last 6 gaps on 2026-07-30: audio results, resource-template reading, + resource-argument completion, ping, and the legacy HTTP+SSE non-goal writeup) + +### Spec implementation + - [x] SEP-2322 MRTR (server scenarios + `sep-2322-client-request-state`) - [x] SEP-2575 Make MCP Stateless (`server-stateless`) - [x] SEP-2164 resource not found - [x] SEP-2549 cache hints (`caching`) - [x] SEP-2243 HTTP standardization (`http-header-validation`, standard headers) - [x] DNS rebinding protection -- [x] Full SEP-1730 issue-triage label taxonomy (bug, enhancement, question, - needs confirmation, needs repro, ready for work, good first issue, help wanted, P0–P3) -- [x] `SECURITY.md` and Dependabot configuration + +--- + +## Nice-to-have (scorecard hygiene, not required for Tier 1) + +- [ ] Add a top-level `CHANGELOG.md` (release notes are currently managed by release-plz). +- [ ] Add a top-level `CONTRIBUTING.md` (contributor docs currently live at `docs/CONTRIBUTE.MD`). From 65f05e9924f2bf698c597b3c49c06aa83660b1b8 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:00:23 -0400 Subject: [PATCH 306/333] feat: classify authorization-required errors (#1056) --- crates/rmcp/Cargo.toml | 5 ++ crates/rmcp/src/service/client.rs | 11 +++ crates/rmcp/src/transport.rs | 21 +++++ .../tests/test_auth_error_classification.rs | 85 +++++++++++++++++++ docs/OAUTH_SUPPORT.md | 20 +++++ 5 files changed, 142 insertions(+) create mode 100644 crates/rmcp/tests/test_auth_error_classification.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 44c064418..92602fbe6 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -390,6 +390,11 @@ name = "test_client_credentials" required-features = ["auth"] path = "tests/test_client_credentials.rs" +[[test]] +name = "test_auth_error_classification" +required-features = ["auth", "client", "transport-streamable-http-client"] +path = "tests/test_auth_error_classification.rs" + [[test]] name = "test_unix_socket_transport" required-features = [ diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 114b958ba..2e25b13a3 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -111,6 +111,17 @@ impl ClientInitializeError { } None } + + /// Returns whether client initialization failed because authorization is required. + /// + /// This covers both missing or expired local OAuth authorization and an HTTP + /// authorization challenge from the MCP server. + pub fn is_authorization_required(&self) -> bool { + matches!( + self, + Self::TransportError { error, .. } if error.is_authorization_required() + ) + } } /// Helper function to get the next message from the stream diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 74a13945b..06fde8e51 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -272,6 +272,27 @@ impl DynamicTransportError { } } + pub(crate) fn is_authorization_required(&self) -> bool { + let mut error = Some(self.error.as_ref() as &(dyn std::error::Error + 'static)); + while let Some(current) = error { + #[cfg(feature = "auth")] + if matches!( + current.downcast_ref::(), + Some(auth::AuthError::AuthorizationRequired) + ) { + return true; + } + + #[cfg(feature = "transport-streamable-http-client")] + if current.is::() { + return true; + } + + error = current.source(); + } + false + } + pub fn downcast + 'static, R: ServiceRole>(self) -> Result { if !self.is::() { Err(self) diff --git a/crates/rmcp/tests/test_auth_error_classification.rs b/crates/rmcp/tests/test_auth_error_classification.rs new file mode 100644 index 000000000..99b050810 --- /dev/null +++ b/crates/rmcp/tests/test_auth_error_classification.rs @@ -0,0 +1,85 @@ +use std::{any::TypeId, error::Error}; + +use rmcp::{ + service::ClientInitializeError, + transport::{ + AuthError, DynamicTransportError, + streamable_http_client::{AuthRequiredError, InsufficientScopeError, StreamableHttpError}, + }, +}; +use thiserror::Error; + +type TestHttpError = StreamableHttpError; + +#[derive(Debug, Error)] +#[error("outer transport wrapper")] +struct OuterError(#[source] TestHttpError); + +fn initialization_error(error: impl Error + Send + Sync + 'static) -> ClientInitializeError { + ClientInitializeError::TransportError { + error: DynamicTransportError::from_parts( + "test transport", + TypeId::of::<()>(), + Box::new(error), + ), + context: "initialize".into(), + } +} + +#[test] +fn classifies_local_authorization_required() { + let error = TestHttpError::Auth(AuthError::AuthorizationRequired); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn classifies_http_authorization_challenge() { + let error = + TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned())); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn classifies_authorization_required_through_multiple_sources() { + let error = OuterError(TestHttpError::Auth(AuthError::AuthorizationRequired)); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn does_not_classify_unrelated_transport_errors() { + let closed = TestHttpError::TransportChannelClosed; + let refresh = TestHttpError::Auth(AuthError::TokenRefreshFailed("timeout".to_owned())); + let scope = TestHttpError::InsufficientScope(InsufficientScopeError::new( + "Bearer error=\"insufficient_scope\"".to_owned(), + Some("admin".to_owned()), + )); + + assert!(!initialization_error(closed).is_authorization_required()); + assert!(!initialization_error(refresh).is_authorization_required()); + assert!(!initialization_error(scope).is_authorization_required()); +} + +#[test] +fn does_not_classify_non_transport_initialization_errors() { + assert!(!ClientInitializeError::Cancelled.is_authorization_required()); + assert!( + !ClientInitializeError::ConnectionClosed("server closed the connection".to_owned()) + .is_authorization_required() + ); +} + +#[test] +fn http_challenge_remains_available_as_an_error_source() { + let error = + TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned())); + + let source = error.source().expect("auth challenge should be a source"); + let challenge = source + .downcast_ref::() + .expect("source should retain the challenge type"); + + assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\""); +} diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index cdd91294a..82a1fe918 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -256,6 +256,26 @@ let client_service = ClientInfo::default(); let client = client_service.serve(transport).await?; ``` +If initialization reports that authorization is required, return to the +application's authorization flow: + +```rust ignore +let client = match client_service.serve(transport).await { + Ok(client) => client, + Err(error) if error.is_authorization_required() => { + // Prompt the user and start the application's authorization flow again. + return Err(error.into()); + } + Err(error) => return Err(error.into()), +}; +``` + +The predicate covers both missing or expired local OAuth authorization and an +HTTP 401 challenge from the MCP server. Other failures, including transient +token-refresh errors and insufficient scope, return `false`. The original error +is preserved for logging or more detailed handling; the SDK does not start an +authorization flow automatically. + ### 6. Handle scope upgrades If a server returns 403 with `insufficient_scope`, you can request a scope From 00bcf13800afbd880a13bb207881139be7727d26 Mon Sep 17 00:00:00 2001 From: Filinto Duran <1373693+filintod@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:23:56 -0500 Subject: [PATCH 307/333] fix(model): decode metadata-bearing input-required results affecting mrtr (#1097) * fix(model): decode metadata-bearing input-required results The draft permits every Result, including InputRequiredResult, to carry _meta and recommends that servers include io.modelcontextprotocol/serverInfo on every response. It also requires an InputRequiredResult to contain inputRequests or requestState so the client knows what to provide or how to resume the request. ServerResult uses untagged deserialization and attempts CallToolResult before InputRequiredResult. Because CallToolResult accepts _meta and defaults missing content to an empty list, it consumed valid input_required results carrying metadata. Unknown inputRequests and requestState fields were then discarded, leaving clients without the information needed to continue. Reject resultType input_required when deserializing CallToolResult so Serde proceeds to the InputRequiredResult variant. Add regression coverage proving that inputRequests, requestState, and serverInfo metadata are preserved. Spec: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/41d9e938e9a9edf23a69429be180cad172e00f4e/schema/draft/schema.ts#L137-L151 https://github.com/modelcontextprotocol/modelcontextprotocol/blob/41d9e938e9a9edf23a69429be180cad172e00f4e/schema/draft/schema.ts#L198-L224 https://github.com/modelcontextprotocol/modelcontextprotocol/blob/41d9e938e9a9edf23a69429be180cad172e00f4e/schema/draft/schema.ts#L541-L565 Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com> * incorporate feedback - close gap in inputrequired not properly checking that at least one of inputRequest or requestState is present Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com> --------- Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com> --- crates/rmcp/src/model.rs | 12 ++++ crates/rmcp/src/model/mrtr.rs | 19 ++++++ crates/rmcp/tests/test_deserialization.rs | 75 +++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 06ed855e1..7ee93d82d 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3814,6 +3814,8 @@ pub struct CallToolResult { // 2. Requires at least one known field to be present, so that `CallToolResult` doesn't // greedily match arbitrary JSON objects when used inside `#[serde(untagged)]` enums // (e.g. `ServerResult`), which would shadow `CustomResult`. +// 3. Rejects `resultType: "input_required"` so untagged `ServerResult` +// decoding selects `InputRequiredResult` and preserves input requests and state. impl<'de> Deserialize<'de> for CallToolResult { fn deserialize(deserializer: D) -> Result where @@ -3833,6 +3835,16 @@ impl<'de> Deserialize<'de> for CallToolResult { let helper = Helper::deserialize(deserializer)?; + if helper + .result_type + .as_ref() + .is_some_and(ResultType::is_input_required) + { + return Err(serde::de::Error::custom( + "CallToolResult cannot use resultType \"input_required\"", + )); + } + if helper.content.is_none() && helper.structured_content.is_none() && helper.is_error.is_none() diff --git a/crates/rmcp/src/model/mrtr.rs b/crates/rmcp/src/model/mrtr.rs index 40621a7cd..8dac9a391 100644 --- a/crates/rmcp/src/model/mrtr.rs +++ b/crates/rmcp/src/model/mrtr.rs @@ -274,6 +274,12 @@ impl<'de> Deserialize<'de> for InputRequiredResult { } } + if helper.input_requests.is_none() && helper.request_state.is_none() { + return Err(serde::de::Error::custom( + "InputRequiredResult requires at least one of inputRequests or requestState", + )); + } + Ok(InputRequiredResult { result_type: ResultType::INPUT_REQUIRED, input_requests: helper.input_requests, @@ -451,6 +457,19 @@ mod tests { ); } + #[test] + fn rejects_missing_input_requests_and_request_state() { + let json = serde_json::json!({ + "resultType": "input_required", + "_meta": {} + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("inputRequests or requestState"), + "error should mention the required continuation fields, got: {err}" + ); + } + #[test] fn rejects_missing_result_type() { let json = serde_json::json!({ diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index c3d08cd52..857077015 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -70,6 +70,81 @@ mod untagged_server_result { ); } + #[test] + fn input_required_result_with_meta_deserializes_to_correct_variant() { + let result = parse_result(wrap_response(json!({ + "resultType": "input_required", + "inputRequests": { + "username": { + "method": "elicitation/create", + "params": { + "message": "Please provide your username", + "requestedSchema": { + "type": "object", + "properties": { + "username": { "type": "string" } + }, + "required": ["username"] + } + } + } + }, + "requestState": "opaque-state", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "test-server", + "version": "1.0.0" + } + } + }))); + + let ServerResult::InputRequiredResult(result) = result else { + panic!("expected InputRequiredResult, got {result:?}"); + }; + assert!( + result.input_requests.is_some_and(|requests| { + requests.len() == 1 && requests.contains_key("username") + }) + ); + assert_eq!(result.request_state.as_deref(), Some("opaque-state")); + assert_eq!( + result + .meta + .as_ref() + .and_then(|meta| meta.get("io.modelcontextprotocol/serverInfo")), + Some(&json!({ + "name": "test-server", + "version": "1.0.0" + })) + ); + } + + #[test] + fn call_tool_result_rejects_input_required_discriminator() { + assert!( + serde_json::from_value::(json!({ + "resultType": "input_required", + "requestState": "opaque-state", + "_meta": {} + })) + .is_err() + ); + } + + #[test] + fn invalid_input_required_result_falls_through_to_custom_result() { + let payload = json!({ + "resultType": "input_required", + "_meta": {} + }); + let result = parse_result(wrap_response(payload.clone())); + + let ServerResult::CustomResult(result) = result else { + panic!("expected CustomResult, got {result:?}"); + }; + assert_eq!(result.0, payload); + } + #[test] fn empty_object_deserializes_to_empty_result() { let result = parse_result(wrap_response(json!({}))); From 1cf6deb419585d3cb926b99c3c19a94f458ab999 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 31 Jul 2026 10:02:03 -0400 Subject: [PATCH 308/333] docs: document the ping utility with examples (#1106) Add a Ping section to the README covering both directions of the utility: constructing and sending a PingRequest via send_request, and the automatic default handler response (with an override example for custom liveness logic). Links to the MCP ping spec. Ping was the only non-experimental feature lacking user-facing documentation and an example. --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index 83ad17a4a..b6c580e17 100644 --- a/README.md +++ b/README.md @@ -1151,6 +1151,55 @@ impl ServerHandler for MyServer { } ``` +### Ping + +Either side can send a `ping` request to check that its counterpart is still +responsive and the connection is alive. A ping carries no parameters and the +receiver replies with an empty result. Because pings can flow in both +directions, `rmcp` handles them symmetrically: + +- **Sending a ping** — construct a `PingRequest` and send it over the peer. + A client pings the server with `ClientRequest::PingRequest`; a server pings + the client with `ServerRequest::PingRequest`. `send_request` resolves once the + empty response arrives, so a returned `Ok` confirms the peer is reachable: + +```rust +use rmcp::model::{PingRequest, ServerRequest}; + +// From a server, ping the connected client to verify it is still alive. +context.peer + .send_request(ServerRequest::PingRequest(PingRequest::default())) + .await?; +``` + +```rust +use rmcp::model::{ClientRequest, PingRequest}; + +// From a client, ping the server. `running` is the value returned by serve(). +running + .send_request(ClientRequest::PingRequest(PingRequest::default())) + .await?; +``` + +- **Responding to a ping** — `rmcp` answers incoming pings automatically. The + default `ping` method on `ServerHandler` and `ClientHandler` returns an empty + result, so no code is required. Override it only if you want to run custom + logic (for example, health checks) when a ping arrives: + +```rust +impl ServerHandler for MyServer { + async fn ping( + &self, + _context: RequestContext, + ) -> Result<(), McpError> { + // Custom liveness logic here, if any. + Ok(()) + } +} +``` + +**MCP Spec:** [Ping](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping) + ### Initialized notification Legacy clients send `initialized` after the `initialize` handshake completes. From 1f9358eddca42d3a510c70ae6446dd6548c7c856 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:49:59 -0400 Subject: [PATCH 309/333] chore: release v3.1.0 (#1090) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 12 ++++++++++++ crates/rmcp/CHANGELOG.md | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f883663d0..2a3f726ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.0.1", path = "./crates/rmcp" } -rmcp-macros = { version = "3.0.1", path = "./crates/rmcp-macros" } +rmcp = { version = "3.1.0", path = "./crates/rmcp" } +rmcp-macros = { version = "3.1.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.0.1" +version = "3.1.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index eebe9dd26..37a671aa2 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.1...rmcp-macros-v3.1.0) - 2026-07-31 + +### Added + +- add strict stateless protocol metadata validation ([#1091](https://github.com/modelcontextprotocol/rust-sdk/pull/1091)) + +### Other + +- document the ping utility with examples ([#1106](https://github.com/modelcontextprotocol/rust-sdk/pull/1106)) +- complete Tier 1 feature docs and finalize roadmap ([#1101](https://github.com/modelcontextprotocol/rust-sdk/pull/1101)) +- *(conformance)* meeting requirements for tier 1 ([#1087](https://github.com/modelcontextprotocol/rust-sdk/pull/1087)) + ## [3.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.5...rmcp-macros-v3.0.0) - 2026-07-28 ### Other diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index e94dd0473..b4e8cf612 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.1...rmcp-v3.1.0) - 2026-07-31 + +### Added + +- classify authorization-required errors ([#1056](https://github.com/modelcontextprotocol/rust-sdk/pull/1056)) +- add strict stateless protocol metadata validation ([#1091](https://github.com/modelcontextprotocol/rust-sdk/pull/1091)) +- SEP-2260 stream-based enforcement of client receive-side request association ([#1055](https://github.com/modelcontextprotocol/rust-sdk/pull/1055)) + +### Fixed + +- *(model)* decode metadata-bearing input-required results affecting mrtr ([#1097](https://github.com/modelcontextprotocol/rust-sdk/pull/1097)) +- require metadata for modern HTTP requests ([#1089](https://github.com/modelcontextprotocol/rust-sdk/pull/1089)) +- honor supported_protocol_versions when negotiating initialize ([#1093](https://github.com/modelcontextprotocol/rust-sdk/pull/1093)) + +### Other + +- document the ping utility with examples ([#1106](https://github.com/modelcontextprotocol/rust-sdk/pull/1106)) +- complete Tier 1 feature docs and finalize roadmap ([#1101](https://github.com/modelcontextprotocol/rust-sdk/pull/1101)) +- *(conformance)* meeting requirements for tier 1 ([#1087](https://github.com/modelcontextprotocol/rust-sdk/pull/1087)) + ## [3.0.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.0...rmcp-v3.0.1) - 2026-07-29 ### Fixed From 830e088d733c7964c806a2305760dd8deb30dff9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:48:08 -0400 Subject: [PATCH 310/333] chore(deps): bump taiki-e/install-action from 2.85.2 to 2.85.3 (#1107) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.2 to 2.85.3. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.85.2...v2.85.3) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44853e4c7..b0fa6b987 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2.85.2 + uses: taiki-e/install-action@v2.85.3 with: tool: cargo-semver-checks @@ -130,7 +130,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-public-api - uses: taiki-e/install-action@v2.85.2 + uses: taiki-e/install-action@v2.85.3 with: tool: cargo-public-api From 684ff5a51e81f000f411dfb4606204d845120053 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:54:59 -0400 Subject: [PATCH 311/333] fix: disambiguate input-required results (#1103) --- crates/rmcp/src/model.rs | 7 ++- crates/rmcp/tests/test_result_type_wire.rs | 62 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 7ee93d82d..6a1409870 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3814,8 +3814,7 @@ pub struct CallToolResult { // 2. Requires at least one known field to be present, so that `CallToolResult` doesn't // greedily match arbitrary JSON objects when used inside `#[serde(untagged)]` enums // (e.g. `ServerResult`), which would shadow `CustomResult`. -// 3. Rejects `resultType: "input_required"` so untagged `ServerResult` -// decoding selects `InputRequiredResult` and preserves input requests and state. +// 3. Rejects non-`complete` result types so other `ServerResult` variants can match. impl<'de> Deserialize<'de> for CallToolResult { fn deserialize(deserializer: D) -> Result where @@ -3838,10 +3837,10 @@ impl<'de> Deserialize<'de> for CallToolResult { if helper .result_type .as_ref() - .is_some_and(ResultType::is_input_required) + .is_some_and(|result_type| !result_type.is_complete()) { return Err(serde::de::Error::custom( - "CallToolResult cannot use resultType \"input_required\"", + "CallToolResult requires resultType to be \"complete\" when present", )); } diff --git a/crates/rmcp/tests/test_result_type_wire.rs b/crates/rmcp/tests/test_result_type_wire.rs index d5f201532..a22933e35 100644 --- a/crates/rmcp/tests/test_result_type_wire.rs +++ b/crates/rmcp/tests/test_result_type_wire.rs @@ -86,6 +86,68 @@ fn legacy_call_tool_result_round_trips_without_result_type() { assert_eq!(reserialized, legacy); } +#[test] +fn call_tool_result_should_reject_non_complete_result_type() { + let input_required = json!({ + "resultType": "input_required", + "_meta": { + "example.com/replica": "r1", + }, + }); + + assert!(serde_json::from_value::(input_required).is_err()); +} + +#[test] +fn server_result_should_preserve_input_required_result_when_meta_present() { + let input_required = json!({ + "resultType": "input_required", + "requestState": "sealed", + "_meta": { + "example.com/replica": "r1", + }, + }); + + let result = + serde_json::from_value::(input_required).expect("deserialize ServerResult"); + + match result { + ServerResult::InputRequiredResult(result) => { + assert_eq!( + (result.request_state.as_deref(), result.meta.is_some()), + (Some("sealed"), true) + ); + } + _ => panic!("expected InputRequiredResult"), + } +} + +#[test] +fn server_result_should_accept_legacy_call_tool_result_when_only_meta_present() { + let legacy = json!({ + "_meta": { + "example.com/replica": "r1", + }, + }); + + let result = + serde_json::from_value::(legacy).expect("deserialize legacy CallToolResult"); + + match result { + ServerResult::CallToolResult(result) => { + assert_eq!( + ( + result.result_type, + result.content.is_empty(), + result.meta.is_some() + ), + (None, true, true) + ); + } + _ => panic!("expected CallToolResult"), + } +} + #[test] fn strip_removes_complete_result_type() { let mut result = From 5b216605f2c2465c79a5d016ab4a3a958edfe930 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:59:28 -0400 Subject: [PATCH 312/333] chore(deps): bump github/codeql-action from 4.37.3 to 4.37.4 (#1116) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.3 to 4.37.4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.3...v4.37.4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9c6838a7c..6be3da16d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,13 +24,13 @@ jobs: uses: actions/checkout@v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.3 + uses: github/codeql-action/init@v4.37.4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.3 + uses: github/codeql-action/autobuild@v4.37.4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.3 + uses: github/codeql-action/analyze@v4.37.4 From 9cbb69d9e933a60b766a5b43931da9342683c27a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:59:42 -0400 Subject: [PATCH 313/333] chore(deps): bump taiki-e/install-action from 2.85.3 to 2.85.5 (#1117) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.3 to 2.85.5. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.85.3...v2.85.5) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0fa6b987..5ae68a9c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2.85.3 + uses: taiki-e/install-action@v2.85.5 with: tool: cargo-semver-checks @@ -130,7 +130,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-public-api - uses: taiki-e/install-action@v2.85.3 + uses: taiki-e/install-action@v2.85.5 with: tool: cargo-public-api From 7e14020cb8e31d2f11318455adf25b449b2e4f18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:34:47 -0400 Subject: [PATCH 314/333] chore(deps): bump taiki-e/install-action from 2.85.5 to 2.85.6 (#1129) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.5 to 2.85.6. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.85.5...v2.85.6) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ae68a9c3..02d6fa263 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2.85.5 + uses: taiki-e/install-action@v2.85.6 with: tool: cargo-semver-checks @@ -130,7 +130,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-public-api - uses: taiki-e/install-action@v2.85.5 + uses: taiki-e/install-action@v2.85.6 with: tool: cargo-public-api From c4def55422e47338c1bd961a772685beccbb2496 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:55:09 -0400 Subject: [PATCH 315/333] fix: expose MRTR state to tool handlers (#1104) * fix: expose MRTR state to tool handlers * docs: document tool call context fields --- crates/rmcp/src/handler/server/tool.rs | 38 ++++++++++++ crates/rmcp/tests/test_mrtr_behavior.rs | 79 ++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/src/handler/server/tool.rs b/crates/rmcp/src/handler/server/tool.rs index a90240660..54de5fa11 100644 --- a/crates/rmcp/src/handler/server/tool.rs +++ b/crates/rmcp/src/handler/server/tool.rs @@ -34,10 +34,18 @@ pub fn parse_json_object(input: JsonObject) -> Result { + /// The request-specific context for this tool call. pub request_context: RequestContext, + /// The server handling this tool call. pub service: &'s S, + /// The name of the tool being called. pub name: Cow<'static, str>, + /// The arguments supplied for the tool call. pub arguments: Option, + /// Client responses to input requests from the previous MRTR round. + pub input_responses: Option, + /// Opaque state returned by the server during the previous MRTR round. + pub request_state: Option, } impl<'s, S> ToolCallContext<'s, S> { @@ -47,6 +55,8 @@ impl<'s, S> ToolCallContext<'s, S> { meta: _, name, arguments, + input_responses, + request_state, .. }: CallToolRequestParams, request_context: RequestContext, @@ -56,6 +66,8 @@ impl<'s, S> ToolCallContext<'s, S> { service, name, arguments, + input_responses, + request_state, } } pub fn name(&self) -> &str { @@ -98,6 +110,12 @@ impl IntoCallToolResult for InputRequiredResult { } } +impl IntoCallToolResult for CallToolResponse { + fn into_call_tool_result(self) -> Result { + Ok(self) + } +} + impl IntoCallToolResult for crate::ErrorData { fn into_call_tool_result(self) -> Result { Err(self) @@ -193,6 +211,26 @@ impl FromContextPart> for ToolName { } } +/// Extracts the opaque state returned by the server during the previous MRTR round. +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct RequestState(pub Option); + +impl FromContextPart> for RequestState { + fn from_context_part(context: &mut ToolCallContext) -> Result { + Ok(Self(context.request_state.take())) + } +} + +/// Extracts client responses to input requests from the previous MRTR round. +#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] +pub struct InputResponses(pub Option); + +impl FromContextPart> for InputResponses { + fn from_context_part(context: &mut ToolCallContext) -> Result { + Ok(Self(context.input_responses.take())) + } +} + // Special implementation for Parameters that handles tool arguments impl FromContextPart> for Parameters

where diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs index 33332c2b7..4fb24e18e 100644 --- a/crates/rmcp/tests/test_mrtr_behavior.rs +++ b/crates/rmcp/tests/test_mrtr_behavior.rs @@ -13,9 +13,16 @@ use std::sync::{ use rmcp::{ ClientHandler, ServerHandler, + handler::server::{ + tool::{InputResponses as ToolInputResponses, RequestState}, + wrapper::Parameters, + }, model::*, - service::{RequestContext, RoleClient, RoleServer, ServiceError, serve_directly}, + service::{RequestContext, RoleClient, RoleServer, Service, ServiceError, serve_directly}, + tool, tool_handler, tool_router, }; +use schemars::JsonSchema; +use serde::Deserialize; use serde_json::json; /// A `requestState` value with characters that must survive a byte-exact echo: @@ -66,6 +73,54 @@ fn single_elicitation(state: &str) -> InputRequiredResult { InputRequiredResult::new(Some(requests), Some(state.into())) } +#[derive(Clone)] +struct MacroMrtrServer; + +#[derive(Deserialize, JsonSchema)] +struct MacroMrtrArguments { + greeting: String, +} + +#[tool_router] +impl MacroMrtrServer { + #[tool(description = "Greet a user after collecting their name")] + async fn greet( + &self, + Parameters(arguments): Parameters, + RequestState(request_state): RequestState, + ToolInputResponses(input_responses): ToolInputResponses, + ) -> Result { + match request_state.as_deref() { + None => Ok(single_elicitation("macro-state").into()), + Some("macro-state") => { + let name = input_responses + .as_ref() + .and_then(|responses| responses.get("answer")) + .and_then(|response| response["content"]["name"].as_str()) + .ok_or_else(|| ErrorData::invalid_params("missing name response", None))?; + Ok(CallToolResult::success(vec![ContentBlock::text(format!( + "{}, {name}", + arguments.greeting + ))]) + .into()) + } + Some(other) => Err(ErrorData::invalid_params( + format!("unexpected request state {other:?}"), + None, + )), + } + } +} + +#[tool_handler] +impl ServerHandler for MacroMrtrServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } +} + impl MrtrServer { fn call_tool_impl( &self, @@ -289,12 +344,13 @@ fn server_info(protocol_version: ProtocolVersion) -> ServerInfo { /// Runs `body` inside a `LocalSet` so `spawn_local` (used when the `local` /// feature is active) is available, wiring up a connected client/server pair. -async fn with_pair( - server: MrtrServer, +async fn with_pair( + server: S, client_protocol: ProtocolVersion, body: F, ) -> anyhow::Result<()> where + S: Service, F: FnOnce(rmcp::service::RunningService) -> Fut, Fut: std::future::Future>, { @@ -346,6 +402,23 @@ async fn client_auto_fulfills_input_required_tool_call() -> anyhow::Result<()> { .await } +#[tokio::test(flavor = "current_thread")] +async fn tool_macro_receives_mrtr_retry_fields() -> anyhow::Result<()> { + with_pair( + MacroMrtrServer, + ProtocolVersion::V_2026_07_28, + |client| async move { + let arguments = serde_json::from_value(json!({ "greeting": "hello" })).unwrap(); + let result = client + .call_tool(CallToolRequestParams::new("greet").with_arguments(arguments)) + .await?; + assert_eq!(result.content[0].as_text().unwrap().text, "hello, Ferris"); + Ok(()) + }, + ) + .await +} + #[tokio::test(flavor = "current_thread")] async fn manual_once_returns_input_required_without_retry() -> anyhow::Result<()> { let server = MrtrServer::default(); From f8f9607d0bf856629d379b9d63a905b52e7f5398 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:19:45 -0400 Subject: [PATCH 316/333] chore: make async-trait optional (#1119) --- crates/rmcp/Cargo.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 92602fbe6..486fdf873 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -45,7 +45,7 @@ features = [ rustdoc-args = ["--cfg", "docsrs"] [dependencies] -async-trait = "0.1.89" +async-trait = { version = "0.1.89", optional = true } serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" thiserror = "2" @@ -187,11 +187,12 @@ transport-streamable-http-server = [ ] transport-streamable-http-server-session = [ "transport-async-rw", + "dep:async-trait", "dep:tokio-stream", ] # transport-ws = ["transport-io", "dep:tokio-tungstenite"] tower = ["dep:tower-service"] -auth = ["dep:oauth2", "__reqwest", "dep:url"] +auth = ["dep:async-trait", "dep:oauth2", "__reqwest", "dep:url"] auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"] schemars = ["dep:schemars"] From 07bcda2973887b1fed222f1151d153ba5d409141 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:20:08 -0400 Subject: [PATCH 317/333] fix: emit cache hints from handler macros (#1120) --- crates/rmcp-macros/src/prompt_handler.rs | 10 +- crates/rmcp-macros/src/tool_handler.rs | 10 +- crates/rmcp/tests/test_handler_cache_hints.rs | 106 ++++++++++++++++++ 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 crates/rmcp/tests/test_handler_cache_hints.rs diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 086eb0d52..6d957ecd9 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -57,16 +57,20 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - _context: rmcp::service::RequestContext, + context: rmcp::service::RequestContext, ) -> Result { let prompts = #router_expr.list_all(); + let supports_cache_hints = context.protocol_version().is_some_and(|version| { + version >= rmcp::model::ProtocolVersion::V_2026_07_28 + }); Ok(rmcp::model::ListPromptsResult { result_type: Some(rmcp::model::ResultType::COMPLETE), prompts, meta: #meta, next_cursor: None, - ttl_ms: None, - cache_scope: None, + ttl_ms: supports_cache_hints.then_some(0), + cache_scope: supports_cache_hints + .then_some(rmcp::model::CacheScope::Public), }) } }; diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index 7614668a9..e274c102e 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -66,15 +66,19 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - _context: rmcp::service::RequestContext, + context: rmcp::service::RequestContext, ) -> Result { + let supports_cache_hints = context.protocol_version().is_some_and(|version| { + version >= rmcp::model::ProtocolVersion::V_2026_07_28 + }); Ok(rmcp::model::ListToolsResult{ result_type: Some(rmcp::model::ResultType::COMPLETE), tools: #router.list_all(), meta: #result_meta, next_cursor: None, - ttl_ms: None, - cache_scope: None, + ttl_ms: supports_cache_hints.then_some(0), + cache_scope: supports_cache_hints + .then_some(rmcp::model::CacheScope::Public), }) } })?; diff --git a/crates/rmcp/tests/test_handler_cache_hints.rs b/crates/rmcp/tests/test_handler_cache_hints.rs new file mode 100644 index 000000000..d6b626d2a --- /dev/null +++ b/crates/rmcp/tests/test_handler_cache_hints.rs @@ -0,0 +1,106 @@ +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + handler::server::router::{prompt::PromptRouter, tool::ToolRouter}, + model::{CacheScope, ClientInfo, ListPromptsResult, ListToolsResult, ProtocolVersion}, + prompt_handler, tool_handler, +}; + +#[derive(Debug, Clone)] +struct CacheHintServer { + tool_router: ToolRouter, + prompt_router: PromptRouter, +} + +impl CacheHintServer { + fn new() -> Self { + Self { + tool_router: ToolRouter::new(), + prompt_router: PromptRouter::new(), + } + } +} + +#[tool_handler(router = self.tool_router)] +#[prompt_handler(router = self.prompt_router)] +impl ServerHandler for CacheHintServer {} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn list_results(protocol_version: ProtocolVersion) -> (ListToolsResult, ListPromptsResult) { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + CacheHintServer::new() + .serve(server_transport) + .await? + .waiting() + .await?; + anyhow::Ok(()) + }); + + let client = VersionedClient { protocol_version } + .serve(client_transport) + .await + .expect("client should connect"); + let tools = client + .list_tools(None) + .await + .expect("tools/list should succeed"); + let prompts = client + .list_prompts(None) + .await + .expect("prompts/list should succeed"); + + client.cancel().await.expect("client should cancel"); + server_handle.await.expect("server task").expect("server"); + (tools, prompts) +} + +#[tokio::test] +async fn handler_macros_should_emit_required_cache_hints_for_2026_07_28() { + let (tools, prompts) = list_results(ProtocolVersion::V_2026_07_28).await; + + assert_eq!( + ( + tools.ttl_ms, + tools.cache_scope, + prompts.ttl_ms, + prompts.cache_scope, + ), + ( + Some(0), + Some(CacheScope::Public), + Some(0), + Some(CacheScope::Public), + ) + ); +} + +#[tokio::test] +async fn handler_macros_should_omit_cache_hints_for_legacy_versions() { + let (tools, prompts) = list_results(ProtocolVersion::V_2025_11_25).await; + + assert_eq!( + ( + tools.ttl_ms, + tools.cache_scope, + prompts.ttl_ms, + prompts.cache_scope, + ), + (None, None, None, None) + ); +} From f57d58516825128f3d2cf76912ceb414037cbe75 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:20:22 -0400 Subject: [PATCH 318/333] chore: upgrade darling and syn (#1138) --- crates/rmcp-macros/Cargo.toml | 4 ++-- crates/rmcp-macros/src/prompt.rs | 16 +++++++++++++++- crates/rmcp-macros/src/tool.rs | 16 +++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/rmcp-macros/Cargo.toml b/crates/rmcp-macros/Cargo.toml index db94afc8c..9f0b3d309 100644 --- a/crates/rmcp-macros/Cargo.toml +++ b/crates/rmcp-macros/Cargo.toml @@ -16,11 +16,11 @@ documentation = "https://docs.rs/rmcp-macros" proc-macro = true [dependencies] -syn = {version = "2", features = ["full"]} +syn = {version = "3", features = ["full"]} quote = "1" proc-macro2 = "1" serde_json = "1.0" -darling = { version = "0.23" } +darling = { version = "0.24" } [features] local = [] diff --git a/crates/rmcp-macros/src/prompt.rs b/crates/rmcp-macros/src/prompt.rs index 1ebc510ed..ea35bc18c 100644 --- a/crates/rmcp-macros/src/prompt.rs +++ b/crates/rmcp-macros/src/prompt.rs @@ -133,7 +133,7 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result let new_output = syn::parse2::({ let mut lt = quote! { 'static }; if let Some(receiver) = fn_item.sig.receiver() - && let Some((_, receiver_lt)) = receiver.reference.as_ref() + && let syn::ReceiverKind::Reference(_, receiver_lt, _) = &receiver.kind { if let Some(receiver_lt) = receiver_lt { lt = quote! { #receiver_lt }; @@ -259,6 +259,20 @@ mod test { Ok(()) } + #[test] + fn test_async_prompt_preserves_receiver_lifetime() -> syn::Result<()> { + let attr = quote! {}; + let input = quote! { + async fn test_prompt_with_lifetime<'a>(&'a self) -> String { + "ok".to_string() + } + }; + let result = prompt(attr, input)?; + + assert!(result.to_string().contains("+ 'a")); + Ok(()) + } + #[test] fn test_async_prompt_local_omits_send() -> syn::Result<()> { let attr = quote! { local }; diff --git a/crates/rmcp-macros/src/tool.rs b/crates/rmcp-macros/src/tool.rs index 69f82e9d2..c640dd5cf 100644 --- a/crates/rmcp-macros/src/tool.rs +++ b/crates/rmcp-macros/src/tool.rs @@ -284,7 +284,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result { let new_output = syn::parse2::({ let mut lt = quote! { 'static }; if let Some(receiver) = fn_item.sig.receiver() - && let Some((_, receiver_lt)) = receiver.reference.as_ref() + && let syn::ReceiverKind::Reference(_, receiver_lt, _) = &receiver.kind { if let Some(receiver_lt) = receiver_lt { lt = quote! { #receiver_lt }; @@ -342,6 +342,20 @@ mod test { Ok(()) } + #[test] + fn test_async_tool_preserves_receiver_lifetime() -> syn::Result<()> { + let attr = quote! {}; + let input = quote! { + async fn test_tool_with_lifetime<'a>(&'a self) -> String { + "ok".to_string() + } + }; + let result = tool(attr, input)?; + + assert!(result.to_string().contains("+ 'a")); + Ok(()) + } + #[test] fn test_doc_comment_description() -> syn::Result<()> { let attr = quote! {}; // No explicit description From baac607e52b9788ec20902e2c7143ba4f4786f4b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:52:23 -0400 Subject: [PATCH 319/333] chore: release v3.1.1 (#1115) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp-macros/CHANGELOG.md | 10 ++++++++++ crates/rmcp/CHANGELOG.md | 12 ++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2a3f726ff..d80e72c40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.1.0", path = "./crates/rmcp" } -rmcp-macros = { version = "3.1.0", path = "./crates/rmcp-macros" } +rmcp = { version = "3.1.1", path = "./crates/rmcp" } +rmcp-macros = { version = "3.1.1", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.1.0" +version = "3.1.1" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 37a671aa2..86002d8f8 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.1.0...rmcp-macros-v3.1.1) - 2026-08-05 + +### Fixed + +- emit cache hints from handler macros ([#1120](https://github.com/modelcontextprotocol/rust-sdk/pull/1120)) + +### Other + +- upgrade darling and syn ([#1138](https://github.com/modelcontextprotocol/rust-sdk/pull/1138)) + ## [3.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.1...rmcp-macros-v3.1.0) - 2026-07-31 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index b4e8cf612..e788632c2 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.1.0...rmcp-v3.1.1) - 2026-08-05 + +### Fixed + +- emit cache hints from handler macros ([#1120](https://github.com/modelcontextprotocol/rust-sdk/pull/1120)) +- expose MRTR state to tool handlers ([#1104](https://github.com/modelcontextprotocol/rust-sdk/pull/1104)) +- disambiguate input-required results ([#1103](https://github.com/modelcontextprotocol/rust-sdk/pull/1103)) + +### Other + +- make async-trait optional ([#1119](https://github.com/modelcontextprotocol/rust-sdk/pull/1119)) + ## [3.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.0.1...rmcp-v3.1.0) - 2026-07-31 ### Added From 9a3168af8743fdb295fcd1bc274797ad2f999d3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:53:43 -0400 Subject: [PATCH 320/333] chore(deps): bump taiki-e/install-action from 2.85.6 to 2.85.7 (#1139) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.6 to 2.85.7. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.85.6...v2.85.7) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02d6fa263..8e75d9bf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2.85.6 + uses: taiki-e/install-action@v2.85.7 with: tool: cargo-semver-checks @@ -130,7 +130,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-public-api - uses: taiki-e/install-action@v2.85.6 + uses: taiki-e/install-action@v2.85.7 with: tool: cargo-public-api From e150d4f3a6fc92672d3250dee788d75e2d16b420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=82=E7=B3=96=E6=A2=A8=E5=AD=90?= Date: Fri, 7 Aug 2026 22:31:24 +0800 Subject: [PATCH 321/333] fix(auth): preserve issuer trailing slash during discovery (#1145) --- crates/rmcp/src/transport/auth.rs | 91 +++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 6f8987924..4a5c9d2dc 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1439,7 +1439,7 @@ impl AuthorizationManager { }); } - if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? { + if let Some(metadata) = self.try_discover_oauth_server(&self.base_url, None).await? { return Ok(AuthorizationMetadataResolution { metadata, source: AuthorizationMetadataSource::AuthorizationServerMetadata, @@ -2298,9 +2298,13 @@ impl AuthorizationManager { async fn try_discover_oauth_server( &self, base_url: &Url, + expected_issuer: Option<&str>, ) -> Result, AuthError> { for discovery_url in Self::generate_discovery_urls(base_url) { - if let Some(metadata) = self.fetch_authorization_metadata(&discovery_url).await? { + if let Some(metadata) = self + .fetch_authorization_metadata(&discovery_url, expected_issuer) + .await? + { return Ok(Some(metadata)); } } @@ -2310,6 +2314,7 @@ impl AuthorizationManager { async fn fetch_authorization_metadata( &self, discovery_url: &Url, + expected_issuer: Option<&str>, ) -> Result, AuthError> { debug!("discovery url: {:?}", discovery_url); let response = self @@ -2324,7 +2329,11 @@ impl AuthorizationManager { match serde_json::from_slice::(response.body()) { Ok(metadata) => { - self.validate_authorization_metadata_issuer(discovery_url, &metadata)?; + self.validate_authorization_metadata_issuer( + discovery_url, + expected_issuer, + &metadata, + )?; Ok(Some(metadata)) } Err(err) => { @@ -2388,11 +2397,13 @@ impl AuthorizationManager { fn validate_authorization_metadata_issuer( &self, discovery_url: &Url, + expected_issuer: Option<&str>, metadata: &AuthorizationMetadata, ) -> Result<(), AuthError> { - let Some(expected_issuer) = - Self::expected_issuer_for_authorization_metadata_url(discovery_url) - else { + let expected_issuer = expected_issuer + .map(str::to_owned) + .or_else(|| Self::expected_issuer_for_authorization_metadata_url(discovery_url)); + let Some(expected_issuer) = expected_issuer else { return Ok(()); }; let Some(received_issuer) = metadata.issuer.as_deref() else { @@ -2477,13 +2488,21 @@ impl AuthorizationManager { } if candidate_url.path().contains("/.well-known/") { - if let Some(metadata) = self.fetch_authorization_metadata(&candidate_url).await? { + if let Some(metadata) = self + .fetch_authorization_metadata(&candidate_url, None) + .await? + { return Ok(Some(metadata)); } continue; } - if let Some(metadata) = self.try_discover_oauth_server(&candidate_url).await? { + // Discovery URL construction removes a non-root trailing slash. Keep the issuer + // advertised by protected resource metadata for the exact RFC 8414 comparison. + if let Some(metadata) = self + .try_discover_oauth_server(&candidate_url, Some(candidate_url.as_str())) + .await? + { return Ok(Some(metadata)); } } @@ -4317,6 +4336,54 @@ mod tests { ); } + #[tokio::test] + async fn protected_resource_metadata_preserves_non_root_issuer_trailing_slash() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(401), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1/"] + }), + ), + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com/tenant1/"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com/tenant1/", + "authorization_endpoint": "https://auth.example.com/tenant1/authorize", + "token_endpoint": "https://auth.example.com/tenant1/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let metadata = manager.resolve_metadata().await.unwrap().metadata; + + assert_eq!( + ( + metadata.issuer.as_deref(), + client.requests().last().map(|request| request.uri.as_str()), + ), + ( + Some("https://auth.example.com/tenant1/"), + Some("https://auth.example.com/.well-known/oauth-authorization-server/tenant1"), + ) + ); + } + #[tokio::test] async fn authorization_metadata_rejects_mismatched_issuer() { let client = RecordingOAuthHttpClient::with_responses(vec![ @@ -4382,7 +4449,7 @@ mod tests { .unwrap(); manager - .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .validate_authorization_metadata_issuer(&discovery_url, None, &metadata) .unwrap(); } @@ -4414,7 +4481,7 @@ mod tests { .unwrap(); manager - .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .validate_authorization_metadata_issuer(&discovery_url, None, &metadata) .unwrap(); } @@ -4436,7 +4503,7 @@ mod tests { manager.set_allow_missing_issuer(true); manager - .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .validate_authorization_metadata_issuer(&discovery_url, None, &metadata) .unwrap(); } @@ -4456,7 +4523,7 @@ mod tests { .unwrap(); let error = manager - .validate_authorization_metadata_issuer(&discovery_url, &metadata) + .validate_authorization_metadata_issuer(&discovery_url, None, &metadata) .unwrap_err(); assert!( From 3c8fb2a7e431121797de238960d75395ff4a8b46 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Fri, 7 Aug 2026 09:34:18 -0500 Subject: [PATCH 322/333] fix(sse): loop instead of recursing when skipping SSE events (#1146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SseAutoReconnectStream::poll_next` recursed into itself for every event it skips — control frames, data-less frames, and frames whose data fails to deserialize — plus once more on every state transition. The inner stream returns `Poll::Ready` for each event it can parse out of already-buffered bytes, so a burst of skipped events has no yield point between them. Each one adds a stack frame, and `poll_next` is a large frame. A client connected to a server that emits a run of non-JSON `message` frames overflows the stack and aborts the process. Wrap the body in a `loop` and replace the four `self.poll_next(cx)` tail calls with `continue`. `this` is re-derived from `self.as_mut().project()` at the top of each iteration, so the borrows end cleanly per iteration; no other logic changes. The diff is mostly the resulting re-indent — review with `?w=1`. Adds `skipped_events_do_not_grow_the_stack`, which feeds 50,000 undeserializable frames through the stream. Before this change it aborts with `fatal runtime error: stack overflow` (SIGABRT); after, it passes. --- .../src/transport/common/client_side_sse.rs | 288 ++++++++++-------- 1 file changed, 164 insertions(+), 124 deletions(-) diff --git a/crates/rmcp/src/transport/common/client_side_sse.rs b/crates/rmcp/src/transport/common/client_side_sse.rs index 4ed05b44f..e668d63df 100644 --- a/crates/rmcp/src/transport/common/client_side_sse.rs +++ b/crates/rmcp/src/transport/common/client_side_sse.rs @@ -376,145 +376,157 @@ where mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { - let mut this = self.as_mut().project(); - // let this_state = this.state.as_mut().project() - let state = this.state.as_mut().project(); - let next_state = match state { - SseAutoReconnectStreamStateProj::Connected { stream } => { - match ready!(stream.poll_next(cx)) { - Some(Ok(sse)) => { - if let Some(new_server_retry) = sse.retry { - *this.server_retry_interval = - Some(Duration::from_millis(new_server_retry)); - } - if let Some(ref event_id) = sse.id { - *this.last_event_id = Some(event_id.clone()); - } - // Only treat blank/`message` events as JSON-RPC payloads. - // Other control frames (endpoint, ping, etc.) are passed to - // the reconnection handler. - let is_message_event = - matches!(sse.event.as_deref(), None | Some("") | Some("message")); - if !is_message_event { - match this.connector.handle_control_event(&sse) { - Ok(()) => return self.poll_next(cx), - Err(e) => { - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(Some(Err(e))); - } + loop { + let mut this = self.as_mut().project(); + // let this_state = this.state.as_mut().project() + let state = this.state.as_mut().project(); + let next_state = match state { + SseAutoReconnectStreamStateProj::Connected { stream } => { + match ready!(stream.poll_next(cx)) { + Some(Ok(sse)) => { + if let Some(new_server_retry) = sse.retry { + *this.server_retry_interval = + Some(Duration::from_millis(new_server_retry)); } - } - if let Some(data) = sse.data { - match serde_json::from_str::(&data) { - Err(e) => { - // Downgrade to debug to avoid noisy logs when servers emit - // non-JSON payloads as message frames. Include last_event_id - // to aid troubleshooting while keeping default behaviour. - let last_id = this.last_event_id.as_deref().unwrap_or(""); - tracing::debug!(last_event_id=%last_id, "failed to deserialize server message: {e}"); - return self.poll_next(cx); - } - Ok(message) => { - return Poll::Ready(Some(Ok(message))); + if let Some(ref event_id) = sse.id { + *this.last_event_id = Some(event_id.clone()); + } + // Only treat blank/`message` events as JSON-RPC payloads. + // Other control frames (endpoint, ping, etc.) are passed to + // the reconnection handler. + let is_message_event = + matches!(sse.event.as_deref(), None | Some("") | Some("message")); + if !is_message_event { + match this.connector.handle_control_event(&sse) { + Ok(()) => continue, + Err(e) => { + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(Some(Err(e))); + } } - }; - } else { - return self.poll_next(cx); - } - } - Some(Err(e)) => { - if is_event_too_large_error(&e) { - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); - } - if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); - } - this.connector - .handle_stream_error(&e, this.last_event_id.as_deref()); - let retrying = this - .connector - .retry_connection(this.last_event_id.as_deref()); - SseAutoReconnectStreamState::Retrying { - retry_times: 0, - retrying, - } - } - None => { - if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { - tracing::debug!( - "sse response ended before an event ID was received; cannot resume" - ); - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(None); + } + if let Some(data) = sse.data { + match serde_json::from_str::(&data) { + Err(e) => { + // Downgrade to debug to avoid noisy logs when servers emit + // non-JSON payloads as message frames. Include last_event_id + // to aid troubleshooting while keeping default behaviour. + let last_id = this.last_event_id.as_deref().unwrap_or(""); + tracing::debug!(last_event_id=%last_id, "failed to deserialize server message: {e}"); + continue; + } + Ok(message) => { + return Poll::Ready(Some(Ok(message))); + } + }; + } else { + continue; + } } - // Per SEP-1699, a graceful stream close is - // reconnectable. If the server sent a `retry` field - // we MUST wait that long before reconnecting. - let interval = this - .server_retry_interval - .take() - .or_else(|| this.retry_policy.retry(0)); - if let Some(interval) = interval { - tracing::debug!(?interval, "sse stream ended gracefully, reconnecting"); - SseAutoReconnectStreamState::WaitingNextRetry { - sleep: tokio::time::sleep(interval), + Some(Err(e)) => { + if is_event_too_large_error(&e) { + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready( + this.connector.map_fatal_stream_error(e).map(Err), + ); + } + if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready( + this.connector.map_fatal_stream_error(e).map(Err), + ); + } + this.connector + .handle_stream_error(&e, this.last_event_id.as_deref()); + let retrying = this + .connector + .retry_connection(this.last_event_id.as_deref()); + SseAutoReconnectStreamState::Retrying { retry_times: 0, + retrying, } - } else { - tracing::debug!("sse stream terminated, no reconnect policy"); - return Poll::Ready(None); } - } - } - } - SseAutoReconnectStreamStateProj::Retrying { - retry_times, - retrying, - } => { - let retry_result = ready!(retrying.poll(cx)); - match retry_result { - Ok(new_stream) => SseAutoReconnectStreamState::Connected { stream: new_stream }, - Err(e) => { - tracing::debug!("retry sse stream error: {e}"); - *retry_times += 1; - if let Some(interval) = this.retry_policy.retry(*retry_times) { + None => { + if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { + tracing::debug!( + "sse response ended before an event ID was received; cannot resume" + ); + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(None); + } + // Per SEP-1699, a graceful stream close is + // reconnectable. If the server sent a `retry` field + // we MUST wait that long before reconnecting. let interval = this .server_retry_interval - .map(|server_retry_interval| server_retry_interval.max(interval)) - .unwrap_or(interval); - let sleep = tokio::time::sleep(interval); - SseAutoReconnectStreamState::WaitingNextRetry { - sleep, - retry_times: *retry_times, + .take() + .or_else(|| this.retry_policy.retry(0)); + if let Some(interval) = interval { + tracing::debug!( + ?interval, + "sse stream ended gracefully, reconnecting" + ); + SseAutoReconnectStreamState::WaitingNextRetry { + sleep: tokio::time::sleep(interval), + retry_times: 0, + } + } else { + tracing::debug!("sse stream terminated, no reconnect policy"); + return Poll::Ready(None); } - } else { - tracing::error!("sse stream error: {e}, max retry times reached"); - this.state.set(SseAutoReconnectStreamState::Terminated); - return Poll::Ready(Some(Err(e))); } } } - } - SseAutoReconnectStreamStateProj::WaitingNextRetry { sleep, retry_times } => { - ready!(sleep.poll(cx)); - let retrying = this - .connector - .retry_connection(this.last_event_id.as_deref()); - let retry_times = *retry_times; - SseAutoReconnectStreamState::Retrying { + SseAutoReconnectStreamStateProj::Retrying { retry_times, retrying, + } => { + let retry_result = ready!(retrying.poll(cx)); + match retry_result { + Ok(new_stream) => { + SseAutoReconnectStreamState::Connected { stream: new_stream } + } + Err(e) => { + tracing::debug!("retry sse stream error: {e}"); + *retry_times += 1; + if let Some(interval) = this.retry_policy.retry(*retry_times) { + let interval = this + .server_retry_interval + .map(|server_retry_interval| { + server_retry_interval.max(interval) + }) + .unwrap_or(interval); + let sleep = tokio::time::sleep(interval); + SseAutoReconnectStreamState::WaitingNextRetry { + sleep, + retry_times: *retry_times, + } + } else { + tracing::error!("sse stream error: {e}, max retry times reached"); + this.state.set(SseAutoReconnectStreamState::Terminated); + return Poll::Ready(Some(Err(e))); + } + } + } } - } - SseAutoReconnectStreamStateProj::Terminated => { - return Poll::Ready(None); - } - }; - // update the state - this.state.set(next_state); - self.poll_next(cx) + SseAutoReconnectStreamStateProj::WaitingNextRetry { sleep, retry_times } => { + ready!(sleep.poll(cx)); + let retrying = this + .connector + .retry_connection(this.last_event_id.as_deref()); + let retry_times = *retry_times; + SseAutoReconnectStreamState::Retrying { + retry_times, + retrying, + } + } + SseAutoReconnectStreamStateProj::Terminated => { + return Poll::Ready(None); + } + }; + // update the state + this.state.set(next_state); + } } } @@ -716,6 +728,34 @@ mod tests { ); } + #[tokio::test] + async fn skipped_events_do_not_grow_the_stack() { + // Every frame here fails to deserialize, so each one takes the "skip this + // event" path. The inner stream is always ready, so nothing yields in + // between: when that path recursed into poll_next instead of looping, this + // overflowed the stack and aborted the process. + const SKIPPED_EVENTS: usize = 50_000; + let mut payload = Vec::with_capacity(SKIPPED_EVENTS * 9); + for _ in 0..SKIPPED_EVENTS { + payload.extend_from_slice(b"data: x\n\n"); + } + + let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from(payload))]); + let attempts = Arc::new(AtomicUsize::new(0)); + let connector = CountingReconnect { + attempts: attempts.clone(), + }; + let stream = SseAutoReconnectStream::new( + bounded_sse_stream(source, 1024), + connector, + Arc::new(NeverRetry), + ); + let mut stream = std::pin::pin!(stream); + + assert!(stream.next().await.is_none()); + assert_eq!(attempts.load(Ordering::Relaxed), 0); + } + #[tokio::test] async fn response_without_event_id_does_not_reconnect() { let attempts = Arc::new(AtomicUsize::new(0)); From 8fb3e046a17ac95335de11003a41a2eb097d7d58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:31:36 -0400 Subject: [PATCH 323/333] chore(deps): bump github/codeql-action from 4.37.4 to 4.37.6 (#1154) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.6) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6be3da16d..e8be9ffc6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,13 +24,13 @@ jobs: uses: actions/checkout@v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.6 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.4 + uses: github/codeql-action/autobuild@v4.37.6 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.6 From f8e63824d3a5ae99d2e7282d1bfc817f96ceb412 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:31:54 -0400 Subject: [PATCH 324/333] chore(deps): bump taiki-e/install-action from 2.85.7 to 2.85.8 (#1153) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.7 to 2.85.8. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.85.7...v2.85.8) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e75d9bf6..f781e4624 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2.85.7 + uses: taiki-e/install-action@v2.85.8 with: tool: cargo-semver-checks @@ -130,7 +130,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-public-api - uses: taiki-e/install-action@v2.85.7 + uses: taiki-e/install-action@v2.85.8 with: tool: cargo-public-api From c3450788671d214f444ac668dd356ccc48f643b5 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Fri, 7 Aug 2026 14:39:55 -0500 Subject: [PATCH 325/333] fix(auth): map 401/403 challenges on the SSE GET stream (#1152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `post_message_with_max_sse_event_size` turns a 401 or 403 carrying a `WWW-Authenticate` header into `StreamableHttpError::AuthRequired` / `InsufficientScope`. `get_stream_with_max_sse_event_size`, in the same file, handles only 405 and then falls through to `error_for_status()?`, so the same challenge becomes an opaque `StreamableHttpError::Client`. That matters because `AuthClient::get_stream` routes through `call_reacting_to_challenges`, whose whole purpose is to catch `AuthRequired`, run `try_refresh_or_reauth()`, and retry once. It never sees that variant from this path, so an expired token on the standalone SSE stream is never refreshed — the stream just fails, while the identical expiry on `post_message` recovers silently. Copies the two blocks verbatim from `post_message` in the same file. `unix_socket.rs` already does the same thing in its own `get_stream`. Adds four tests against an axum mock server: a 401 with a challenge maps to `AuthRequired`, a 403 maps to `InsufficientScope` with the scope extracted, a 401 *without* a challenge is still not `AuthRequired`, and 405 keeps reporting `ServerDoesNotSupportSse`. The first two fail before this change with `Err(Client(reqwest::Error { kind: Status(401, None) }))`; the last two pass either way and exist to catch an over-broad fix. --- .../common/reqwest/streamable_http_client.rs | 31 ++++++ ...reamable_http_get_stream_auth_challenge.rs | 105 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 crates/rmcp/tests/test_streamable_http_get_stream_auth_challenge.rs diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index e2eeebb4a..7f08b6c25 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -94,6 +94,37 @@ impl StreamableHttpClient for reqwest::Client { if response.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { return Err(StreamableHttpError::ServerDoesNotSupportSse); } + if response.status() == reqwest::StatusCode::UNAUTHORIZED + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header = header + .to_str() + .map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })? + .to_string(); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError { + www_authenticate_header: header, + })); + } + if response.status() == reqwest::StatusCode::FORBIDDEN + && let Some(header) = response.headers().get(WWW_AUTHENTICATE) + { + let header_str = header.to_str().map_err(|_| { + StreamableHttpError::UnexpectedServerResponse(Cow::from( + "invalid www-authenticate header value", + )) + })?; + let scope = extract_scope_from_header(header_str); + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError { + www_authenticate_header: header_str.to_string(), + required_scope: scope, + }, + )); + } let response = response.error_for_status()?; match response.headers().get(reqwest::header::CONTENT_TYPE) { Some(ct) => { diff --git a/crates/rmcp/tests/test_streamable_http_get_stream_auth_challenge.rs b/crates/rmcp/tests/test_streamable_http_get_stream_auth_challenge.rs new file mode 100644 index 000000000..dcd1b8d91 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_get_stream_auth_challenge.rs @@ -0,0 +1,105 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError}; + +/// Spin up a minimal axum server whose GET handler always responds with the given +/// status and optional `WWW-Authenticate` header — no MCP logic involved. +async fn spawn_mock_server(status: u16, www_authenticate: Option<&'static str>) -> String { + use axum::{Router, body::Body, http::Response, routing::get}; + + let router = Router::new().route( + "/mcp", + get(move || async move { + let mut builder = Response::builder().status(status); + if let Some(challenge) = www_authenticate { + builder = builder.header("www-authenticate", challenge); + } + builder.body(Body::empty()).unwrap() + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + format!("http://{addr}/mcp") +} + +async fn get_stream_against( + status: u16, + www_authenticate: Option<&'static str>, +) -> Result<(), StreamableHttpError> { + let url = spawn_mock_server(status, www_authenticate).await; + reqwest::Client::new() + .get_stream(Arc::from(url.as_str()), None, None, None, HashMap::new()) + .await + .map(|_| ()) +} + +/// A 401 carrying a `WWW-Authenticate` challenge must surface as `AuthRequired`, +/// which is the variant `AuthClient::call_reacting_to_challenges` catches to +/// refresh the token and retry. Classified as a plain `Client` error instead, the +/// refresh never runs and the stream just fails. +#[tokio::test] +async fn get_stream_maps_401_challenge_to_auth_required() { + let result = get_stream_against(401, Some("Bearer realm=\"mcp\"")).await; + + match result { + Err(StreamableHttpError::AuthRequired(err)) => { + assert_eq!(err.www_authenticate_header, "Bearer realm=\"mcp\""); + } + other => panic!("expected AuthRequired, got: {other:?}"), + } +} + +/// A 403 carrying a challenge must surface as `InsufficientScope`, with the +/// required scope extracted from the header. +#[tokio::test] +async fn get_stream_maps_403_challenge_to_insufficient_scope() { + let result = get_stream_against( + 403, + Some("Bearer error=\"insufficient_scope\", scope=\"mcp:read\""), + ) + .await; + + match result { + Err(StreamableHttpError::InsufficientScope(err)) => { + assert_eq!(err.required_scope.as_deref(), Some("mcp:read")); + } + other => panic!("expected InsufficientScope, got: {other:?}"), + } +} + +/// Without a `WWW-Authenticate` header there is no challenge to act on, so a 401 +/// must keep falling through to the ordinary error path rather than being +/// reported as an auth challenge the caller can retry. +#[tokio::test] +async fn get_stream_401_without_challenge_is_not_auth_required() { + let result = get_stream_against(401, None).await; + + assert!( + !matches!(result, Err(StreamableHttpError::AuthRequired(_))), + "401 with no challenge header must not be classified as AuthRequired" + ); + assert!(result.is_err(), "401 must still be an error"); +} + +/// 405 keeps its dedicated meaning — the server does not support SSE on GET — +/// and must not be swallowed by the new auth branches. +#[tokio::test] +async fn get_stream_405_still_reports_sse_unsupported() { + let result = get_stream_against(405, None).await; + + assert!( + matches!(result, Err(StreamableHttpError::ServerDoesNotSupportSse)), + "expected ServerDoesNotSupportSse, got: {result:?}" + ); +} From 02c62aef2e331e5cf79c06c744eb1eb052cc8ebd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:32:03 -0400 Subject: [PATCH 326/333] chore: release v3.1.2 (#1148) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Cargo.toml | 6 +++--- crates/rmcp/CHANGELOG.md | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d80e72c40..79f8aebb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" [workspace.dependencies] -rmcp = { version = "3.1.1", path = "./crates/rmcp" } -rmcp-macros = { version = "3.1.1", path = "./crates/rmcp-macros" } +rmcp = { version = "3.1.2", path = "./crates/rmcp" } +rmcp-macros = { version = "3.1.2", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.1.1" +version = "3.1.2" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index e788632c2..80867764b 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.1.1...rmcp-v3.1.2) - 2026-08-07 + +### Fixed + +- *(auth)* map 401/403 challenges on the SSE GET stream ([#1152](https://github.com/modelcontextprotocol/rust-sdk/pull/1152)) +- *(sse)* loop instead of recursing when skipping SSE events ([#1146](https://github.com/modelcontextprotocol/rust-sdk/pull/1146)) +- *(auth)* preserve issuer trailing slash during discovery ([#1145](https://github.com/modelcontextprotocol/rust-sdk/pull/1145)) + ## [3.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.1.0...rmcp-v3.1.1) - 2026-08-05 ### Fixed From 22be2cc5ad2f42d9f83d670e4f97b125b695cd7f Mon Sep 17 00:00:00 2001 From: ip2a Date: Mon, 10 Aug 2026 02:07:50 +0800 Subject: [PATCH 327/333] fix(client): classify discover outcome at source, not at the error type (#1133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ClientLifecycleMode::Auto` only fell back from `server/discover` on `-32601`, so legacy servers that reject the probe with other codes (`-32600`, `-32602`, implementation-defined errors) failed to connect even though `initialize` would have succeeded. The previous attempt (indicates_legacy_server) classified the failure after the fact by reverse-engineering the error type. This rewrite moves the classification into `discover_startup` itself, where the full context (request id, response correlation, transport state) is still available. `discover_startup` now returns `DiscoverOutcome`: `Modern` on success, `Legacy(error)` when the probe received a complete, correlated JSON-RPC error whose code is not a modern-era rejection. Every other failure becomes `Err`, so `Auto` simply matches the outcome — no methods on `ClientInitializeError`, no downcast, no transport-specific types leaking into the generic lifecycle layer. Additional fixes that fall out naturally: - Response correlation is now checked in `expect_response` for both success and error branches. Previously error responses skipped id correlation entirely. A new `UncorrelatedErrorResponse` variant surfaces responses that cannot be tied to the request. - When both discover and the legacy fallback fail, a `LegacyFallbackFailed` compound error preserves both phases instead of discarding the discover error. Fixes #1040. --- crates/rmcp/src/service/client.rs | 174 +++++++--- .../rmcp/tests/test_client_initialization.rs | 11 +- .../rmcp/tests/test_client_lifecycle_modes.rs | 328 ++++++++++++++++++ 3 files changed, 468 insertions(+), 45 deletions(-) diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 2e25b13a3..54bfb9407 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -49,6 +49,14 @@ pub enum ClientInitializeError { #[error("conflict initialized response id: expected {0}, got {1}")] ConflictInitResponseId(RequestId, RequestId), + #[error( + "uncorrelated error response: expected id {expected}, error response carried {received}" + )] + UncorrelatedErrorResponse { + expected: RequestId, + received: RequestId, + }, + #[error("connection closed: {0}")] ConnectionClosed(String), @@ -74,6 +82,12 @@ pub enum ClientInitializeError { #[error("Cancelled")] Cancelled, + + #[error("discover and legacy initialize both failed")] + LegacyFallbackFailed { + discover: Box, + fallback: Box, + }, } impl ClientInitializeError { @@ -96,8 +110,13 @@ impl ClientInitializeError { pub fn auth_challenge(&self) -> Option<&str> { use crate::transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError}; - let Self::TransportError { error, .. } = self else { - return None; + let error = match self { + Self::TransportError { error, .. } => error, + // A 401/403 in the fallback phase is still actionable. + Self::LegacyFallbackFailed { fallback, .. } => { + return fallback.auth_challenge(); + } + _ => return None, }; let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error.error.as_ref()); while let Some(current) = source { @@ -117,10 +136,11 @@ impl ClientInitializeError { /// This covers both missing or expired local OAuth authorization and an HTTP /// authorization challenge from the MCP server. pub fn is_authorization_required(&self) -> bool { - matches!( - self, - Self::TransportError { error, .. } if error.is_authorization_required() - ) + match self { + Self::TransportError { error, .. } => error.is_authorization_required(), + Self::LegacyFallbackFailed { fallback, .. } => fallback.is_authorization_required(), + _ => false, + } } } @@ -138,13 +158,20 @@ where .ok_or_else(|| ClientInitializeError::ConnectionClosed(context.to_string())) } -/// Helper function to expect a response from the stream +/// Helper function to expect a response from the stream, correlated to +/// `expected_id`. +/// +/// Both success and error responses are checked here: a mismatched id on a +/// success response is `ConflictInitResponseId`; on an error response (whose +/// `id` is optional per spec) it is `UncorrelatedErrorResponse`. The caller +/// never sees an uncorrelated response. async fn expect_response( transport: &mut T, context: &str, service: &S, peer: Peer, -) -> Result<(ServerResult, RequestId), ClientInitializeError> + expected_id: &RequestId, +) -> Result where T: Transport, S: Service, @@ -152,13 +179,29 @@ where loop { let message = expect_next_message(transport, context).await?; match message { - // Expected message to complete the initialization ServerJsonRpcMessage::Response(JsonRpcResponse { id, result, .. }) => { - break Ok((result, id)); + if !expected_id.matches_response_id(&id) { + return Err(ClientInitializeError::ConflictInitResponseId( + expected_id.clone(), + id, + )); + } + return Ok(result); } - // Handle JSON-RPC error responses ServerJsonRpcMessage::Error(error) => { - break Err(ClientInitializeError::JsonRpcError(error.error)); + return Err(match &error.id { + Some(id) if expected_id.matches_response_id(id) => { + ClientInitializeError::JsonRpcError(error.error) + } + // Spec: error id is optional; a server that cannot read + // the request id omits it. The error is still a response + // to our request, so it remains available to the caller. + None => ClientInitializeError::JsonRpcError(error.error), + Some(id) => ClientInitializeError::UncorrelatedErrorResponse { + expected: expected_id.clone(), + received: id.clone(), + }, + }); } // Server could send logging messages before handshake ServerJsonRpcMessage::Notification(mut notification) => { @@ -714,7 +757,7 @@ where legacy_startup(&service, &mut transport, &id_provider, &peer, client_info).await?; } ClientLifecycleMode::Discover { preferred_versions } => { - discover_startup( + match discover_startup( &service, &mut transport, &id_provider, @@ -722,13 +765,18 @@ where &client_info, preferred_versions, ) - .await?; + .await? + { + DiscoverOutcome::Modern => {} + // Discover mode does not fall back; a legacy server is an error. + DiscoverOutcome::Legacy(error) => return Err(*error), + } } ClientLifecycleMode::Auto { preferred_versions, legacy_version, } => { - let discover_result = discover_startup( + match discover_startup( &service, &mut transport, &id_provider, @@ -736,18 +784,23 @@ where &client_info, preferred_versions, ) - .await; - match discover_result { - Ok(()) => {} - Err(ClientInitializeError::JsonRpcError(error)) - if error.code == crate::model::ErrorCode::METHOD_NOT_FOUND => - { + .await + { + Ok(DiscoverOutcome::Modern) => {} + Ok(DiscoverOutcome::Legacy(discover_error)) => { let mut legacy_info = client_info; if let Some(version) = legacy_version { legacy_info.protocol_version = version; } - legacy_startup(&service, &mut transport, &id_provider, &peer, legacy_info) - .await?; + if let Err(fallback_error) = + legacy_startup(&service, &mut transport, &id_provider, &peer, legacy_info) + .await + { + return Err(ClientInitializeError::LegacyFallbackFailed { + discover: discover_error, + fallback: Box::new(fallback_error), + }); + } } Err(error) => return Err(error), } @@ -756,6 +809,41 @@ where Ok(serve_inner(service, transport, peer, peer_rx, ct)) } +/// Modern-era JSON-RPC error codes a server can return from `server/discover` +/// without being legacy. Version negotiation (`UNSUPPORTED_PROTOCOL_VERSION`) +/// is handled by `discover_startup`'s own retry loop and never reaches the +/// classification below. +/// +/// `ErrorCode` is an open integer type, so this cannot be exhaustive: if a +/// future revision adds another modern-era rejection code, add it here. +fn is_modern_rejection_code(code: crate::model::ErrorCode) -> bool { + matches!( + code, + crate::model::ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY + | crate::model::ErrorCode::HEADER_MISMATCH + ) +} + +/// The outcome of a `server/discover` probe, classified at the point where all +/// the context (request id, response correlation, transport state) is still +/// available. +/// +/// `Legacy` is returned only when the probe produced a complete, correlated +/// JSON-RPC error whose code is not a modern-era rejection — i.e. the +/// transport is in a known-good state and the error identifies the peer as +/// legacy per the 2026-07-28 backward-compatibility guidance. Every other +/// failure (transport error, uncorrelated response, modern rejection, etc.) +/// becomes `Err` so the caller surfaces it instead of retrying. +enum DiscoverOutcome { + /// The server speaks the modern protocol; discovery succeeded. + Modern, + /// The server is legacy: discovery received a correlated, non-modern + /// JSON-RPC error. The transport is still usable for a legacy `initialize` + /// handshake. The original error is preserved so a failed fallback can + /// report both phases. + Legacy(Box), +} + async fn legacy_startup( service: &S, transport: &mut T, @@ -784,15 +872,8 @@ where context: "send initialize request".into(), })?; - let (response, response_id) = - expect_response(transport, "initialize response", service, peer.clone()).await?; - - if !id.matches_response_id(&response_id) { - return Err(ClientInitializeError::ConflictInitResponseId( - id, - response_id, - )); - } + let response = + expect_response(transport, "initialize response", service, peer.clone(), &id).await?; let ServerResult::InitializeResult(initialize_result) = response else { return Err(ClientInitializeError::ExpectedInitResult(Some(response))); @@ -819,7 +900,7 @@ async fn discover_startup( peer: &Peer, client_info: &ClientInfo, preferred_versions: Vec, -) -> Result<(), ClientInitializeError> +) -> Result where S: Service, T: Transport + 'static, @@ -851,14 +932,8 @@ where ClientInitializeError::transport::(error, "send discover request") })?; - match expect_response(transport, "discover response", service, peer.clone()).await { - Ok((ServerResult::DiscoverResult(result), response_id)) => { - if !id.matches_response_id(&response_id) { - return Err(ClientInitializeError::ConflictInitResponseId( - id, - response_id, - )); - } + match expect_response(transport, "discover response", service, peer.clone(), &id).await { + Ok(ServerResult::DiscoverResult(result)) => { let Some(selected) = select_protocol_version(&preferred_versions, &result.supported_versions) else { @@ -876,9 +951,9 @@ where client_info: client_info.client_info.clone(), client_capabilities: client_info.capabilities.clone(), }); - return Ok(()); + return Ok(DiscoverOutcome::Modern); } - Ok((response, _)) => { + Ok(response) => { return Err(ClientInitializeError::ExpectedInitResult(Some(response))); } Err(ClientInitializeError::JsonRpcError(error)) @@ -912,6 +987,19 @@ where }; candidate = next; } + // A correlated JSON-RPC error that is not a modern-era rejection + // and not a version-negotiation signal: the server is legacy. + // The transport delivered a complete response, so a legacy + // `initialize` can follow on the same connection. + Err(error) + if matches!( + &error, + ClientInitializeError::JsonRpcError(data) + if !is_modern_rejection_code(data.code) + ) => + { + return Ok(DiscoverOutcome::Legacy(Box::new(error))); + } Err(error) => return Err(error), } } diff --git a/crates/rmcp/tests/test_client_initialization.rs b/crates/rmcp/tests/test_client_initialization.rs index 960e1cf53..0ca0182f9 100644 --- a/crates/rmcp/tests/test_client_initialization.rs +++ b/crates/rmcp/tests/test_client_initialization.rs @@ -125,11 +125,18 @@ async fn test_client_init_handles_jsonrpc_error() { }); tokio::spawn(async move { - let _init_request = server.receive().await; + let request = server.receive().await; + // Echo the request's own id back on the error so it correlates: an + // uncorrelated id would surface as `UncorrelatedErrorResponse` + // instead of the `JsonRpcError` this test exercises. + let request_id = request + .and_then(|message| message.into_request()) + .map(|(_, id)| id) + .expect("client sent an initialize request"); let error_msg = ServerJsonRpcMessage::Error(JsonRpcError { jsonrpc: JsonRpcVersion2_0, - id: Some(RequestId::Number(1)), + id: Some(request_id), error: ErrorData { code: ErrorCode(-32600), message: Cow::Borrowed("Invalid Request"), diff --git a/crates/rmcp/tests/test_client_lifecycle_modes.rs b/crates/rmcp/tests/test_client_lifecycle_modes.rs index 4b75c3550..5fa2cd191 100644 --- a/crates/rmcp/tests/test_client_lifecycle_modes.rs +++ b/crates/rmcp/tests/test_client_lifecycle_modes.rs @@ -337,6 +337,334 @@ async fn auto_startup_falls_back_after_discover_method_not_found() { server_task.await.expect("server task"); } +/// Drives an `Auto` client through a single `server/discover` probe and asserts +/// the legacy fallback decision against the response the server sends back. +/// +/// When `expect_fallback` is set, the server also accepts the subsequent +/// `initialize` request and the client is expected to connect. Otherwise the +/// client must surface the discover error without sending `initialize`, and the +/// server's next receive must not be an initialize request. +async fn run_auto_discover_response_scenario(error: ErrorData, expect_fallback: bool) { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(discover) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + assert!(matches!( + discover.request, + ClientRequest::DiscoverRequest(_) + )); + server + .send(ServerJsonRpcMessage::error(error, Some(discover.id))) + .await + .expect("send discover error response"); + + if expect_fallback { + let ClientJsonRpcMessage::Request(initialize) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected request"); + }; + assert!(matches!( + initialize.request, + ClientRequest::InitializeRequest(_) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult(InitializeResult::new( + ServerCapabilities::default(), + )), + initialize.id, + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + } else { + // The client must surface the error without falling back, so no + // initialize request should follow. The transport closes when the + // failed client is dropped. + if let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await { + panic!( + "client fell back to {:?} but should have surfaced the modern error", + request.request + ); + } + } + }); + + let client_result = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await; + + if expect_fallback { + let client = client_result.expect("auto client should fall back to initialize"); + client.cancel().await.expect("cancel client"); + } else { + assert!( + client_result.is_err(), + "modern error should surface without legacy fallback" + ); + } + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn auto_startup_falls_back_after_discover_invalid_request() { + // Legacy servers commonly reject an unknown pre-initialize request with + // `-32600` (e.g. a session middleware that requires `initialize` first). + run_auto_discover_response_scenario( + ErrorData::new(ErrorCode::INVALID_REQUEST, "Bad Request", None), + true, + ) + .await; +} + +#[tokio::test] +async fn auto_startup_falls_back_after_discover_invalid_params() { + // `-32602` is explicitly called out by the specification as an + // implementation-defined response legacy servers use for unknown requests. + run_auto_discover_response_scenario( + ErrorData::new(ErrorCode::INVALID_PARAMS, "Invalid params", None), + true, + ) + .await; +} + +#[tokio::test] +async fn auto_startup_does_not_fall_back_for_missing_required_capability() { + // A `MISSING_REQUIRED_CLIENT_CAPABILITY` response identifies a modern + // server; falling back to `initialize` would not address it. + run_auto_discover_response_scenario( + ErrorData::new( + ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY, + "Missing required client capability", + None, + ), + false, + ) + .await; +} + +#[tokio::test] +async fn auto_startup_does_not_fall_back_for_header_mismatch() { + // A `HEADER_MISMATCH` response identifies a modern server performing + // header validation; falling back to `initialize` would not address it. + run_auto_discover_response_scenario( + ErrorData::new(ErrorCode::HEADER_MISMATCH, "Header mismatch", None), + false, + ) + .await; +} + +#[tokio::test] +async fn auto_startup_preserves_both_errors_when_fallback_also_fails() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + // Discover: legacy rejection. + let ClientJsonRpcMessage::Request(discover) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + server + .send(ServerJsonRpcMessage::error( + ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "not found", None), + Some(discover.id), + )) + .await + .expect("send discover error"); + + // Initialize: close the transport instead of responding. + let ClientJsonRpcMessage::Request(_) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected request"); + }; + drop(server); // closes the transport + }); + + let result = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await; + + let err = result.err().expect("both phases should fail"); + match err { + rmcp::service::ClientInitializeError::LegacyFallbackFailed { discover, fallback } => { + assert!( + matches!( + *discover, + rmcp::service::ClientInitializeError::JsonRpcError(_) + ), + "discover phase should be a JsonRpcError, got {discover:?}" + ); + assert!( + matches!( + *fallback, + rmcp::service::ClientInitializeError::ConnectionClosed(_) + ), + "fallback phase should be ConnectionClosed, got {fallback:?}" + ); + } + other => panic!("expected LegacyFallbackFailed, got {other:?}"), + } + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn auto_startup_surfaces_uncorrelated_error_id() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(_) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + // Respond with an error carrying a different id. + server + .send(ServerJsonRpcMessage::error( + ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "not found", None), + Some(RequestId::Number(999)), + )) + .await + .expect("send mismatched-id error"); + }); + + let result = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await; + + assert!( + matches!( + result, + Err(rmcp::service::ClientInitializeError::UncorrelatedErrorResponse { .. }) + ), + "mismatched id should surface as UncorrelatedErrorResponse, got {:?}", + result.as_ref().err() + ); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn auto_startup_falls_back_for_absent_error_id() { + // Spec: error id is optional; a server that cannot read the request id + // omits it. The error is still a response to our request, so it should + // trigger legacy fallback. + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(_discover) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + server + .send(ServerJsonRpcMessage::error( + ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "not found", None), + None, + )) + .await + .expect("send no-id error"); + + // Client should fall back to initialize. + let ClientJsonRpcMessage::Request(initialize) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected request"); + }; + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult( + InitializeResult::new(ServerCapabilities::default()), + ), + initialize.id, + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + }); + + let client = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + ) + .await + .expect("absent-id error should trigger legacy fallback"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn discover_mode_surfaces_legacy_error() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = IntoTransport::::into_transport(server_transport); + let server_task = tokio::spawn(async move { + let ClientJsonRpcMessage::Request(discover) = + server.receive().await.expect("expected discover request") + else { + panic!("expected request"); + }; + server + .send(ServerJsonRpcMessage::error( + ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "not found", None), + Some(discover.id), + )) + .await + .expect("send discover error"); + }); + + let result = DiscoverClient + .serve_with_lifecycle( + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + }, + ) + .await; + + assert!( + matches!( + result, + Err(rmcp::service::ClientInitializeError::JsonRpcError(_)) + ), + "Discover mode should surface a legacy error, not fall back, got {:?}", + result.as_ref().err() + ); + server_task.await.expect("server task"); +} + #[tokio::test] async fn discover_startup_retries_a_mutually_supported_version() { let unsupported: ProtocolVersion = From b5cf34eee74cb3e8e4ce7b535798f0fe216eb3e2 Mon Sep 17 00:00:00 2001 From: Recoordinate Date: Mon, 10 Aug 2026 06:23:10 +1200 Subject: [PATCH 328/333] Fix typo in to_authorized_http_client doc comment (#1158) --- crates/rmcp/src/transport/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 4a5c9d2dc..2b20391ac 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -3682,7 +3682,7 @@ impl OAuthState { Err(AuthError::InternalError("Not in session state".to_string())) } } - /// covert to authorized http client + /// convert to authorized http client pub async fn to_authorized_http_client(&mut self) -> Result<(), AuthError> { let placeholder = self.placeholder_state().await?; if let OAuthState::Authorized(manager) = std::mem::replace(self, placeholder) { From a0e103c9701f5366328d41a72d512c59e66a6018 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:20:13 -0400 Subject: [PATCH 329/333] fix: time out auto discovery probe (#1149) --- README.md | 2 +- crates/rmcp/src/service/client.rs | 117 ++++++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b6c580e17..6ab22d67c 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ let client = ClientInfo::default() .await?; // Or probe the discover lifecycle and fall back when a legacy server reports -// that server/discover is not implemented. +// that server/discover is not implemented or does not respond within 10 seconds. let client = ClientInfo::default() .serve_with_lifecycle( transport, diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 54bfb9407..520410fb1 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -632,13 +632,16 @@ pub enum ClientLifecycleMode { Discover { preferred_versions: Vec, }, - /// Probe with `server/discover`, falling back only when the peer proves it is legacy. + /// Probe with `server/discover`, falling back when the peer reports that it is legacy or does + /// not respond within 10 seconds. Auto { preferred_versions: Vec, legacy_version: Option, }, } +const DEFAULT_AUTO_DISCOVER_TIMEOUT: Duration = Duration::from_secs(10); + /// Client-specific lifecycle entry points. pub trait ClientServiceExt: Service + Sized { fn serve_with_lifecycle( @@ -730,7 +733,13 @@ where E: std::error::Error + Send + Sync + 'static, { tokio::select! { - result = serve_client_with_ct_inner(service, transport.into_transport(), lifecycle, ct.clone()) => { result } + result = serve_client_with_ct_inner( + service, + transport.into_transport(), + lifecycle, + ct.clone(), + DEFAULT_AUTO_DISCOVER_TIMEOUT, + ) => { result } _ = ct.cancelled() => { Err(ClientInitializeError::Cancelled) } @@ -742,6 +751,7 @@ async fn serve_client_with_ct_inner( transport: T, lifecycle: ClientLifecycleMode, ct: CancellationToken, + auto_discover_timeout: Duration, ) -> Result, ClientInitializeError> where S: Service, @@ -776,18 +786,21 @@ where preferred_versions, legacy_version, } => { - match discover_startup( - &service, - &mut transport, - &id_provider, - &peer, - &client_info, - preferred_versions, + let discover_result = tokio::time::timeout( + auto_discover_timeout, + discover_startup( + &service, + &mut transport, + &id_provider, + &peer, + &client_info, + preferred_versions, + ), ) - .await - { - Ok(DiscoverOutcome::Modern) => {} - Ok(DiscoverOutcome::Legacy(discover_error)) => { + .await; + match discover_result { + Ok(Ok(DiscoverOutcome::Modern)) => {} + Ok(Ok(DiscoverOutcome::Legacy(discover_error))) => { let mut legacy_info = client_info; if let Some(version) = legacy_version { legacy_info.protocol_version = version; @@ -802,7 +815,15 @@ where }); } } - Err(error) => return Err(error), + Ok(Err(error)) => return Err(error), + Err(_) => { + let mut legacy_info = client_info; + if let Some(version) = legacy_version { + legacy_info.protocol_version = version; + } + legacy_startup(&service, &mut transport, &id_provider, &peer, legacy_info) + .await?; + } } } } @@ -2172,6 +2193,74 @@ where mod tests { use super::*; + #[tokio::test] + async fn auto_startup_falls_back_when_discover_is_ignored() { + use crate::model::{InitializeResult, ServerCapabilities}; + + tokio::task::LocalSet::new() + .run_until(async { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = + crate::transport::IntoTransport::::into_transport( + server_transport, + ); + let server_task = tokio::task::spawn_local(async move { + let ClientJsonRpcMessage::Request(discover) = + server.receive().await.expect("expected discover request") + else { + panic!("expected discover request"); + }; + assert!(matches!( + discover.request, + ClientRequest::DiscoverRequest(_) + )); + + let ClientJsonRpcMessage::Request(initialize) = + server.receive().await.expect("expected initialize request") + else { + panic!("expected initialize request"); + }; + assert!(matches!( + initialize.request, + ClientRequest::InitializeRequest(_) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult(InitializeResult::new( + ServerCapabilities::default(), + )), + initialize.id, + )) + .await + .expect("send initialize response"); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + }); + + let client_transport = + crate::transport::IntoTransport::::into_transport( + client_transport, + ); + let client = serve_client_with_ct_inner( + (), + client_transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + legacy_version: Some(ProtocolVersion::V_2025_11_25), + }, + CancellationToken::new(), + Duration::from_millis(25), + ) + .await + .expect("auto client should fall back after discover timeout"); + client.cancel().await.expect("cancel client"); + server_task.await.expect("server task"); + }) + .await; + } + fn disconnected_peer() -> Peer { let (peer, receiver) = Peer::::new(Arc::new(AtomicU32RequestIdProvider::default()), None); From 3f5a3f6a28b7d61cf4e91b70f2e00826ab714c20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:21:19 +0900 Subject: [PATCH 330/333] chore(deps): bump taiki-e/install-action from 2.85.8 to 2.85.10 (#1163) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.8 to 2.85.10. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.85.8...v2.85.10) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f781e4624..4f85d2c30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2.85.8 + uses: taiki-e/install-action@v2.85.10 with: tool: cargo-semver-checks @@ -130,7 +130,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-public-api - uses: taiki-e/install-action@v2.85.8 + uses: taiki-e/install-action@v2.85.10 with: tool: cargo-public-api From d81bfa8854e58867ace1831b847fd6f89d3093d0 Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Tue, 11 Aug 2026 18:56:17 +0800 Subject: [PATCH 331/333] fix(model): preserve elicitation property order metadata (#1150) Co-authored-by: nightcityblade --- crates/rmcp/Cargo.toml | 1 + crates/rmcp/src/model/elicitation_schema.rs | 84 ++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 486fdf873..9dd27a251 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -51,6 +51,7 @@ serde_json = "1.0" thiserror = "2" tokio = { version = "1", features = ["sync", "macros", "rt", "time"] } futures = "0.3" +indexmap = { version = "2", features = ["serde"] } tracing = { version = "0.1" } tokio-util = { version = "0.7" } pin-project-lite = "0.2" diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 3cc24ca4c..78f109365 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -18,7 +18,8 @@ use std::{borrow::Cow, collections::BTreeMap, marker::PhantomData}; -use serde::{Deserialize, Serialize}; +use indexmap::IndexMap; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{const_string, model::ConstString}; @@ -1109,9 +1110,10 @@ impl EnumSchema { /// .optional_bool("newsletter", false) /// .build(); /// ``` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", schemars(!into))] +#[serde(rename_all = "camelCase", into = "ElicitationSchemaWire")] #[non_exhaustive] pub struct ElicitationSchema { /// Always "object" for elicitation schemas @@ -1125,6 +1127,11 @@ pub struct ElicitationSchema { /// Property definitions (must be primitive types) pub properties: BTreeMap, + /// Property names in wire order. Schemas constructed from a `BTreeMap` + /// use the map's sorted key order. + #[serde(skip)] + pub property_order: Option>, + /// List of required property names #[serde(skip_serializing_if = "Option::is_none")] pub required: Option>, @@ -1134,13 +1141,75 @@ pub struct ElicitationSchema { pub description: Option>, } +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ElicitationSchemaWire { + #[serde(rename = "type")] + type_: ObjectTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option>, + properties: IndexMap, + #[serde(skip_serializing_if = "Option::is_none")] + required: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option>, +} + +impl From for ElicitationSchema { + fn from(schema: ElicitationSchemaWire) -> Self { + Self { + type_: schema.type_, + title: schema.title, + property_order: Some(schema.properties.keys().cloned().collect()), + properties: schema.properties.into_iter().collect(), + required: schema.required, + description: schema.description, + } + } +} + +impl From for ElicitationSchemaWire { + fn from(schema: ElicitationSchema) -> Self { + let mut remaining = schema.properties; + let mut properties = IndexMap::with_capacity(remaining.len()); + + if let Some(property_order) = schema.property_order { + for name in property_order { + if let Some(definition) = remaining.remove(&name) { + properties.insert(name, definition); + } + } + } + properties.extend(remaining); + + Self { + type_: schema.type_, + title: schema.title, + properties, + required: schema.required, + description: schema.description, + } + } +} + +impl<'de> Deserialize<'de> for ElicitationSchema { + fn deserialize<__D>(__deserializer: __D) -> Result + where + __D: Deserializer<'de>, + { + ElicitationSchemaWire::deserialize(__deserializer).map(Into::into) + } +} + impl ElicitationSchema { /// Create a new elicitation schema with the given properties pub fn new(properties: BTreeMap) -> Self { + let property_order = Some(properties.keys().cloned().collect()); Self { type_: ObjectTypeConst, title: None, properties, + property_order, required: None, description: None, } @@ -1632,10 +1701,12 @@ impl ElicitationSchemaBuilder { } } + let property_order = Some(self.properties.keys().cloned().collect()); Ok(ElicitationSchema { type_: ObjectTypeConst, title: self.title, properties: self.properties, + property_order, required: if self.required.is_empty() { None } else { @@ -1821,6 +1892,13 @@ mod tests { output["properties"]["choice"]["enumNames"], serde_json::json!(["Option One", "Option Two", "Option Three"]), ); + let input = r#"{"type":"object","properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"email":{"type":"string"}}}"#; + let ordered: ElicitationSchema = serde_json::from_str(input)?; + assert_eq!( + ordered.property_order.as_ref().unwrap().join(","), + "firstName,lastName,email", + ); + assert_eq!(serde_json::to_string(&ordered)?, input); Ok(()) } From a50a73fda2cd55f87633a280b430f539b1094234 Mon Sep 17 00:00:00 2001 From: stevenlee-oai Date: Wed, 12 Aug 2026 03:27:43 -0700 Subject: [PATCH 332/333] fix(auth): retain state until issuer validation (#1167) --- crates/rmcp/src/transport/auth.rs | 77 +++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 2b20391ac..904df5b17 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2018,11 +2018,13 @@ impl AuthorizationManager { AuthError::InternalError("Authorization state not found".to_string()) })?; - // Delete state after retrieval (one-time use) - self.state_store.delete(csrf_token).await?; - Self::validate_authorization_response_issuer(&stored_state, received_issuer)?; + // Consume state only after the callback is bound to the expected issuer. + // A callback with the correct state but a forged or missing required `iss` + // must not discard the PKCE verifier needed by the legitimate callback. + self.state_store.delete(csrf_token).await?; + // capture requested scopes before the state is consumed let requested_scopes = stored_state.requested_scopes.clone(); @@ -6762,6 +6764,75 @@ mod tests { ); } + #[rstest] + #[case::missing_required_issuer(None)] + #[case::mismatched_issuer(Some("https://evil.example.com"))] + #[tokio::test] + async fn invalid_issuer_does_not_consume_authorization_state( + #[case] invalid_issuer: Option<&str>, + ) { + let client = RecordingOAuthHttpClient::with_responses(vec![http_response( + 200, + serde_json::json!({ + "access_token": "access-token", + "token_type": "bearer", + "expires_in": 3600 + }), + )]); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + manager.set_metadata(AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + issuer: Some("https://auth.example.com".to_string()), + ..Default::default() + }); + manager.configure_client_id("test-client-id").unwrap(); + + let pkce = PkceCodeVerifier::new("verifier".to_string()); + let csrf = CsrfToken::new("csrf".to_string()); + let state = StoredAuthorizationState::new_with_expected_issuer( + &pkce, + &csrf, + Some("https://auth.example.com".to_string()), + true, + ); + manager.state_store.save("csrf", state).await.unwrap(); + + manager + .exchange_code_for_token_with_issuer("forged-code", "csrf", invalid_issuer) + .await + .unwrap_err(); + + assert!( + manager.state_store.load("csrf").await.unwrap().is_some(), + "issuer validation failures must leave state available for the legitimate callback" + ); + assert!( + client.requests().is_empty(), + "issuer validation failures must not reach the token endpoint" + ); + + manager + .exchange_code_for_token_with_issuer( + "legitimate-code", + "csrf", + Some("https://auth.example.com"), + ) + .await + .unwrap(); + + assert!( + manager.state_store.load("csrf").await.unwrap().is_none(), + "a valid callback must consume its one-time authorization state" + ); + assert_eq!(client.requests().len(), 1); + } + // -- scope management -- #[test] From f713ebd1a6feab492fb730a8bc13026be114d82f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:33:23 +0900 Subject: [PATCH 333/333] chore(deps): bump taiki-e/install-action from 2.85.10 to 2.85.11 (#1169) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.10 to 2.85.11. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.85.10...v2.85.11) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f85d2c30..ad6cad1c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@v2.85.10 + uses: taiki-e/install-action@v2.85.11 with: tool: cargo-semver-checks @@ -130,7 +130,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install cargo-public-api - uses: taiki-e/install-action@v2.85.10 + uses: taiki-e/install-action@v2.85.11 with: tool: cargo-public-api