From 42cf8d9fa3e8578e33dd33ba6aee446cb574c493 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:13:12 +0900 Subject: [PATCH 01/29] chore(deps): update process-wrap requirement from 9.0 to 10.0 (#1229) Updates the requirements on [process-wrap](https://github.com/watchexec/process-wrap) to permit the latest version. - [Changelog](https://github.com/watchexec/process-wrap/blob/main/CHANGELOG.md) - [Commits](https://github.com/watchexec/process-wrap/compare/v9.0.0...v10.0.0) --- updated-dependencies: - dependency-name: process-wrap dependency-version: 10.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 e554e86..66cb029 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -87,7 +87,7 @@ url = { version = "2.4", optional = true } tower-service = { version = "0.3", optional = true } # for child process transport -process-wrap = { version = "9.0", features = ["tokio1"], optional = true } +process-wrap = { version = "10.0", features = ["tokio1"], optional = true } # for cross-platform executable path resolution which = { version = "8", optional = true } From 3b5ca4dd3f2a7f34404ed3d97ea6880b0ffa90bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:01:50 +0900 Subject: [PATCH 02/29] chore(deps): bump astral-sh/setup-uv from 7.6.0 to 10.0.1 (#1238) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.6.0 to 10.0.1. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/37802adc94f370d6bfd71619e3f0bf239e1f3b78...20cfd1bf945f4377ade1205e4dbc17946fc9a30d) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 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 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a43adf7..c71d9d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @@ -274,7 +274,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @@ -310,7 +310,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @@ -345,7 +345,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable From 302319861a4b5ab538f6aebf25befdc3c7dfe039 Mon Sep 17 00:00:00 2001 From: Nick Steele Date: Fri, 4 Sep 2026 15:04:08 -0400 Subject: [PATCH 03/29] feat(auth): add enterprise refresh-token and ID-JAG exchanges (#1234) * feat(auth): request ID-JAGs with enterprise refresh tokens * feat(auth): exchange ID-JAGs for MCP access tokens Redeem ID-JAGs at the approved resource authorization server and return its bearer token, lifetime, and effective granted scopes. Preserve scope narrowing and redacted diagnostics, and reuse the default HTTP adapter. Support independently configured client authentication at both servers. Document the exchange profile and test redirects and staged failures. Partially addresses modelcontextprotocol/rust-sdk#531. * test(conformance): cover EMA refresh-token exchange --- conformance/Cargo.toml | 2 + conformance/src/bin/client.rs | 81 +- crates/rmcp/Cargo.toml | 4 +- crates/rmcp/README.md | 5 + crates/rmcp/src/transport/auth.rs | 74 +- crates/rmcp/src/transport/auth/enterprise.rs | 767 +++++++++++ .../src/transport/auth/enterprise_tests.rs | 1145 +++++++++++++++++ docs/OAUTH_SUPPORT.md | 125 ++ 8 files changed, 2196 insertions(+), 7 deletions(-) create mode 100644 crates/rmcp/src/transport/auth/enterprise.rs create mode 100644 crates/rmcp/src/transport/auth/enterprise_tests.rs diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index fc4be6d..e8de6f0 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -19,6 +19,7 @@ rmcp = { path = "../crates/rmcp", features = [ "elicitation", "auth", "auth-client-credentials-jwt", + "auth-enterprise-managed", "request-state", "transport-streamable-http-server", "transport-streamable-http-client-reqwest", @@ -31,6 +32,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } axum = { version = "0.8", features = ["macros"] } anyhow = "1" +oauth2 = { version = "5.0", default-features = false } reqwest = { version = "0.13", features = ["json"] } urlencoding = "2" url = "2" diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 5d65410..ea98a8d 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -1,3 +1,5 @@ +use anyhow::Context; +use oauth2::{ClientSecret, RefreshToken}; use rmcp::{ ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt, model::*, @@ -6,7 +8,8 @@ use rmcp::{ AuthClient, AuthorizationManager, StreamableHttpClientTransport, auth::{ AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig, - InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState, + InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState, default_oauth_http_client, + enterprise::{EmaAuthorizationServer, EmaClientAuthentication, EmaExchangeRequest}, }, streamable_http_client::StreamableHttpClientTransportConfig, }, @@ -36,6 +39,17 @@ struct ConformanceContext { private_key_pem: Option, #[serde(default)] signing_algorithm: Option, + // enterprise-managed-authorization-refresh-token + #[serde(default)] + idp_client_id: Option, + #[serde(default)] + idp_client_secret: Option, + #[serde(default)] + idp_refresh_token: Option, + #[serde(default)] + idp_issuer: Option, + #[serde(default)] + idp_token_endpoint: Option, } fn load_context() -> ConformanceContext { @@ -760,6 +774,66 @@ async fn run_client_credentials_jwt( Ok(()) } +/// Exchange the fixture's IdP refresh token, then exercise authenticated MCP access. +async fn run_ema_refresh_token_client( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let manager = AuthorizationManager::new(server_url).await?; + let metadata = manager.resolve_metadata().await?.metadata; + let idp = EmaAuthorizationServer::new( + ctx.idp_issuer.as_deref().context("Missing idp_issuer")?, + ctx.idp_token_endpoint + .as_deref() + .context("Missing idp_token_endpoint")?, + ctx.idp_client_id + .as_deref() + .context("Missing idp_client_id")?, + ) + .with_client_authentication(EmaClientAuthentication::ClientSecretBasic( + ClientSecret::new( + ctx.idp_client_secret + .clone() + .context("Missing idp_client_secret")?, + ), + )); + let resource_as = EmaAuthorizationServer::new( + metadata + .issuer + .context("Missing authorization server issuer")?, + metadata.token_endpoint, + ctx.client_id.as_deref().context("Missing client_id")?, + ) + .with_client_authentication(EmaClientAuthentication::ClientSecretBasic( + ClientSecret::new(ctx.client_secret.clone().context("Missing client_secret")?), + )); + let refresh_token = RefreshToken::new( + ctx.idp_refresh_token + .clone() + .context("Missing idp_refresh_token")?, + ); + let http = default_oauth_http_client()?; + let token = EmaExchangeRequest::new(idp, resource_as, server_url, &refresh_token) + .with_scopes(manager.select_scopes(None, &[])) + .exchange(&http, &http) + .await?; + + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(server_url) + .auth_header(token.access_token.secret()), + ); + let client = BasicClientHandler + .serve_with_lifecycle(transport, conformance_lifecycle()) + .await?; + let tools = client.list_tools(Default::default()).await?; + for tool in tools.tools { + let args = build_tool_arguments(&tool); + client.call_tool(call_tool_params(tool.name, args)).await?; + } + client.cancel().await?; + Ok(()) +} + /// Cross-app access flow (SEP-1046 extension). async fn run_cross_app_access_client( server_url: &str, @@ -1110,6 +1184,11 @@ async fn run_scenario( "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 - enterprise-managed authorization with a refresh-token subject + "auth/enterprise-managed-authorization-refresh-token" => { + run_ema_refresh_token_client(server_url, ctx).await? + } + // Auth - cross-app access "auth/cross-app-access-complete-flow" => { run_cross_app_access_client(server_url, ctx).await? diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 66cb029..ac51ebf 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -18,6 +18,7 @@ exhaustive_enums = "warn" features = [ "auth", "auth-client-credentials-jwt", + "auth-enterprise-managed", "base64", "client", "client-side-sse", @@ -196,10 +197,11 @@ transport-streamable-http-server-session = [ tower = ["dep:tower-service"] auth = ["dep:async-trait", "dep:oauth2", "__reqwest", "dep:url"] auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"] +auth-enterprise-managed = ["auth", "base64"] schemars = ["dep:schemars"] [dev-dependencies] -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full", "test-util"] } schemars = { version = "1.1.0", features = ["chrono04"] } axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } hyper = { version = "1", features = ["server", "http1"] } diff --git a/crates/rmcp/README.md b/crates/rmcp/README.md index bb7837e..f34469e 100644 --- a/crates/rmcp/README.md +++ b/crates/rmcp/README.md @@ -24,6 +24,7 @@ For **getting started**, **usage guides**, and **full MCP feature documentation* | `macros` | `#[tool]` / `#[prompt]` macros (re-exports [`rmcp-macros`](../rmcp-macros)) | ✅ | | `schemars` | JSON Schema generation for tool definitions | | | `auth` | OAuth 2.0 authentication support | | +| `auth-enterprise-managed` | EMA/XAA refresh-token and ID-JAG exchanges for registered public and confidential clients (includes `auth`) | | | `elicitation` | Elicitation support | | ### Transport features @@ -45,6 +46,10 @@ For **getting started**, **usage guides**, and **full MCP feature documentation* | `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) | +For enterprise-managed authorization, enable `auth-enterprise-managed` and a TLS +backend such as `reqwest`. See the [EMA/XAA guide](../../docs/OAUTH_SUPPORT.md#enterprise-managed-authorization-emaxaa) +for client authentication and an MCP connection example. + ## Transports The transport layer is pluggable. Two built-in pairs cover the most common cases: diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 6762286..8c9e412 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -28,6 +28,9 @@ use tracing::{debug, warn}; use crate::transport::common::http_header::HEADER_MCP_PROTOCOL_VERSION; +#[cfg(feature = "auth-enterprise-managed")] +pub mod enterprise; + 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; @@ -99,6 +102,19 @@ pub trait OAuthHttpClient: Send + Sync { fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_>; } +/// Create an OAuth HTTP client with the SDK's default reqwest configuration. +/// +/// Honors each request's redirect policy, with a 30-second timeout and bounded +/// response bodies. Enable a TLS feature such as `reqwest` for HTTPS requests. +/// Implement [`OAuthHttpClient`] instead when custom network policy is required. +pub fn default_oauth_http_client() -> Result { + let client = ReqwestClient::builder() + .timeout(DEFAULT_HTTP_TIMEOUT) + .build() + .map_err(|error| AuthError::InternalError(error.to_string()))?; + ReqwestOAuthHttpClient::new(client) +} + struct ReqwestOAuthHttpClient { follow_redirects: ReqwestClient, stop_redirects: ReqwestClient, @@ -1307,13 +1323,9 @@ impl AuthorizationManager { /// create new auth manager with base url pub async fn new(base_url: U) -> Result { - 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)?), + Arc::new(default_oauth_http_client()?), OAuthHttpRedirectPolicy::Stop, ) .await @@ -4043,6 +4055,58 @@ mod tests { ); } + #[tokio::test] + async fn default_oauth_http_client_honors_redirect_policy() { + use axum::{Router, routing::post}; + + let received = Arc::new(StdMutex::new(Vec::new())); + let capture = Arc::clone(&received); + let app = Router::new() + .route( + "/redirect", + post(|| async { (StatusCode::TEMPORARY_REDIRECT, [("location", "/token")]) }), + ) + .route( + "/token", + post(move |body: String| { + let capture = Arc::clone(&capture); + async move { + capture.lock().unwrap().push(body); + StatusCode::OK + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/redirect", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = super::default_oauth_http_client().unwrap(); + + for (policy, expected) in [ + ( + OAuthHttpRedirectPolicy::Stop, + StatusCode::TEMPORARY_REDIRECT, + ), + (OAuthHttpRedirectPolicy::Follow, StatusCode::OK), + ] { + let request = oauth2::http::Request::builder() + .method("POST") + .uri(&endpoint) + .body(b"credential-sentinel".to_vec()) + .unwrap(); + let response = client + .execute(OAuthHttpRequest::new(request, policy)) + .await + .unwrap(); + assert_eq!(response.status(), expected); + let expected_bodies = if policy == OAuthHttpRedirectPolicy::Stop { + vec![] + } else { + vec!["credential-sentinel".to_owned()] + }; + assert_eq!(*received.lock().unwrap(), expected_bodies); + } + } + #[tokio::test] async fn default_http_client_preserves_connection_failure_cause() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/rmcp/src/transport/auth/enterprise.rs b/crates/rmcp/src/transport/auth/enterprise.rs new file mode 100644 index 0000000..226c8d5 --- /dev/null +++ b/crates/rmcp/src/transport/auth/enterprise.rs @@ -0,0 +1,767 @@ +//! Non-interactive enterprise-managed authorization (EMA/XAA) token exchanges. +//! +//! Exchanges an enterprise refresh token for an ID-JAG, then for an MCP access +//! token. Callers must discover and approve both servers and their registrations +//! before supplying a credential. This module does not discover servers, log in, +//! persist credentials, or decide when to reauthenticate. +//! +//! Configure each pre-registered client's approved authentication method separately: +//! HTTP Basic, a client secret in the request body, or a freshly signed JWT client +//! assertion. Public-client authentication requires the server's explicit approval. +//! This helper requires one MCP resource and does not implement Rich +//! Authorization Requests or DPoP. Redemption consumes the SDK's ID-JAG handle, +//! without automatic retries; server-side replay policy remains the server's responsibility. +//! +//! ID-JAG checks below enforce structure and claim bindings, not cryptographic +//! signature verification. Assertions come directly from the trusted IdP token +//! endpoint; the resource authorization server must verify their signatures. +//! +//! ```no_run +//! use oauth2::{ClientSecret, RefreshToken}; +//! use rmcp::transport::auth::{default_oauth_http_client, enterprise::*}; +//! +//! # async fn authorize(refresh: &RefreshToken, idp_secret: ClientSecret, resource_secret: ClientSecret) -> Result<(), Box> { +//! // Enable `auth-enterprise-managed` and a TLS feature such as `reqwest`. +//! let http = default_oauth_http_client()?; +//! let token = EmaExchangeRequest::new( +//! EmaAuthorizationServer::new("https://idp.example", "https://idp.example/token", "idp-client") +//! .with_client_authentication(EmaClientAuthentication::ClientSecretBasic(idp_secret)), +//! EmaAuthorizationServer::new("https://as.example", "https://as.example/token", "mcp-client") +//! .with_client_authentication(EmaClientAuthentication::ClientSecretBasic(resource_secret)), +//! "https://mcp.example", refresh, +//! ).with_scopes(["files.read"]).exchange(&http, &http).await?; +//! // Use token.access_token only for the approved MCP resource; never log it. +//! // token.scopes contains the final granted scopes, which may be narrower. +//! # Ok(()) } +//! ``` + +use std::{ + collections::HashSet, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use oauth2::{AccessToken, ClientSecret, RefreshToken}; +use serde::{Deserialize, de::DeserializeOwned}; +use thiserror::Error; +use url::{Host, Url}; + +use super::{ + DEFAULT_HTTP_TIMEOUT, MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES, OAuthHttpClient, + OAuthHttpRedirectPolicy, OAuthHttpRequest, +}; + +const ID_JAG_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:id-jag"; + +/// Authentication approved for a pre-registered client at one authorization server. +/// +/// The selected method is used as configured, without negotiation or fallback. +#[derive(Clone)] +#[non_exhaustive] +pub enum EmaClientAuthentication { + /// Public client (`token_endpoint_auth_method=none`), only if the server permits it. + None, + /// `client_secret_basic`, with OAuth form encoding before HTTP Basic encoding. + ClientSecretBasic(ClientSecret), + /// `client_secret_post`, for servers requiring credentials in the request body. + ClientSecretPost(ClientSecret), + /// Fresh JWT client assertions, such as `private_key_jwt` or `client_secret_jwt`. + /// Signing, key custody, claims, and the registered algorithm belong to the provider. + JwtAssertion(Arc), +} + +impl std::fmt::Debug for EmaClientAuthentication { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::None => "None", + Self::ClientSecretBasic(_) => "ClientSecretBasic { .. }", + Self::ClientSecretPost(_) => "ClientSecretPost { .. }", + Self::JwtAssertion(_) => "JwtAssertion { .. }", + }) + } +} + +impl EmaClientAuthentication { + fn validate(&self) -> Result<(), EmaError> { + if let Self::ClientSecretBasic(secret) | Self::ClientSecretPost(secret) = self + && secret.secret().trim().is_empty() + { + return Err(EmaError::InvalidRequest("client secret must not be empty")); + } + Ok(()) + } +} + +/// A signed JWT used to authenticate the client, distinct from the ID-JAG grant. +pub struct EmaClientAssertion(String); + +impl EmaClientAssertion { + /// Wrap a fresh assertion without exposing it through `Debug`. + pub fn new(assertion: String) -> Self { + Self(assertion) + } +} + +impl std::fmt::Debug for EmaClientAssertion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("EmaClientAssertion { .. }") + } +} + +/// Creates client assertions on demand, allowing keys to remain in an external signer. +/// +/// Called once immediately before each token request, including delayed ID-JAG +/// redemption. Set `iss` and `sub` to the registered client identifier and `aud` +/// to the server's approved audience, with a short expiration and a fresh `jti`. +/// The SDK does not sign or validate these assertions. Provider failures are +/// sanitized and the call shares the token request's timeout. Cancellation may +/// occur when that deadline expires. +#[async_trait::async_trait] +pub trait EmaClientAssertionProvider: Send + Sync { + async fn create_assertion( + &self, + server: &EmaAuthorizationServer, + ) -> Result>; +} + +/// A trusted server with a pre-registered client and its approved authentication method. +#[derive(Clone)] +#[non_exhaustive] +pub struct EmaAuthorizationServer { + /// Exact issuer identifier from approved metadata. + pub issuer: String, + /// Token endpoint from that metadata. + pub token_endpoint: String, + /// Pre-registered client identifier. + pub client_id: String, + client_authentication: EmaClientAuthentication, +} + +impl EmaAuthorizationServer { + /// Use approved metadata and a public-client registration (`token_endpoint_auth_method=none`). + /// Set [`Self::with_client_authentication`] for a confidential client. + pub fn new( + issuer: impl Into, + token_endpoint: impl Into, + client_id: impl Into, + ) -> Self { + Self { + issuer: issuer.into(), + token_endpoint: token_endpoint.into(), + client_id: client_id.into(), + client_authentication: EmaClientAuthentication::None, + } + } + + /// Select the authentication method approved for this server's client registration. + pub fn with_client_authentication(mut self, authentication: EmaClientAuthentication) -> Self { + self.client_authentication = authentication; + self + } +} + +/// A refresh-token exchange bound to one MCP resource and two registered clients. +pub struct EmaExchangeRequest<'a> { + idp: EmaAuthorizationServer, + resource_as: EmaAuthorizationServer, + resource: &'a str, + refresh_token: &'a RefreshToken, + scopes: Vec, +} + +impl<'a> EmaExchangeRequest<'a> { + /// No scope parameter is sent until [`Self::with_scopes`] is used. + pub fn new( + idp: EmaAuthorizationServer, + resource_as: EmaAuthorizationServer, + resource: &'a str, + refresh_token: &'a RefreshToken, + ) -> Self { + Self { + idp, + resource_as, + resource, + refresh_token, + scopes: Vec::new(), + } + } + + /// Request distinct non-empty scope tokens; the IdP may narrow them. + /// An empty iterator omits `scope`, rather than requesting an empty grant. + pub fn with_scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + + /// Obtain an ID-JAG without redirects or retries, checking its resource/client bindings. + /// The returned assertion does not retain the enterprise refresh token. + pub async fn exchange_id_jag( + self, + idp_http: &dyn OAuthHttpClient, + ) -> Result { + for endpoint in [ + self.resource, + &self.idp.issuer, + &self.idp.token_endpoint, + &self.resource_as.issuer, + &self.resource_as.token_endpoint, + ] { + validate_endpoint(endpoint)?; + } + if self.idp.issuer == self.resource_as.issuer { + return Err(EmaError::InvalidRequest( + "IdP and resource AS issuers must differ", + )); + } + if self.idp.client_id.trim().is_empty() + || self.resource_as.client_id.trim().is_empty() + || self.refresh_token.secret().trim().is_empty() + { + return Err(EmaError::InvalidRequest( + "client IDs and refresh token must not be empty", + )); + } + self.idp.client_authentication.validate()?; + self.resource_as.client_authentication.validate()?; + let requested: HashSet<&str> = self.scopes.iter().map(String::as_str).collect(); + if requested.len() != self.scopes.len() || self.scopes.iter().any(|s| !is_scope_token(s)) { + return Err(EmaError::InvalidRequest( + "scopes must be distinct non-empty tokens", + )); + } + let mut params = vec![ + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:token-exchange", + ), + ("requested_token_type", ID_JAG_TOKEN_TYPE), + ( + "subject_token_type", + "urn:ietf:params:oauth:token-type:refresh_token", + ), + ("subject_token", self.refresh_token.secret()), + ("audience", self.resource_as.issuer.as_str()), + ("resource", self.resource), + ]; + let scope = self.scopes.join(" "); + if !scope.is_empty() { + params.push(("scope", &scope)); + } + let jag: IdJagResponse = post_form( + idp_http, + &self.idp, + ¶ms, + EmaExchangeStage::IdentityProvider, + None, + unix_time, + ) + .await?; + let (granted, expires_at) = jag.validate(&self, &requested)?; + Ok(EmaIdJag { + assertion: AccessToken::new(jag.access_token), + scopes: granted, + resource_as: self.resource_as, + resource: self.resource.to_owned(), + expires_at, + }) + } + + /// Perform both exchanges with independently routed HTTP clients and no automatic retries. + pub async fn exchange( + self, + idp_http: &dyn OAuthHttpClient, + resource_http: &dyn OAuthHttpClient, + ) -> Result { + self.exchange_id_jag(idp_http) + .await? + .exchange(resource_http) + .await + } +} + +/// An IdP-issued ID-JAG whose structure and bindings have been checked, not its signature. +pub struct EmaIdJag { + assertion: AccessToken, + scopes: HashSet, + resource_as: EmaAuthorizationServer, + resource: String, + expires_at: u64, +} + +impl std::fmt::Debug for EmaAuthorizationServer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("EmaAuthorizationServer { .. }") + } +} + +impl std::fmt::Debug for EmaExchangeRequest<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("EmaExchangeRequest { .. }") + } +} + +impl std::fmt::Debug for EmaIdJag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("EmaIdJag { .. }") + } +} + +impl EmaIdJag { + /// The assertion to present to the approved resource authorization server. + pub fn assertion(&self) -> &AccessToken { + &self.assertion + } + + /// The scope tokens carried by the assertion; an empty set means scope was omitted. + pub fn scopes(&self) -> &HashSet { + &self.scopes + } + + /// Redeem this assertion once, at its approved resource AS, without redirects or retries. + /// Consume the grant so this helper cannot accidentally replay it after a failed exchange. + pub async fn exchange(self, http: &dyn OAuthHttpClient) -> Result { + self.exchange_with_clock(http, unix_time).await + } + + async fn exchange_with_clock( + self, + http: &dyn OAuthHttpClient, + now: impl Fn() -> Result + Sync, + ) -> Result { + if self.expires_at <= now()? { + return Err(EmaError::InvalidRequest("ID-JAG expired before redemption")); + } + // Only the assertion carries authority: repeating resource/scope could undo narrowing. + let token: ResourceTokenResponse = post_form( + http, + &self.resource_as, + &[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", self.assertion.secret()), + ], + EmaExchangeStage::ResourceAuthorizationServer, + Some(self.expires_at), + now, + ) + .await?; + token.validate(&self.resource, self.scopes) + } +} + +/// A resource-bound bearer with secret-safe diagnostics and its optional lifetime. +#[derive(Clone)] +#[non_exhaustive] +pub struct EmaAccessToken { + pub access_token: AccessToken, + pub expires_in: Option, + /// Resource-AS scopes, or the ID-JAG scopes when the response omits `scope`. + /// Empty when both the ID-JAG and response omit scopes. + pub scopes: HashSet, +} + +impl std::fmt::Debug for EmaAccessToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("EmaAccessToken { .. }") + } +} + +/// The endpoint that failed, allowing the caller to apply its own credential lifecycle policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum EmaExchangeStage { + IdentityProvider, + ResourceAuthorizationServer, +} + +/// Sanitized failures. Raw HTTP adapter errors and provider response bodies are never retained. +#[derive(Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum EmaError { + #[error("invalid EMA exchange request: {0}")] + InvalidRequest(&'static str), + #[error("invalid EMA response from {stage:?}: {message}")] + InvalidResponse { + stage: EmaExchangeStage, + message: &'static str, + }, + #[error("EMA request to {0:?} failed")] + RequestFailed(EmaExchangeStage), + #[error("{0:?}: invalid_grant")] + InvalidGrant(EmaExchangeStage), + #[error("{0:?}: insufficient_user_authentication")] + InsufficientUserAuthentication(EmaExchangeStage), + #[error("{stage:?} returned HTTP {status}: {code}")] + OAuthRejected { + stage: EmaExchangeStage, + status: u16, + code: &'static str, + }, +} + +async fn post_form( + http: &dyn OAuthHttpClient, + server: &EmaAuthorizationServer, + params: &[(&str, &str)], + stage: EmaExchangeStage, + grant_expires_at: Option, + now: impl Fn() -> Result + Sync, +) -> Result { + let response = tokio::time::timeout(DEFAULT_HTTP_TIMEOUT, async { + // Generate assertions at the request boundary, not when server configuration is built. + let client_assertion = match &server.client_authentication { + EmaClientAuthentication::JwtAssertion(provider) => { + let assertion = provider + .create_assertion(server) + .await + .map_err(|_| EmaError::RequestFailed(stage))?; + if assertion.0.trim().is_empty() { + return Err(EmaError::InvalidRequest( + "client assertion must not be empty", + )); + } + Some(assertion) + } + _ => None, + }; + let request = { + let mut form = url::form_urlencoded::Serializer::new(String::new()); + form.extend_pairs(params.iter().copied()); + let mut request = oauth2::http::Request::builder() + .method("POST") + .uri(&server.token_endpoint) + .header("content-type", "application/x-www-form-urlencoded") + .header("accept", "application/json"); + match &server.client_authentication { + EmaClientAuthentication::ClientSecretBasic(secret) => { + let client_id: String = + url::form_urlencoded::byte_serialize(server.client_id.as_bytes()).collect(); + let secret: String = + url::form_urlencoded::byte_serialize(secret.secret().as_bytes()).collect(); + let encoded = STANDARD.encode(format!("{client_id}:{secret}")); + let mut header = + oauth2::http::HeaderValue::from_str(&format!("Basic {encoded}")).map_err( + |_| EmaError::InvalidRequest("invalid client authentication header"), + )?; + header.set_sensitive(true); + request = request.header(oauth2::http::header::AUTHORIZATION, header); + } + EmaClientAuthentication::ClientSecretPost(secret) => { + form.append_pair("client_id", &server.client_id) + .append_pair("client_secret", secret.secret()); + } + EmaClientAuthentication::None | EmaClientAuthentication::JwtAssertion(_) => { + form.append_pair("client_id", &server.client_id); + } + } + if let Some(assertion) = client_assertion { + form.append_pair( + "client_assertion_type", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ) + .append_pair("client_assertion", &assertion.0); + } + request + .body(form.finish().into_bytes()) + .map_err(|_| EmaError::InvalidRequest("invalid token endpoint URI"))? + }; + // An external signer may outlive the grant even when it meets the request deadline. + if let Some(expires_at) = grant_expires_at + && expires_at <= now()? + { + return Err(EmaError::InvalidRequest("ID-JAG expired before redemption")); + } + http.execute(OAuthHttpRequest::new( + request, + OAuthHttpRedirectPolicy::Stop, + )) + .await + .map_err(|_| EmaError::RequestFailed(stage)) + }) + .await + .map_err(|_| EmaError::RequestFailed(stage))??; + let invalid = |message| EmaError::InvalidResponse { stage, message }; + if response.body().len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES { + return Err(invalid("response body too large")); + } + if !response.status().is_success() { + #[derive(Deserialize)] + struct OAuthError { + error: Option, + } + let error = serde_json::from_slice::(response.body()).ok(); + let code = match error.as_ref().and_then(|e| e.error.as_deref()) { + Some("invalid_grant") => return Err(EmaError::InvalidGrant(stage)), + Some("insufficient_user_authentication") => { + return Err(EmaError::InsufficientUserAuthentication(stage)); + } + Some("invalid_request") => "invalid_request", + Some("invalid_client") => "invalid_client", + Some("invalid_scope") => "invalid_scope", + Some("invalid_target") => "invalid_target", + Some("unauthorized_client") => "unauthorized_client", + Some("unsupported_grant_type") => "unsupported_grant_type", + Some("access_denied") => "access_denied", + Some("temporarily_unavailable") => "temporarily_unavailable", + Some("server_error") => "server_error", + _ => "OAuth token request rejected", + }; + return Err(EmaError::OAuthRejected { + stage, + status: response.status().as_u16(), + code, + }); + } + serde_json::from_slice(response.body()).map_err(|_| invalid("malformed token response")) +} + +fn validate_endpoint(value: &str) -> Result<(), EmaError> { + let url = Url::parse(value).map_err(|_| EmaError::InvalidRequest("invalid endpoint URL"))?; + let loopback = match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + }; + if (url.scheme() != "https" && !(url.scheme() == "http" && loopback)) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(EmaError::InvalidRequest( + "endpoint must use HTTPS or HTTP loopback without userinfo or fragments", + )); + } + Ok(()) +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum Resource { + Single(String), + Multiple(Vec), +} + +impl Resource { + fn is_exact(&self, expected: &str) -> bool { + match self { + Self::Single(value) => value == expected, + Self::Multiple(values) => values.as_slice() == [expected], + } + } +} + +#[derive(Deserialize)] +struct JwtHeader { + alg: String, + typ: Option, +} + +#[derive(Deserialize)] +struct IdJagClaims { + iss: String, + sub: String, + aud: Resource, + client_id: String, + jti: String, + exp: u64, + iat: u64, + resource: Resource, + scope: Option, + authorization_details: Option>, +} + +#[derive(Deserialize)] +struct IdJagResponse { + access_token: String, + issued_token_type: String, + token_type: String, + resource: Option, + scope: Option, + refresh_token: Option, + authorization_details: Option>, +} + +impl IdJagResponse { + fn validate( + &self, + request: &EmaExchangeRequest<'_>, + requested: &HashSet<&str>, + ) -> Result<(HashSet, u64), EmaError> { + let invalid = |message| EmaError::InvalidResponse { + stage: EmaExchangeStage::IdentityProvider, + message, + }; + if self.issued_token_type != ID_JAG_TOKEN_TYPE + || self.token_type != "N_A" + || self.refresh_token.is_some() + { + return Err(invalid("unsupported ID-JAG token type or refresh token")); + } + let mut parts = self.access_token.split('.'); + let (Some(header), Some(payload), Some(signature), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(invalid("ID-JAG must be a compact signed JWT")); + }; + if header.is_empty() || payload.is_empty() || signature.is_empty() { + return Err(invalid("ID-JAG contains an empty JWT segment")); + } + let decode = |value| { + URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| invalid("malformed ID-JAG encoding")) + }; + decode(signature)?; + let header: JwtHeader = serde_json::from_slice(&decode(header)?) + .map_err(|_| invalid("malformed ID-JAG header"))?; + let claims: IdJagClaims = serde_json::from_slice(&decode(payload)?) + .map_err(|_| invalid("malformed ID-JAG claims"))?; + if [&self.authorization_details, &claims.authorization_details] + .into_iter() + .any(|details| details.as_ref().is_some_and(|details| !details.is_empty())) + { + return Err(invalid("authorization_details is not supported")); + } + if header.alg.trim().is_empty() + || header.alg.eq_ignore_ascii_case("none") + || header.typ.as_deref() != Some("oauth-id-jag+jwt") + || claims.iss != request.idp.issuer + || !claims.aud.is_exact(&request.resource_as.issuer) + || claims.client_id != request.resource_as.client_id + || claims.sub.trim().is_empty() + || claims.jti.trim().is_empty() + { + return Err(invalid( + "ID-JAG type, issuer, audience, client, subject, or JWT ID mismatch", + )); + } + let now = unix_time()?; + if claims.exp <= now || claims.iat > now.saturating_add(60) { + return Err(invalid("expired or future-issued ID-JAG")); + } + if !claims.resource.is_exact(request.resource) + || self + .resource + .as_ref() + .is_some_and(|r| !r.is_exact(request.resource)) + { + return Err(invalid("ID-JAG resource mismatch")); + } + let parse = + |scope| parse_scope(scope).ok_or_else(|| invalid("malformed or duplicate scopes")); + let granted = match claims.scope.as_deref() { + Some(scope) => parse(scope)?, + None if requested.is_empty() => HashSet::new(), + None => return Err(invalid("ID-JAG is missing requested scope authorization")), + }; + if !requested.is_empty() && !granted.is_subset(requested) { + return Err(invalid("ID-JAG scope exceeds the request")); + } + match self.scope.as_deref() { + Some(scope) if parse(scope)? != granted => { + return Err(invalid("response scope differs from ID-JAG scope")); + } + None if !requested.is_empty() && granted != *requested => { + return Err(invalid("response omitted narrowed scope")); + } + _ => {} + } + Ok((granted.into_iter().map(str::to_owned).collect(), claims.exp)) + } +} + +fn parse_scope(scope: &str) -> Option> { + let scopes: HashSet<_> = scope.split(' ').collect(); + (scopes.iter().all(|s| is_scope_token(s)) && scopes.len() == scope.split(' ').count()) + .then_some(scopes) +} + +fn is_scope_token(scope: &str) -> bool { + !scope.is_empty() + && scope + .bytes() + .all(|b| matches!(b, b'!' | b'#'..=b'[' | b']'..=b'~')) +} + +fn unix_time() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| EmaError::InvalidRequest("system clock precedes the Unix epoch")) +} + +#[derive(Deserialize)] +struct ResourceTokenResponse { + access_token: String, + token_type: String, + expires_in: Option, + resource: Option, + scope: Option, + refresh_token: Option, + authorization_details: Option>, +} + +impl ResourceTokenResponse { + fn validate( + self, + resource: &str, + granted: HashSet, + ) -> Result { + let invalid = |message| EmaError::InvalidResponse { + stage: EmaExchangeStage::ResourceAuthorizationServer, + message, + }; + if self + .authorization_details + .as_ref() + .is_some_and(|details| !details.is_empty()) + { + return Err(invalid("authorization_details is not supported")); + } + if !self.token_type.eq_ignore_ascii_case("bearer") + || self.access_token.trim().is_empty() + || self.refresh_token.is_some() + || self.expires_in == Some(0) + { + return Err(invalid( + "invalid bearer token, lifetime, or unexpected refresh token", + )); + } + // The resource need not be echoed, but must agree with the ID-JAG if present. + if self + .resource + .as_ref() + .is_some_and(|r| !r.is_exact(resource)) + { + return Err(invalid("access token resource mismatch")); + } + // An omitted scope retains the authority carried by the assertion. + let scopes = if let Some(scope) = self.scope.as_deref() { + let scopes = + parse_scope(scope).ok_or_else(|| invalid("malformed or duplicate scopes"))?; + if !scopes.iter().all(|s| granted.contains(*s)) { + return Err(invalid("access token scope exceeds ID-JAG scope")); + } + scopes.into_iter().map(str::to_owned).collect() + } else { + granted + }; + Ok(EmaAccessToken { + access_token: AccessToken::new(self.access_token), + expires_in: self.expires_in.map(Duration::from_secs), + scopes, + }) + } +} + +#[cfg(test)] +#[path = "enterprise_tests.rs"] +mod tests; diff --git a/crates/rmcp/src/transport/auth/enterprise_tests.rs b/crates/rmcp/src/transport/auth/enterprise_tests.rs new file mode 100644 index 0000000..31a63c1 --- /dev/null +++ b/crates/rmcp/src/transport/auth/enterprise_tests.rs @@ -0,0 +1,1145 @@ +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + time::{Duration, SystemTime}, +}; + +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use oauth2::{ClientSecret, HttpResponse, RefreshToken}; +use serde_json::{Value, json}; + +use super::{ + EmaExchangeStage::{IdentityProvider as Idp, ResourceAuthorizationServer as ResourceServer}, + *, +}; +use crate::transport::auth::{OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRequest}; + +const IDP: &str = "https://idp.example?private-query"; +const AS: &str = "https://as.example?private-query"; +const RESOURCE: &str = "https://mcp.example?private-query"; +const IDP_TOKEN: &str = "https://idp.example/token?private-query"; +const AS_TOKEN: &str = "https://as.example/token?private-query"; +const BAD_SCOPES: &[&str] = &["", "files\tread", "\"", "\\", "\0", "读"]; + +#[derive(Default)] +struct MockHttp { + requests: Mutex>, + response: Mutex>>, +} + +impl MockHttp { + fn new(status: u16, body: Value) -> Self { + Self { + response: Mutex::new(Some(Ok(oauth2::http::Response::builder() + .status(status) + .body(serde_json::to_vec(&body).unwrap()) + .unwrap()))), + ..Self::default() + } + } +} + +impl OAuthHttpClient for MockHttp { + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + self.requests.lock().unwrap().push(request); + let response = self.response.lock().unwrap().take(); + let response = response.expect("unexpected HTTP"); + Box::pin(async move { response }) + } +} + +fn jwt(header: Value, claims: &Value) -> String { + format!( + "{}.{}.{}", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(claims.to_string()), + URL_SAFE_NO_PAD.encode(b"synthetic-signature") + ) +} + +fn claims() -> Value { + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + json!({"iss":IDP,"aud":AS,"sub":"user","client_id":"mcp", + "jti":"jag-id","iat":now,"exp":now + 3600,"resource":RESOURCE,"scope":"files.read"}) +} + +fn jag(claims: &Value) -> Value { + let mut body = json!({"access_token":jwt(json!({"alg":"ES256","typ":"oauth-id-jag+jwt"}), claims), + "issued_token_type":ID_JAG_TOKEN_TYPE,"token_type":"N_A","resource":claims["resource"]}); + if let Some(scope) = claims.get("scope") { + body["scope"] = scope.clone(); + } + body +} + +async fn exchange(idp: &MockHttp, scopes: &str) -> Result { + let scopes = scopes + .split_ascii_whitespace() + .map(str::to_owned) + .collect::>(); + let refresh = RefreshToken::new("refresh-token".into()); + let resource_as = EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp"); + assert!(!format!("{resource_as:?}").contains("private-query")); + let request = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp"), + resource_as, + RESOURCE, + &refresh, + ) + .with_scopes(scopes); + assert!(!format!("{request:?}").contains(refresh.secret())); + assert!(!format!("{request:?}").contains("private-query")); + let future = request.exchange_id_jag(idp); + fn is_send(_: &T) {} + is_send(&future); + future.await +} + +fn form(request: &OAuthHttpRequest) -> BTreeMap { + assert!(!request.request.headers().contains_key("authorization")); + authenticated_form(request) +} + +fn authenticated_form(request: &OAuthHttpRequest) -> BTreeMap { + assert_eq!(request.request.method(), "POST"); + let headers = request.request.headers(); + assert_eq!(headers["content-type"], "application/x-www-form-urlencoded"); + assert_eq!(headers["accept"], "application/json"); + assert_eq!(request.redirect_policy, OAuthHttpRedirectPolicy::Stop); + assert_eq!(request.timeout, Some(Duration::from_secs(30))); + let pairs = url::form_urlencoded::parse(request.request.body()) + .into_owned() + .collect::>(); + let fields = pairs.iter().cloned().collect::>(); + assert_eq!(pairs.len(), fields.len(), "duplicate form fields"); + fields +} + +#[tokio::test] +async fn refresh_exchange_preserves_exact_forms_and_signed_narrowing() { + for scopes in ["files.read files.write", ""] { + let response = jag(&claims()); + let idp = MockHttp::new(200, response.clone()); + let result = exchange(&idp, scopes).await.unwrap(); + assert_eq!( + result.assertion().secret(), + response["access_token"].as_str().unwrap() + ); + assert_eq!(result.scopes(), &HashSet::from(["files.read".to_owned()])); + assert!(!format!("{result:?}").contains(result.assertion().secret())); + assert!(!format!("{result:?}").contains("private-query")); + let requests = idp.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].request.uri(), IDP_TOKEN); + let mut fields = form(&requests[0]); + assert_eq!( + fields.remove("scope"), + (!scopes.is_empty()).then(|| scopes.to_owned()) + ); + assert_eq!( + serde_json::to_value(fields).unwrap(), + json!({ + "grant_type":"urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type":ID_JAG_TOKEN_TYPE,"subject_token":"refresh-token", + "subject_token_type":"urn:ietf:params:oauth:token-type:refresh_token", + "audience":AS,"resource":RESOURCE,"client_id":"idp" + }) + ); + } +} + +#[tokio::test] +async fn scopes_may_be_omitted_but_never_widened() { + for (requested, signed, echoed, valid) in [ + ("", Some("files.read"), None, true), + ("", None, None, true), + ("files.read", Some("files.read"), None, true), + ("files.read files.write", Some("files.read"), None, false), + ( + "files.read", + Some("files.read files.write"), + Some("files.read files.write"), + false, + ), + ("files.read", None, None, false), + ("", Some("files.read"), Some("files.write"), false), + ("", Some(" \t"), None, false), + ("", Some("files.read files.read"), None, false), + ("", Some("files.read"), Some(" \t"), false), + ("", Some("files.read"), Some("files.read files.read"), false), + ] { + let mut claims = claims(); + claims.as_object_mut().unwrap().remove("scope"); + if let Some(scope) = signed { + claims["scope"] = json!(scope); + } + let mut response = jag(&claims); + response.as_object_mut().unwrap().remove("scope"); + if let Some(scope) = echoed { + response["scope"] = json!(scope); + } + let result = exchange(&MockHttp::new(200, response), requested).await; + assert_eq!( + result.is_ok(), + valid, + "requested={requested:?}, signed={signed:?}, echoed={echoed:?}" + ); + if let Ok(result) = result { + let expected = signed.into_iter().map(str::to_owned).collect(); + assert_eq!(result.scopes(), &expected); + } + } +} + +#[tokio::test] +async fn unsupported_authorization_details_are_rejected_at_each_stage() { + const SECRET: &str = "authorization-details-secret"; + for (location, stage) in [ + ("claims", Idp), + ("idp response", Idp), + ("resource response", ResourceServer), + ] { + for (case, details, valid) in [ + ("absent", None, true), + ("null", Some(Value::Null), true), + ("empty", Some(json!([])), true), + ( + "additional authority", + Some( + json!([{"type":SECRET,"locations":["https://other.example"],"actions":["write"]}]), + ), + false, + ), + ("invalid member", Some(json!([null])), false), + ("object", Some(json!({"type":SECRET})), false), + ("string", Some(json!(SECRET)), false), + ] { + let mut claims = claims(); + let mut response = bearer(); + // Unrelated extension fields remain compatible at every boundary. + claims["vendor_extension"] = json!(SECRET); + response["vendor_extension"] = json!(SECRET); + if location == "claims" + && let Some(details) = &details + { + claims["authorization_details"] = details.clone(); + } + let mut idp_response = jag(&claims); + idp_response["vendor_extension"] = json!(SECRET); + if let Some(details) = details { + match location { + "idp response" => idp_response["authorization_details"] = details, + "resource response" => response["authorization_details"] = details, + _ => {} + } + } + let idp = MockHttp::new(200, idp_response); + let resource = MockHttp::new(200, response); + let result = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp"), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp"), + RESOURCE, + &RefreshToken::new("refresh-token".into()), + ) + .with_scopes(["files.read"]) + .exchange(&idp, &resource) + .await; + assert_eq!(result.is_ok(), valid, "{location}: {case}"); + if let Err(error) = result { + assert!( + matches!(error, EmaError::InvalidResponse { stage: actual, .. } if actual == stage) + ); + assert!(!format!("{error:?} {error}").contains(SECRET)); + } + assert_eq!(idp.requests.lock().unwrap().len(), 1); + assert_eq!( + resource.requests.lock().unwrap().len(), + usize::from(valid || stage == ResourceServer), + "{location}: {case}" + ); + } + } +} + +#[tokio::test] +async fn invalid_jags_never_escape_validation() { + let original = claims(); + let mut cases = Vec::new(); + let invalid = json!({"iss":"https://other.example","aud":[AS,"other"], + "client_id":"other","sub":"","jti":" \t","exp":0,"iat":u64::MAX,"resource":[RESOURCE,"other"]}); + for (key, value) in invalid.as_object().unwrap() { + let mut changed = original.clone(); + changed[key] = value.clone(); + cases.push(jag(&changed)); + } + for scope in BAD_SCOPES { + let mut changed = original.clone(); + changed["scope"] = json!(scope); + cases.push(jag(&changed)); + } + for header in [ + json!({"alg":"ES256","typ":"JWT"}), + json!({"alg":"ES256"}), + json!({"alg":"none","typ":"oauth-id-jag+jwt"}), + ] { + let mut response = jag(&original); + response["access_token"] = json!(jwt(header, &original)); + cases.push(response); + } + let invalid = json!({"issued_token_type":"Bearer","token_type":"Bearer", + "refresh_token":"unsupported","resource":"https://other.example","access_token":"a.b.c.d"}); + for (key, value) in invalid.as_object().unwrap() { + let mut response = jag(&original); + response[key] = value.clone(); + cases.push(response); + } + for signature in ["", "signature", "not+base64url", "c2ln="] { + let mut response = jag(&original); + let assertion = response["access_token"].as_str().unwrap(); + let (signed, _) = assertion.rsplit_once('.').unwrap(); + response["access_token"] = json!(format!("{signed}.{signature}")); + cases.push(response); + } + for response in cases { + let result = exchange(&MockHttp::new(200, response), "files.read").await; + assert!(matches!( + result, + Err(EmaError::InvalidResponse { stage: Idp, .. }) + )); + } +} + +#[tokio::test] +async fn invalid_inputs_fail_before_http() { + // Each server occupies issuer, token endpoint, and client ID slots. + let original = [IDP, IDP_TOKEN, "idp", AS, AS_TOKEN, "mcp", RESOURCE, "rt"]; + let mut cases = Vec::new(); + for index in [0, 1, 3, 4, 6] { + for value in [ + "invalid", + "http://idp.example/token", + "https://user:pass@idp.example/token", + "https://idp.example/token#fragment", + ] { + let mut fields = original; + fields[index] = value; + cases.push((fields, vec![])); + } + } + for (index, value) in [(0, AS), (2, " "), (5, ""), (7, " \t")] { + let mut fields = original; + fields[index] = value; + cases.push((fields, vec![])); + } + for scope in BAD_SCOPES.iter().copied().chain(["files.read files.write"]) { + cases.push((original, vec![scope])); + } + cases.push((original, vec!["files.read", "files.read"])); + for (fields, scopes) in cases { + let http = MockHttp::default(); + let result = EmaExchangeRequest::new( + EmaAuthorizationServer::new(fields[0], fields[1], fields[2]), + EmaAuthorizationServer::new(fields[3], fields[4], fields[5]), + fields[6], + &RefreshToken::new(fields[7].into()), + ) + .with_scopes(scopes) + .exchange_id_jag(&http) + .await; + assert!(matches!(result, Err(EmaError::InvalidRequest(_)))); + assert!(http.requests.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn errors_and_redirects_cannot_reflect_credentials() { + const SECRET: &str = "secret-error-sentinel"; + for (status, code) in [ + (400, "invalid_grant"), + (400, "insufficient_user_authentication"), + (400, "invalid_client"), + (400, SECRET), + (302, SECRET), + ] { + let failure = json!({"error":code,"error_description":SECRET}); + let error = exchange(&MockHttp::new(status, failure), "") + .await + .unwrap_err(); + match code { + "invalid_grant" => assert_eq!(error, EmaError::InvalidGrant(Idp)), + "insufficient_user_authentication" => { + assert_eq!(error, EmaError::InsufficientUserAuthentication(Idp)) + } + _ => assert!( + matches!(error, EmaError::OAuthRejected {stage: Idp, status: actual, ..} if actual == status) + ), + } + assert!(!format!("{error:?} {error}").contains(SECRET)); + } + for adapter_failure in [false, true] { + let failure = MockHttp::new( + 200, + json!({"access_token":SECRET,"issued_token_type":SECRET}), + ); + if adapter_failure { + *failure.response.lock().unwrap() = Some(Err(SECRET.into())); + } + let error = exchange(&failure, "").await.unwrap_err(); + assert!(!format!("{error:?} {error}").contains(SECRET)); + assert!(std::error::Error::source(&error).is_none()); + if adapter_failure { + assert_eq!(error, EmaError::RequestFailed(Idp)); + } + } + let mut oversized = jag(&claims()); + oversized["ignored"] = json!("x".repeat(1024 * 1024)); + let error = exchange(&MockHttp::new(200, oversized), "") + .await + .unwrap_err(); + assert!(matches!( + error, + EmaError::InvalidResponse { stage: Idp, .. } + )); +} + +fn bearer() -> Value { + json!({"access_token":"resource-token","token_type":"Bearer","expires_in":300}) +} + +async fn redeem(http: &MockHttp) -> Result { + exchange( + &MockHttp::new(200, jag(&claims())), + "files.read files.write", + ) + .await? + .exchange(http) + .await +} + +#[tokio::test] +async fn full_exchange_uses_separate_clients_and_only_the_narrowed_assertion() { + for valid in [true, false] { + let mut claims = claims(); + if !valid { + claims["client_id"] = json!("other"); + } + let response = jag(&claims); + let idp = MockHttp::new(200, response.clone()); + let resource_as = MockHttp::new(200, bearer()); + let refresh = RefreshToken::new("refresh-token".into()); + let request = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp"), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp"), + RESOURCE, + &refresh, + ) + .with_scopes(["files.read", "files.write"]); + let future = request.exchange(&idp, &resource_as); + fn is_send(_: &T) {} + is_send(&future); + let result = future.await; + assert_eq!(idp.requests.lock().unwrap().len(), 1); + let requests = resource_as.requests.lock().unwrap(); + assert_eq!(requests.len(), usize::from(valid)); + if !valid { + assert!(matches!( + result, + Err(EmaError::InvalidResponse { stage: Idp, .. }) + )); + continue; + } + let token = result.unwrap(); + assert_eq!(token.access_token.secret(), "resource-token"); + assert_eq!(token.expires_in, Some(Duration::from_secs(300))); + assert_eq!(token.scopes, HashSet::from(["files.read".to_owned()])); + assert!(!format!("{token:?}").contains(token.access_token.secret())); + assert_eq!(requests[0].request.uri(), AS_TOKEN); + assert_eq!( + serde_json::to_value(form(&requests[0])).unwrap(), + json!({ + "grant_type":"urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion":response["access_token"],"client_id":"mcp" + }) + ); + } +} + +#[tokio::test] +async fn expired_grants_are_not_redeemed() { + let mut grant = exchange(&MockHttp::new(200, jag(&claims())), "") + .await + .unwrap(); + grant.expires_at = 0; + let resource_as = MockHttp::default(); + assert!(matches!( + grant.exchange(&resource_as).await, + Err(EmaError::InvalidRequest(_)) + )); + assert!(resource_as.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn bearer_responses_cannot_change_resource_or_widen_scope() { + let mut cases = vec![ + ("scope", json!("files.read"), true), + ("scope", json!("files.admin"), false), + ("scope", json!("files.read files.write"), false), + ("scope", json!("files.read files.read"), false), + ("resource", json!(RESOURCE), true), + ("resource", json!([RESOURCE]), true), + ("resource", json!([RESOURCE, "other"]), false), + ("resource", json!("https://mcp.example"), false), + ("expires_in", json!(0), false), + ("expires_in", Value::Null, true), + ("refresh_token", json!("unsupported"), false), + ("token_type", json!("N_A"), false), + ("token_type", json!("bearer"), true), + ("access_token", json!(" \t"), false), + ]; + cases.extend( + BAD_SCOPES + .iter() + .map(|scope| ("scope", json!(scope), false)), + ); + for (key, value, valid) in cases { + let mut token = bearer(); + if value.is_null() { + token.as_object_mut().unwrap().remove(key); + } else { + token[key] = value; + } + let result = redeem(&MockHttp::new(200, token)).await; + assert_eq!(result.is_ok(), valid, "{key}"); + if key == "expires_in" && valid { + assert_eq!(result.unwrap().expires_in, None); + } else if !valid { + assert!(matches!( + result, + Err(EmaError::InvalidResponse { + stage: ResourceServer, + .. + }) + )); + } + } +} + +#[tokio::test] +async fn resource_scope_narrowing_is_reported_without_logging_scope_values() { + for read in ["files.read", "private-scope-sentinel"] { + let requested = format!("{read} files.write"); + let mut claims = claims(); + claims["scope"] = json!(requested); + let grant = exchange(&MockHttp::new(200, jag(&claims)), &requested) + .await + .unwrap(); + let mut response = bearer(); + response["scope"] = json!(read); + let token = grant.exchange(&MockHttp::new(200, response)).await.unwrap(); + assert_eq!(token.scopes, HashSet::from([read.to_owned()])); + assert!(!format!("{token:?}").contains(read)); + } +} + +#[tokio::test] +async fn bearer_scope_may_be_omitted_but_not_added_to_an_unscoped_grant() { + for scope in [None, Some("files.read")] { + let mut claims = claims(); + claims.as_object_mut().unwrap().remove("scope"); + let grant = exchange(&MockHttp::new(200, jag(&claims)), "") + .await + .unwrap(); + let mut token = bearer(); + if let Some(scope) = scope { + token["scope"] = json!(scope); + } + let result = grant.exchange(&MockHttp::new(200, token)).await; + assert_eq!(result.is_ok(), scope.is_none()); + if let Ok(token) = result { + assert!(token.scopes.is_empty()); + } + } +} + +#[tokio::test] +async fn resource_errors_are_staged_and_never_reflect_credentials() { + const SECRET: &str = "secret-resource-error-sentinel"; + for (status, code) in [ + (400, "invalid_grant"), + (400, "insufficient_user_authentication"), + (400, "invalid_client"), + (302, SECRET), + (500, SECRET), + ] { + let http = MockHttp::new(status, json!({"error":code,"error_description":SECRET})); + let error = redeem(&http).await.unwrap_err(); + match code { + "invalid_grant" => assert_eq!(error, EmaError::InvalidGrant(ResourceServer)), + "insufficient_user_authentication" => assert_eq!( + error, + EmaError::InsufficientUserAuthentication(ResourceServer) + ), + _ => assert!( + matches!(error, EmaError::OAuthRejected {stage: ResourceServer, status: actual, ..} if actual == status) + ), + } + assert!(!format!("{error:?} {error}").contains(SECRET)); + assert!(std::error::Error::source(&error).is_none()); + assert_eq!(http.requests.lock().unwrap().len(), 1); + } + let mut oversized = bearer(); + oversized["ignored"] = json!("x".repeat(1024 * 1024)); + for (body, adapter_failure) in [ + (json!({"access_token":SECRET,"expires_in":SECRET}), false), + (oversized, false), + (Value::Null, true), + ] { + let http = MockHttp::new(200, body); + if adapter_failure { + *http.response.lock().unwrap() = Some(Err(SECRET.into())); + } + let error = redeem(&http).await.unwrap_err(); + assert!(!format!("{error:?} {error}").contains(SECRET)); + assert!(std::error::Error::source(&error).is_none()); + if adapter_failure { + assert_eq!(error, EmaError::RequestFailed(ResourceServer)); + } else { + assert!(matches!( + error, + EmaError::InvalidResponse { + stage: ResourceServer, + .. + } + )); + } + } +} + +#[tokio::test(start_paused = true)] +async fn both_exchanges_enforce_the_timeout_when_the_adapter_does_not() { + struct PendingHttp; + impl OAuthHttpClient for PendingHttp { + fn execute(&self, _: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + Box::pin(std::future::pending()) + } + } + for stage in [Idp, ResourceServer] { + let ready = MockHttp::new(200, jag(&claims())); + let (idp, resource): (&dyn OAuthHttpClient, &dyn OAuthHttpClient) = match stage { + Idp => (&PendingHttp, &ready), + _ => (&ready, &PendingHttp), + }; + let refresh = RefreshToken::new("refresh-token".into()); + let request = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp"), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp"), + RESOURCE, + &refresh, + ); + let start = tokio::time::Instant::now(); + let result = tokio::time::timeout(Duration::from_secs(31), request.exchange(idp, resource)) + .await + .expect("the SDK must enforce its own deadline"); + assert_eq!(result.unwrap_err(), EmaError::RequestFailed(stage)); + assert_eq!(start.elapsed(), Duration::from_secs(30)); + assert_eq!( + ready.requests.lock().unwrap().len(), + usize::from(stage == ResourceServer) + ); + } +} + +#[derive(Clone, Copy)] +enum AssertionBehavior { + Success, + Error, + Empty(&'static str), + Delay(Duration), + Pending, +} + +struct RecordingAssertionProvider { + calls: Mutex>, + behavior: AssertionBehavior, +} + +impl RecordingAssertionProvider { + fn new(behavior: AssertionBehavior) -> Arc { + Arc::new(Self { + calls: Mutex::new(Vec::new()), + behavior, + }) + } +} + +fn client_assertion(client_id: &str, issuer: &str, sequence: usize) -> String { + jwt( + json!({"alg":"ES256","typ":"client-authentication+jwt"}), + &json!({"iss":client_id,"sub":client_id,"aud":issuer, + "exp":4102444800_u64,"jti":format!("client-assertion-{sequence}")}), + ) +} + +#[async_trait::async_trait] +impl EmaClientAssertionProvider for RecordingAssertionProvider { + async fn create_assertion( + &self, + server: &EmaAuthorizationServer, + ) -> Result> { + let sequence = { + let mut calls = self.calls.lock().unwrap(); + calls.push(( + server.issuer.clone(), + server.token_endpoint.clone(), + server.client_id.clone(), + )); + calls.len() + }; + match self.behavior { + AssertionBehavior::Error => return Err("client-assertion-provider-secret".into()), + AssertionBehavior::Empty(value) => return Ok(EmaClientAssertion::new(value.into())), + AssertionBehavior::Delay(delay) => tokio::time::sleep(delay).await, + AssertionBehavior::Pending => std::future::pending::<()>().await, + AssertionBehavior::Success => {} + } + Ok(EmaClientAssertion::new(client_assertion( + &server.client_id, + &server.issuer, + sequence, + ))) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AuthenticationMethod { + Public, + Basic, + Post, + Jwt, +} + +impl AuthenticationMethod { + fn configure( + self, + secret: &str, + provider: &Arc, + ) -> EmaClientAuthentication { + match self { + Self::Public => EmaClientAuthentication::None, + Self::Basic => { + EmaClientAuthentication::ClientSecretBasic(ClientSecret::new(secret.into())) + } + Self::Post => { + EmaClientAuthentication::ClientSecretPost(ClientSecret::new(secret.into())) + } + Self::Jwt => EmaClientAuthentication::JwtAssertion(provider.clone()), + } + } +} + +fn remove_client_authentication( + request: &OAuthHttpRequest, + method: AuthenticationMethod, + client_id: &str, + secret: &str, + encoded_basic: &str, + assertion: &str, +) -> BTreeMap { + let mut fields = authenticated_form(request); + let authorization = request.request.headers().get("authorization"); + if method == AuthenticationMethod::Basic { + let authorization = authorization.expect("Basic authentication must use a header"); + assert!(authorization.is_sensitive()); + let encoded = authorization + .to_str() + .unwrap() + .strip_prefix("Basic ") + .unwrap(); + assert_eq!(STANDARD.decode(encoded).unwrap(), encoded_basic.as_bytes()); + assert!(!fields.contains_key("client_id")); + } else { + assert!(authorization.is_none()); + assert_eq!(fields.remove("client_id").as_deref(), Some(client_id)); + } + assert_eq!( + fields.remove("client_secret").as_deref(), + (method == AuthenticationMethod::Post).then_some(secret) + ); + assert_eq!( + fields.remove("client_assertion_type").as_deref(), + (method == AuthenticationMethod::Jwt) + .then_some("urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + ); + assert_eq!( + fields.remove("client_assertion").as_deref(), + (method == AuthenticationMethod::Jwt).then_some(assertion) + ); + fields +} + +#[tokio::test] +async fn client_authentication_is_endpoint_specific_and_never_mixed_with_grants() { + const METHODS: [AuthenticationMethod; 4] = [ + AuthenticationMethod::Public, + AuthenticationMethod::Basic, + AuthenticationMethod::Post, + AuthenticationMethod::Jwt, + ]; + // Colons, plus signs, spaces, percent signs, and non-ASCII bytes must be + // form-encoded individually before the HTTP Basic username/password join. + const IDP_CLIENT: &str = "idp:+ %é"; + const AS_CLIENT: &str = "mcp:+ %é"; + const IDP_SECRET: &str = "idp-secret:+ %é"; + const AS_SECRET: &str = "as-secret:+ %é"; + for idp_method in METHODS { + for resource_method in METHODS { + let provider = RecordingAssertionProvider::new(AssertionBehavior::Success); + let mut claims = claims(); + claims["client_id"] = json!(AS_CLIENT); + let response = jag(&claims); + let idp = MockHttp::new(200, response.clone()); + let resource = MockHttp::new(200, bearer()); + let refresh = RefreshToken::new("refresh-token".into()); + let token = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, IDP_CLIENT) + .with_client_authentication(idp_method.configure(IDP_SECRET, &provider)), + EmaAuthorizationServer::new(AS, AS_TOKEN, AS_CLIENT) + .with_client_authentication(resource_method.configure(AS_SECRET, &provider)), + RESOURCE, + &refresh, + ) + .exchange(&idp, &resource) + .await + .unwrap(); + assert_eq!(token.access_token.secret(), "resource-token"); + let idp_requests = idp.requests.lock().unwrap(); + let resource_requests = resource.requests.lock().unwrap(); + assert_eq!(idp_requests.len(), 1); + assert_eq!(resource_requests.len(), 1); + assert_eq!(idp_requests[0].request.uri(), IDP_TOKEN); + assert_eq!(resource_requests[0].request.uri(), AS_TOKEN); + let idp_fields = remove_client_authentication( + &idp_requests[0], + idp_method, + IDP_CLIENT, + IDP_SECRET, + "idp%3A%2B+%25%C3%A9:idp-secret%3A%2B+%25%C3%A9", + &client_assertion(IDP_CLIENT, IDP, 1), + ); + assert_eq!( + serde_json::to_value(idp_fields).unwrap(), + json!({ + "grant_type":"urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type":ID_JAG_TOKEN_TYPE,"subject_token":"refresh-token", + "subject_token_type":"urn:ietf:params:oauth:token-type:refresh_token", + "audience":AS,"resource":RESOURCE + }) + ); + let resource_fields = remove_client_authentication( + &resource_requests[0], + resource_method, + AS_CLIENT, + AS_SECRET, + "mcp%3A%2B+%25%C3%A9:as-secret%3A%2B+%25%C3%A9", + &client_assertion( + AS_CLIENT, + AS, + 1 + usize::from(idp_method == AuthenticationMethod::Jwt), + ), + ); + assert_eq!( + serde_json::to_value(resource_fields).unwrap(), + json!({ + "grant_type":"urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion":response["access_token"] + }) + ); + let mut expected_calls = Vec::new(); + if idp_method == AuthenticationMethod::Jwt { + expected_calls.push((IDP.into(), IDP_TOKEN.into(), IDP_CLIENT.into())); + } + if resource_method == AuthenticationMethod::Jwt { + expected_calls.push((AS.into(), AS_TOKEN.into(), AS_CLIENT.into())); + } + assert_eq!(*provider.calls.lock().unwrap(), expected_calls); + } + } +} + +#[tokio::test(start_paused = true)] +async fn client_assertions_are_fresh_for_every_request_and_delayed_redemption() { + let provider = RecordingAssertionProvider::new(AssertionBehavior::Success); + let idp_server = EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp") + .with_client_authentication(EmaClientAuthentication::JwtAssertion(provider.clone())); + let resource_server = EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp") + .with_client_authentication(EmaClientAuthentication::JwtAssertion(provider.clone())); + let refresh = RefreshToken::new("refresh-token".into()); + let mut assertions = HashSet::new(); + for attempt in 0..2 { + let idp = MockHttp::new(200, jag(&claims())); + let resource = MockHttp::new(200, bearer()); + let grant = EmaExchangeRequest::new( + idp_server.clone(), + resource_server.clone(), + RESOURCE, + &refresh, + ) + .exchange_id_jag(&idp) + .await + .unwrap(); + assert_eq!(provider.calls.lock().unwrap().len(), attempt * 2 + 1); + tokio::time::sleep(Duration::from_secs(60)).await; + assert_eq!(provider.calls.lock().unwrap().len(), attempt * 2 + 1); + grant.exchange(&resource).await.unwrap(); + assert_eq!(provider.calls.lock().unwrap().len(), attempt * 2 + 2); + for http in [&idp, &resource] { + let requests = http.requests.lock().unwrap(); + let mut fields = form(&requests[0]); + assert!(assertions.insert(fields.remove("client_assertion").unwrap())); + } + } + assert_eq!(assertions.len(), 4); +} + +#[tokio::test] +async fn invalid_static_client_secrets_fail_before_any_http_or_signing() { + for stage in [Idp, ResourceServer] { + for method in [AuthenticationMethod::Basic, AuthenticationMethod::Post] { + for secret in ["", " \t"] { + let provider = RecordingAssertionProvider::new(AssertionBehavior::Success); + let valid = EmaClientAuthentication::JwtAssertion(provider.clone()); + let invalid = method.configure(secret, &provider); + let (idp_auth, resource_auth) = match stage { + Idp => (invalid, valid), + _ => (valid, invalid), + }; + let idp = MockHttp::default(); + let resource = MockHttp::default(); + let error = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp") + .with_client_authentication(idp_auth), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp") + .with_client_authentication(resource_auth), + RESOURCE, + &RefreshToken::new("refresh-token".into()), + ) + .exchange(&idp, &resource) + .await + .unwrap_err(); + assert!(matches!(error, EmaError::InvalidRequest(_))); + assert!(idp.requests.lock().unwrap().is_empty()); + assert!(resource.requests.lock().unwrap().is_empty()); + assert!(provider.calls.lock().unwrap().is_empty()); + } + } + } +} + +#[tokio::test] +async fn a_grant_that_expires_while_signing_is_never_sent_to_the_resource_server() { + use std::sync::atomic::{AtomicU64, Ordering}; + + let provider = RecordingAssertionProvider::new(AssertionBehavior::Success); + let idp = MockHttp::new(200, jag(&claims())); + let resource = MockHttp::default(); + let mut grant = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp"), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp") + .with_client_authentication(EmaClientAuthentication::JwtAssertion(provider.clone())), + RESOURCE, + &RefreshToken::new("refresh-token".into()), + ) + .exchange_id_jag(&idp) + .await + .unwrap(); + grant.expires_at = 100; + // The clock crosses expiration between the checks before and after signing. + let now = AtomicU64::new(99); + let error = grant + .exchange_with_clock(&resource, || Ok(now.fetch_add(1, Ordering::Relaxed))) + .await + .unwrap_err(); + assert!(matches!(error, EmaError::InvalidRequest(_))); + assert_eq!(provider.calls.lock().unwrap().len(), 1); + assert!(resource.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn assertion_provider_failures_and_empty_results_never_reach_http_or_escape_errors() { + for stage in [Idp, ResourceServer] { + for behavior in [ + AssertionBehavior::Error, + AssertionBehavior::Empty(""), + AssertionBehavior::Empty(" \t"), + ] { + let provider = RecordingAssertionProvider::new(behavior); + let auth = EmaClientAuthentication::JwtAssertion(provider.clone()); + let (idp_auth, resource_auth) = match stage { + Idp => (auth, EmaClientAuthentication::None), + _ => (EmaClientAuthentication::None, auth), + }; + let idp = MockHttp::new(200, jag(&claims())); + let resource = MockHttp::default(); + let error = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp") + .with_client_authentication(idp_auth), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp") + .with_client_authentication(resource_auth), + RESOURCE, + &RefreshToken::new("refresh-token".into()), + ) + .exchange(&idp, &resource) + .await + .unwrap_err(); + if matches!(behavior, AssertionBehavior::Error) { + assert_eq!(error, EmaError::RequestFailed(stage)); + } else { + assert!(matches!(error, EmaError::InvalidRequest(_))); + } + assert!(!format!("{error:?} {error}").contains("client-assertion-provider-secret")); + assert!(std::error::Error::source(&error).is_none()); + assert_eq!(provider.calls.lock().unwrap().len(), 1); + assert_eq!( + idp.requests.lock().unwrap().len(), + usize::from(stage == ResourceServer) + ); + assert!(resource.requests.lock().unwrap().is_empty()); + } + } +} + +#[tokio::test(start_paused = true)] +async fn signing_and_http_share_one_deadline_at_both_endpoints() { + struct DelayedHttp(Mutex>); + impl OAuthHttpClient for DelayedHttp { + fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + self.0.lock().unwrap().push(request); + Box::pin(async { + tokio::time::sleep(Duration::from_secs(10)).await; + Err("http-adapter-secret".into()) + }) + } + } + for stage in [Idp, ResourceServer] { + for behavior in [ + AssertionBehavior::Pending, + AssertionBehavior::Delay(Duration::from_secs(25)), + ] { + let provider = RecordingAssertionProvider::new(behavior); + let auth = EmaClientAuthentication::JwtAssertion(provider.clone()); + let (idp_auth, resource_auth) = match stage { + Idp => (auth, EmaClientAuthentication::None), + _ => (EmaClientAuthentication::None, auth), + }; + let ready = MockHttp::new(200, jag(&claims())); + let delayed = DelayedHttp(Mutex::new(Vec::new())); + let (idp, resource): (&dyn OAuthHttpClient, &dyn OAuthHttpClient) = match stage { + Idp => (&delayed, &ready), + _ => (&ready, &delayed), + }; + let refresh = RefreshToken::new("refresh-token".into()); + let request = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp") + .with_client_authentication(idp_auth), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp") + .with_client_authentication(resource_auth), + RESOURCE, + &refresh, + ); + let start = tokio::time::Instant::now(); + let result = + tokio::time::timeout(Duration::from_secs(31), request.exchange(idp, resource)) + .await + .expect("signing must share the SDK's token request deadline"); + assert_eq!(result.unwrap_err(), EmaError::RequestFailed(stage)); + assert_eq!(start.elapsed(), Duration::from_secs(30)); + assert_eq!(provider.calls.lock().unwrap().len(), 1); + assert_eq!( + delayed.0.lock().unwrap().len(), + usize::from(matches!(behavior, AssertionBehavior::Delay(_))) + ); + assert_eq!( + ready.requests.lock().unwrap().len(), + usize::from(stage == ResourceServer) + ); + } + } +} + +#[tokio::test] +async fn rejected_client_authentication_never_falls_back_or_retries() { + for stage in [Idp, ResourceServer] { + for method in [ + AuthenticationMethod::Basic, + AuthenticationMethod::Post, + AuthenticationMethod::Jwt, + ] { + let provider = RecordingAssertionProvider::new(AssertionBehavior::Success); + let failure = json!({"error":"invalid_client","error_description":"client-authentication-secret"}); + let idp = if stage == Idp { + MockHttp::new(401, failure.clone()) + } else { + MockHttp::new(200, jag(&claims())) + }; + let resource = MockHttp::new(401, failure); + let error = EmaExchangeRequest::new( + EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp") + .with_client_authentication(method.configure("idp-client-secret", &provider)), + EmaAuthorizationServer::new(AS, AS_TOKEN, "mcp") + .with_client_authentication(method.configure("as-client-secret", &provider)), + RESOURCE, + &RefreshToken::new("refresh-token".into()), + ) + .exchange(&idp, &resource) + .await + .unwrap_err(); + assert_eq!( + error, + EmaError::OAuthRejected { + stage, + status: 401, + code: "invalid_client" + } + ); + assert!(!format!("{error:?} {error}").contains("client-authentication-secret")); + assert_eq!(idp.requests.lock().unwrap().len(), 1); + assert_eq!( + resource.requests.lock().unwrap().len(), + usize::from(stage == ResourceServer) + ); + let expected_calls = if method == AuthenticationMethod::Jwt { + 1 + usize::from(stage == ResourceServer) + } else { + 0 + }; + assert_eq!(provider.calls.lock().unwrap().len(), expected_calls); + } + } +} + +#[test] +fn client_authentication_debug_output_redacts_credentials_and_provider_state() { + const SECRET: &str = "client-authentication-secret-sentinel"; + let provider = RecordingAssertionProvider::new(AssertionBehavior::Empty(SECRET)); + let assertion = EmaClientAssertion::new(SECRET.into()); + assert!(!format!("{assertion:?}").contains(SECRET)); + for authentication in [ + EmaClientAuthentication::ClientSecretBasic(ClientSecret::new(SECRET.into())), + EmaClientAuthentication::ClientSecretPost(ClientSecret::new(SECRET.into())), + EmaClientAuthentication::JwtAssertion(provider.clone()), + ] { + assert!(!format!("{authentication:?}").contains(SECRET)); + let server = EmaAuthorizationServer::new(IDP, IDP_TOKEN, "idp") + .with_client_authentication(authentication); + let refresh = RefreshToken::new("refresh-token".into()); + let request = EmaExchangeRequest::new(server.clone(), server.clone(), RESOURCE, &refresh); + assert!(!format!("{server:?} {request:?}").contains(SECRET)); + assert!(!format!("{server:?} {request:?}").contains("private-query")); + } +} diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 82a1fe9..5d32be3 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -15,6 +15,7 @@ This document describes the OAuth 2.1 authorization implementation for Model Con - Automatic token refresh - Authorized HTTP Client implementation - Injectable OAuth HTTP client for custom network environments +- Opt-in EMA/XAA refresh-token and ID-JAG exchanges for registered public and confidential clients ## Usage Guide @@ -294,6 +295,130 @@ match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await { } ``` +## Enterprise-managed authorization (EMA/XAA) + +The example requires the `rmcp` features `auth-enterprise-managed`, `client`, +`reqwest` (TLS), and `transport-streamable-http-client-reqwest`, plus `oauth2` +version 5. Call the async function from a Tokio runtime. + +The exchange profile has these requirements and limits: + +- Each authorization server has its own approved client registration and explicit + `EmaClientAuthentication`: `None`, `ClientSecretBasic`, `ClientSecretPost`, or + `JwtAssertion`. The SDK does not select methods from metadata or fall back to a + different method after a failure. +- Input is an enterprise IdP refresh token. The requested MCP resource must match + the ID-JAG's sole `resource` claim; scope may be omitted or narrowed. +- RAR (`authorization_details`) and DPoP are not supported. Nonempty authorization + details are rejected at both exchange stages. +- Redemption consumes the SDK's ID-JAG handle and does not retry automatically. + This is an SDK safety choice, not a protocol requirement that ID-JAGs be single-use. + +The [ID-JAG draft recommends confidential clients](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant-04#section-9.1). +The example below uses confidential clients with `client_secret_basic` at both +servers. Use `None` only where that server permits a public client registration. +Discovery, server approval, SSO, credential storage, and reauthentication remain +the application's responsibility. Client-side ID-JAG checks validate structure and +bindings, not signatures; the resource authorization server verifies signatures. + +```rust no_run +use oauth2::{ClientSecret, RefreshToken}; +use rmcp::{ + ServiceExt, + model::ClientInfo, + transport::{ + StreamableHttpClientTransport, + auth::{ + default_oauth_http_client, + enterprise::{EmaAuthorizationServer, EmaClientAuthentication, EmaExchangeRequest}, + }, + streamable_http_client::StreamableHttpClientTransportConfig, + }, +}; + +async fn connect( + refresh: &RefreshToken, + idp_client_secret: ClientSecret, + resource_client_secret: ClientSecret, +) -> Result<(), Box> { + let resource = "https://mcp.example/mcp"; + let http = default_oauth_http_client()?; + let idp = EmaAuthorizationServer::new( + "https://idp.example", "https://idp.example/token", "idp-client", + ) + .with_client_authentication(EmaClientAuthentication::ClientSecretBasic(idp_client_secret)); + let resource_as = EmaAuthorizationServer::new( + "https://as.example", "https://as.example/token", "mcp-client", + ) + .with_client_authentication(EmaClientAuthentication::ClientSecretBasic(resource_client_secret)); + let token = EmaExchangeRequest::new(idp, resource_as, resource, refresh) + .with_scopes(["files.read"]) + .exchange(&http, &http) + .await?; + let transport = StreamableHttpClientTransport::from_config( + StreamableHttpClientTransportConfig::with_uri(resource) + .auth_header(token.access_token.secret()), + ); + let client = ClientInfo::default().serve(transport).await?; + client.list_tools(Default::default()).await?; + client.cancel().await?; + Ok(()) +} +``` + +`auth_header` takes the token without a `Bearer ` prefix. Use it only with the +approved resource and never log it. This transport uses a fixed token; obtain a +new token and reconnect when it expires or is rejected. + +For a registration using JWT client authentication, implement +`EmaClientAssertionProvider` with your application's signer and configure +`JwtAssertion` on that server. The provider example below also uses `async-trait` +version 0.1; `AppSigner` represents your application's existing signing service. + +```rust ignore +use std::sync::Arc; +use rmcp::transport::auth::enterprise::{ + EmaAuthorizationServer, EmaClientAssertion, EmaClientAssertionProvider, + EmaClientAuthentication, +}; + +struct AppAssertionProvider { + signer: AppSigner, +} + +#[async_trait::async_trait] +impl EmaClientAssertionProvider for AppAssertionProvider { + async fn create_assertion( + &self, + server: &EmaAuthorizationServer, + ) -> Result> { + // Sign a new assertion for this registration and approved server. + let jwt = self.signer.sign_client_assertion( + &server.client_id, &server.issuer, &server.token_endpoint, + ).await?; + Ok(EmaClientAssertion::new(jwt)) + } +} + +let resource_as = EmaAuthorizationServer::new( + "https://as.example", "https://as.example/token", "mcp-client", +) +.with_client_authentication(EmaClientAuthentication::JwtAssertion(Arc::new( + AppAssertionProvider { signer }, +))); +``` + +The SDK calls the provider before each token request, including delayed +`EmaIdJag::exchange` redemption. Sign a fresh, short-lived assertion with a unique +`jti`, the registered client ID in `iss` and `sub`, and the server's approved +audience in `aud`. Your signer owns the keys and algorithm; the client assertion +is separate from the ID-JAG grant. Signing and HTTP share a 30-second deadline. + +The factory honors per-request redirect policy with the SDK's default reqwest +settings. For custom proxy, CA, or remote-execution policy, implement +`OAuthHttpClient`; use separate adapters for the IdP and resource AS when their +network policies differ. + ## Complete Examples - **Authorization Code client**: [`examples/clients/src/auth/oauth_client.rs`](../examples/clients/src/auth/oauth_client.rs) From add9cbeab56725547e7ea3bf3fb78f59de0806f1 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:38:30 -0400 Subject: [PATCH 04/29] ci: bump codeql-action to 4.37.9 and group updates (#1240) --- .github/dependabot.yml | 4 ++++ .github/workflows/codeql.yml | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0fa34ce..168b680 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,6 +21,10 @@ updates: # Mark PRs as CI related change. - T-CI open-pull-requests-limit: 3 + groups: + codeql-action: + patterns: + - "github/codeql-action*" commit-message: prefix: "chore" include: "scope" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4ecf348..83e1ce4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,13 +27,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 From 04c2f3fac90dbd2ec4ae0fbd9082fababd32dab9 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:04:15 -0400 Subject: [PATCH 05/29] fix(auth): unify refresh checks and error handling (#1236) * fix(auth): unify refresh checks and error handling Claude-Session: https://claude.ai/code/session_011pHFfoTygeG84mCDXzCcmw * test(auth): add live authorization-server checks Claude-Session: https://claude.ai/code/session_011pHFfoTygeG84mCDXzCcmw --- crates/rmcp/src/transport/auth.rs | 118 +++++++-- .../common/auth/streamable_http_client.rs | 118 ++++++++- crates/rmcp/tests/test_live_oauth_refresh.rs | 233 ++++++++++++++++++ scripts/keycloak-oauth-fixture.sh | 56 +++++ 4 files changed, 502 insertions(+), 23 deletions(-) create mode 100644 crates/rmcp/tests/test_live_oauth_refresh.rs create mode 100755 scripts/keycloak-oauth-fixture.sh diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 8c9e412..759044b 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -294,6 +294,7 @@ impl CredentialRefreshGuard { /// Implementations of this trait can provide custom storage backends /// for OAuth2 credentials, such as file-based storage, keychain integration, /// or database storage. +/// /// Return [`AuthError::CredentialStoreError`] for backend or locking failures /// so they remain distinct from errors requiring reauthorization. #[async_trait] @@ -2248,12 +2249,19 @@ impl AuthorizationManager { .as_ref() .ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?; - let refresh_guard = self.credential_store.acquire_refresh_guard().await?; + // Held for the rest of this function so the load, the exchange, and the + // save stay inside one guarded section. + let _refresh_guard = self.credential_store.acquire_refresh_guard().await?; let stored = self.credential_store.load().await?; let stored_credentials = stored.ok_or(AuthError::AuthorizationRequired)?; - if refresh_guard.is_some() - && stored_credentials.client_id != oauth_client.client_id().as_str() - { + // Refreshing with another client's stored token would put that token on a + // request authenticated as this client. + if stored_credentials.client_id != oauth_client.client_id().as_str() { + tracing::warn!( + stored_client_id = stored_credentials.client_id.as_str(), + configured_client_id = oauth_client.client_id().as_str(), + "stored credentials belong to a different client; reauthorization required" + ); return Err(AuthError::AuthorizationRequired); } let current_credentials = stored_credentials @@ -2271,8 +2279,8 @@ impl AuthorizationManager { // RFC 8707: the resource indicator is required on token requests, including refreshes .add_extra_param("resource", self.oauth_resource().await); let mut refresh_scopes = stored_credentials.granted_scopes; - let authoritative_scopes = refresh_guard.is_some().then(|| refresh_scopes.clone()); self.add_offline_access_if_supported(&mut refresh_scopes); + let requested_scopes = refresh_scopes.clone(); for scope in refresh_scopes { refresh_request = refresh_request.add_scope(Scope::new(scope)); } @@ -2298,10 +2306,12 @@ impl AuthorizationManager { token_result.set_refresh_token(Some(refresh_token_value)); } - let granted_scopes: Vec = match (token_result.scopes(), authoritative_scopes) { - (Some(scopes), _) => scopes.iter().map(|s| s.to_string()).collect(), - (None, Some(scopes)) => scopes, - (None, None) => self.current_scopes.read().await.clone(), + let response_scopes = token_result + .scopes() + .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(); @@ -8515,17 +8525,17 @@ mod tests { } } - fn refresh_store() -> RefreshStore { + async fn refresh_store() -> RefreshStore { let credentials = StoredCredentials::new( "my-client".into(), Some(make_token_response_with_refresh("old-token", "old-refresh")), vec!["read".into()], Some(AuthorizationManager::now_epoch_secs()), ); + let credential_store = InMemoryCredentialStore::new(); + credential_store.save(credentials).await.unwrap(); RefreshStore { - credentials: InMemoryCredentialStore { - credentials: Arc::new(tokio::sync::RwLock::new(Some(credentials))), - }, + credentials: credential_store, lock: Arc::new(Mutex::new(())), events: Arc::new(StdMutex::new(Vec::new())), guard_requested: Arc::new(Semaphore::new(0)), @@ -8604,7 +8614,7 @@ mod tests { #[tokio::test] async fn refresh_guard_spans_load_exchange_and_completed_save() { - let store = refresh_store(); + let store = refresh_store().await; let manager = refresh_manager(store.clone(), refresh_http_client(&store)).await; manager.refresh_token().await.unwrap(); @@ -8631,7 +8641,7 @@ mod tests { #[tokio::test] async fn concurrent_refreshes_wait_for_save_and_use_the_latest_token() { - let mut store = refresh_store(); + let mut store = refresh_store().await; let save_gate = Arc::new(Semaphore::new(0)); store.save_gate = Some(save_gate.clone()); let http_client = refresh_http_client(&store); @@ -8677,8 +8687,8 @@ mod tests { } #[tokio::test] - async fn guarded_refresh_rejects_credentials_for_another_client() { - let store = refresh_store(); + async fn refresh_rejects_credentials_for_another_client() { + let store = refresh_store().await; let mut credentials = store.credentials.load().await.unwrap().unwrap(); credentials.client_id = "other-client".into(); store.credentials.save(credentials).await.unwrap(); @@ -8693,6 +8703,78 @@ mod tests { assert!(store.lock.try_lock().is_ok()); } + #[tokio::test] + async fn refresh_rejects_credentials_for_another_client_without_a_guard() { + let (base_url, captured) = start_token_server().await; + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{base_url}/authorize"), + token_endpoint: format!("{base_url}/token"), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + manager + .credential_store + .save(StoredCredentials::new( + "other-client".into(), + Some(make_token_response_with_refresh("old-token", "old-refresh")), + vec!["read".into()], + Some(AuthorizationManager::now_epoch_secs()), + )) + .await + .unwrap(); + + let error = manager.refresh_token().await.unwrap_err(); + + assert!( + matches!(error, AuthError::AuthorizationRequired), + "a client mismatch must require reauthorization, got: {error:?}" + ); + assert!( + captured.lock().unwrap().is_none(), + "a client mismatch must be caught before the refresh token leaves the process" + ); + } + + #[tokio::test] + async fn refresh_without_a_guard_keeps_stored_scopes_when_response_omits_them() { + // start_token_server answers without a `scope`, matching a provider that + // grants the request in full. + let (base_url, _captured) = start_token_server().await; + let mut manager = manager_with_metadata(Some(AuthorizationMetadata { + authorization_endpoint: format!("{base_url}/authorize"), + token_endpoint: format!("{base_url}/token"), + ..Default::default() + })) + .await; + manager.configure_client(test_client_config()).unwrap(); + manager + .credential_store + .save(StoredCredentials::new( + "my-client".into(), + Some(make_token_response_with_refresh("old-token", "old-refresh")), + vec!["read".into()], + Some(AuthorizationManager::now_epoch_secs()), + )) + .await + .unwrap(); + *manager.current_scopes.write().await = vec!["stale".into()]; + + manager.refresh_token().await.unwrap(); + + let saved = manager.credential_store.load().await.unwrap().unwrap(); + assert_eq!( + saved.granted_scopes, + ["read"], + "the stored grant outranks the per-process scope cache" + ); + assert_eq!( + manager.get_current_scopes().await, + ["read"], + "the refreshed grant must replace the stale scope cache" + ); + } + #[rstest] #[case("guard", 0)] #[case("load", 0)] @@ -8702,7 +8784,7 @@ mod tests { #[case] phase: &'static str, #[case] provider_requests: usize, ) { - let mut store = refresh_store(); + let mut store = refresh_store().await; store.fail_at = Some(phase); let http_client = refresh_http_client(&store); let manager = refresh_manager(store.clone(), http_client.clone()).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 97432f9..47b6caf 100644 --- a/crates/rmcp/src/transport/common/auth/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/auth/streamable_http_client.rs @@ -19,7 +19,10 @@ where /// 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. + /// retry, then the challenge propagates; + /// - a refresh that fails for any other reason (credential store, network, + /// provider) → that error propagates so the caller can retry instead of + /// being sent through a new authorization. async fn call_reacting_to_challenges( &self, auth_token: Option, @@ -54,11 +57,13 @@ where match refreshed { Ok(fresh_token) if fresh_token != sent_token => call(Some(fresh_token)).await, Ok(_) => Err(StreamableHttpError::AuthRequired(challenge)), - Err(error @ AuthError::CredentialStoreError(_)) => Err(error.into()), - Err(error) => { - debug!("token refresh after server rejection failed: {error}"); + // `try_refresh_or_reauth` already reports the cases that need a + // new authorization; anything else is retryable or infrastructural. + Err(AuthError::AuthorizationRequired) => { + debug!("token refresh after server rejection requires authorization"); Err(StreamableHttpError::AuthRequired(challenge)) } + Err(error) => Err(error.into()), } } result => result, @@ -212,11 +217,16 @@ where #[cfg(all(test, feature = "transport-streamable-http-client-reqwest"))] mod tests { + use std::sync::Arc; + + use oauth2::{AccessToken, RefreshToken, basic::BasicTokenType}; + use super::*; use crate::transport::{ auth::{ AuthorizationManager, AuthorizationMetadata, CredentialRefreshGuard, CredentialStore, - StoredCredentials, + InMemoryCredentialStore, OAuthHttpClient, OAuthHttpClientFuture, OAuthHttpRequest, + OAuthTokenResponse, StoredCredentials, VendorExtraTokenFields, }, streamable_http_client::AuthRequiredError, }; @@ -269,4 +279,102 @@ mod tests { StreamableHttpError::Auth(AuthError::CredentialStoreError(message)) if message == "guard unavailable")); } + + struct UnreachableTokenEndpoint; + + impl OAuthHttpClient for UnreachableTokenEndpoint { + fn execute(&self, _: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + Box::pin(async { Err("token endpoint unreachable".into()) }) + } + } + + struct RejectingTokenEndpoint; + + impl OAuthHttpClient for RejectingTokenEndpoint { + fn execute(&self, _: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { + Box::pin(async { + Ok(oauth2::http::Response::builder() + .status(400) + .header("content-type", "application/json") + .body(br#"{"error":"invalid_grant"}"#.to_vec()) + .unwrap()) + }) + } + } + + /// A manager holding a refresh token the given token endpoint will answer for. + async fn manager_with_stored_refresh_token( + token_endpoint: Arc, + ) -> AuthorizationManager { + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + token_endpoint, + ) + .await + .unwrap(); + manager.set_metadata(AuthorizationMetadata { + authorization_endpoint: "https://auth.example.com/authorize".into(), + token_endpoint: "https://auth.example.com/token".into(), + ..Default::default() + }); + manager.configure_client_id("client").unwrap(); + + let mut token_response = OAuthTokenResponse::new( + AccessToken::new("old-token".into()), + BasicTokenType::Bearer, + VendorExtraTokenFields::default(), + ); + token_response.set_refresh_token(Some(RefreshToken::new("stored-refresh".into()))); + let store = InMemoryCredentialStore::new(); + store + .save(StoredCredentials::new( + "client".into(), + Some(token_response), + vec![], + None, + )) + .await + .unwrap(); + manager.set_credential_store(store); + manager + } + + /// Drive one call whose server answer is a 401 challenge. + async fn challenge_once(manager: AuthorizationManager) -> StreamableHttpError { + AuthClient::new(reqwest::Client::new(), manager) + .call_reacting_to_challenges(Some("old-token".into()), |_| async { + Err::<(), _>(StreamableHttpError::AuthRequired(AuthRequiredError::new( + "Bearer".into(), + ))) + }) + .await + .unwrap_err() + } + + #[tokio::test] + async fn reactive_refresh_propagates_retryable_refresh_failure() { + let manager = manager_with_stored_refresh_token(Arc::new(UnreachableTokenEndpoint)).await; + + let error = challenge_once(manager).await; + + assert!( + matches!( + error, + StreamableHttpError::Auth(AuthError::TokenRefreshFailed(_)) + ), + "a retryable refresh failure must reach the caller instead of asking for a new authorization, got: {error:?}" + ); + } + + #[tokio::test] + async fn reactive_refresh_reports_a_rejected_refresh_token_as_a_challenge() { + let manager = manager_with_stored_refresh_token(Arc::new(RejectingTokenEndpoint)).await; + + let error = challenge_once(manager).await; + + assert!( + matches!(error, StreamableHttpError::AuthRequired(_)), + "a definitively rejected refresh token must surface the challenge, got: {error:?}" + ); + } } diff --git a/crates/rmcp/tests/test_live_oauth_refresh.rs b/crates/rmcp/tests/test_live_oauth_refresh.rs new file mode 100644 index 0000000..543abe5 --- /dev/null +++ b/crates/rmcp/tests/test_live_oauth_refresh.rs @@ -0,0 +1,233 @@ +//! Live authorization-server checks for the refresh path. +//! +//! These are `#[ignore]`d, so they never run in CI. Provision the authorization +//! server with `./scripts/keycloak-oauth-fixture.sh`, then run them with +//! `cargo test -p rmcp --all-features --test test_live_oauth_refresh -- --ignored`. +//! `KC_BASE` overrides the server location for both the script and these tests. +#![cfg(feature = "auth")] + +use std::sync::Arc; + +use rmcp::transport::auth::{ + AuthError, AuthorizationManager, AuthorizationMetadata, CredentialRefreshGuard, + CredentialStore, InMemoryCredentialStore, OAuthClientConfig, OAuthTokenResponse, + StoredCredentials, +}; +use tokio::sync::Mutex; + +const REALM: &str = "rmcp"; +const CLIENT_ID: &str = "rmcp-client"; +const CLIENT_SECRET: &str = "rmcp-secret"; + +fn kc_base() -> String { + std::env::var("KC_BASE").unwrap_or_else(|_| "http://localhost:8081".to_string()) +} + +fn token_endpoint() -> String { + format!("{}/realms/{REALM}/protocol/openid-connect/token", kc_base()) +} + +/// Ask Keycloak for a genuine token pair through the direct access grant, so the +/// stored credentials hold a refresh token the server will actually honor. +async fn issue_real_credentials() -> OAuthTokenResponse { + let form = format!( + "client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}\ + &username=alice&password=alice-pw&grant_type=password&scope=openid+profile" + ); + let body = reqwest::Client::new() + .post(token_endpoint()) + .header("content-type", "application/x-www-form-urlencoded") + .body(form) + .send() + .await + .expect("keycloak unreachable") + .text() + .await + .unwrap(); + serde_json::from_str(&body).unwrap_or_else(|e| panic!("unexpected token response {body}: {e}")) +} + +fn metadata() -> AuthorizationMetadata { + // `AuthorizationMetadata` is `#[non_exhaustive]`, so fill it field by field. + let mut metadata = AuthorizationMetadata::default(); + metadata.authorization_endpoint = + format!("{}/realms/{REALM}/protocol/openid-connect/auth", kc_base()); + metadata.token_endpoint = token_endpoint(); + metadata +} + +fn client_config() -> OAuthClientConfig { + let mut config = OAuthClientConfig::new(CLIENT_ID, "http://localhost/callback"); + config.client_secret = Some(CLIENT_SECRET.to_string()); + config +} + +async fn manager_with_store(store: S) -> AuthorizationManager { + let mut manager = AuthorizationManager::new(kc_base()).await.unwrap(); + manager.set_metadata(metadata()); + manager.configure_client(client_config()).unwrap(); + manager.set_credential_store(store); + manager +} + +fn stored(client_id: &str, token: OAuthTokenResponse) -> StoredCredentials { + StoredCredentials::new( + client_id.to_string(), + Some(token), + vec!["openid".into(), "profile".into()], + None, + ) +} + +/// A store that serializes refreshes the way a shared on-disk store would. +#[derive(Clone, Default)] +struct GuardedStore { + inner: InMemoryCredentialStore, + lock: Arc>, +} + +#[async_trait::async_trait] +impl CredentialStore for GuardedStore { + async fn load(&self) -> Result, AuthError> { + self.inner.load().await + } + + async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> { + self.inner.save(credentials).await + } + + async fn clear(&self) -> Result<(), AuthError> { + self.inner.clear().await + } + + async fn acquire_refresh_guard(&self) -> Result, AuthError> { + Ok(Some(CredentialRefreshGuard::new( + self.lock.clone().lock_owned().await, + ))) + } +} + +#[tokio::test] +#[ignore = "requires a live Keycloak"] +async fn live_refresh_rotates_the_stored_token() { + use oauth2::TokenResponse; + + let issued = issue_real_credentials().await; + let original_refresh = issued.refresh_token().unwrap().secret().clone(); + let store = InMemoryCredentialStore::new(); + store.save(stored(CLIENT_ID, issued)).await.unwrap(); + let manager = manager_with_store(store.clone()).await; + + let refreshed = manager.refresh_token().await.expect("live refresh failed"); + + let saved = store.load().await.unwrap().unwrap(); + let saved_token = saved.token_response.unwrap(); + assert_ne!( + saved_token.refresh_token().unwrap().secret(), + &original_refresh, + "keycloak rotates refresh tokens, so the store must hold the new one" + ); + assert_eq!( + saved_token.access_token().secret(), + refreshed.access_token().secret(), + "the saved credentials must match what the caller received" + ); + assert!( + saved.granted_scopes.contains(&"openid".to_string()), + "granted scopes should come from the provider response, got: {:?}", + saved.granted_scopes + ); +} + +#[tokio::test] +#[ignore = "requires a live Keycloak"] +async fn live_refresh_rejects_credentials_for_another_client() { + use oauth2::TokenResponse; + + let issued = issue_real_credentials().await; + let untouched_refresh = issued.refresh_token().unwrap().secret().clone(); + let store = InMemoryCredentialStore::new(); + store + .save(stored("some-other-client", issued)) + .await + .unwrap(); + let manager = manager_with_store(store.clone()).await; + + let error = manager.refresh_token().await.unwrap_err(); + + assert!( + matches!(error, AuthError::AuthorizationRequired), + "a client mismatch must require reauthorization, got: {error:?}" + ); + let saved = store.load().await.unwrap().unwrap(); + assert_eq!( + saved + .token_response + .unwrap() + .refresh_token() + .unwrap() + .secret(), + &untouched_refresh, + "a rejected refresh must leave the stored token untouched" + ); +} + +#[tokio::test] +#[ignore = "requires a live Keycloak"] +async fn live_concurrent_refreshes_survive_refresh_token_rotation() { + use oauth2::TokenResponse; + + let issued = issue_real_credentials().await; + let store = GuardedStore::default(); + store.save(stored(CLIENT_ID, issued)).await.unwrap(); + let first = manager_with_store(store.clone()).await; + let second = manager_with_store(store.clone()).await; + + // Without the guard the second caller would reuse the refresh token the first + // one already consumed, and Keycloak would answer `invalid_grant`. + let (a, b) = tokio::join!( + tokio::spawn(async move { first.refresh_token().await }), + tokio::spawn(async move { second.refresh_token().await }) + ); + + let a = a.unwrap().expect("first concurrent refresh failed"); + let b = b.unwrap().expect("second concurrent refresh failed"); + assert_ne!( + a.access_token().secret(), + b.access_token().secret(), + "each caller performs its own exchange, so the tokens must differ" + ); +} + +/// Deterministic proof that this realm really does invalidate a rotated refresh +/// token, which is what makes the coordination above load-bearing. +#[tokio::test] +#[ignore = "requires a live Keycloak with revokeRefreshToken enabled"] +async fn live_reusing_a_rotated_refresh_token_is_rejected() { + let issued = issue_real_credentials().await; + + let first_store = InMemoryCredentialStore::new(); + first_store + .save(stored(CLIENT_ID, issued.clone())) + .await + .unwrap(); + manager_with_store(first_store) + .await + .refresh_token() + .await + .expect("the first refresh should succeed"); + + // A second caller that never saw the rotation still holds the consumed token. + let stale_store = InMemoryCredentialStore::new(); + stale_store.save(stored(CLIENT_ID, issued)).await.unwrap(); + let error = manager_with_store(stale_store) + .await + .refresh_token() + .await + .unwrap_err(); + + assert!( + matches!(error, AuthError::TokenRefreshRejected(_)), + "reusing a rotated refresh token must be rejected, got: {error:?}" + ); +} diff --git a/scripts/keycloak-oauth-fixture.sh b/scripts/keycloak-oauth-fixture.sh new file mode 100755 index 0000000..50c9b85 --- /dev/null +++ b/scripts/keycloak-oauth-fixture.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ============================================================================= +# keycloak-oauth-fixture.sh — Live OAuth fixture for rmcp refresh tests +# +# Starts a throwaway Keycloak and provisions the realm that +# crates/rmcp/tests/test_live_oauth_refresh.rs expects. Those tests are +# #[ignore]d, so nothing here runs in CI. +# +# Provision: ./scripts/keycloak-oauth-fixture.sh +# Run tests: cargo test -p rmcp --all-features \ +# --test test_live_oauth_refresh -- --ignored +# Tear down: docker rm -f kc-rmcp-test +# +# Requires Docker. Override the port with KC_BASE (default localhost:8081); +# the tests read the same variable. +# ============================================================================= +set -euo pipefail + +KC=${KC_BASE:-http://localhost:8081} +PORT=${KC##*:} +CONTAINER=kc-rmcp-test + +docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +docker run -d --name "$CONTAINER" -p "$PORT:8080" \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:26.0 start-dev >/dev/null + +echo "waiting for keycloak at $KC ..." +until curl -sf -o /dev/null "$KC/realms/master/.well-known/openid-configuration"; do + sleep 3 +done + +token=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \ + -d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password | + python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') + +provision() { + curl -s -X POST "$KC/admin/realms$1" \ + -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ + -d "$2" -o /dev/null -w " ${1:-/} -> %{http_code}\n" +} + +# revokeRefreshToken makes refresh tokens single-use. The concurrency test +# depends on it: without the refresh guard the second caller replays a consumed +# token and Keycloak answers invalid_grant. +provision "" '{"realm":"rmcp","enabled":true,"revokeRefreshToken":true,"refreshTokenMaxReuse":0}' +provision "/rmcp/clients" '{"clientId":"rmcp-client","secret":"rmcp-secret","publicClient":false, + "directAccessGrantsEnabled":true,"standardFlowEnabled":true, + "redirectUris":["http://localhost/callback"]}' +# The profile fields and empty requiredActions keep the direct access grant from +# failing with "Account is not fully set up". +provision "/rmcp/users" '{"username":"alice","enabled":true,"emailVerified":true, + "email":"alice@example.com","firstName":"Alice","lastName":"Example","requiredActions":[], + "credentials":[{"type":"password","value":"alice-pw","temporary":false}]}' + +echo "ready" From 7d14f3a8a2419dc91dc439f3c308e327d4e9f0c7 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:04:40 -0400 Subject: [PATCH 06/29] feat(macros): reject empty tool_router (#1233) Closes #1174 Claude-Session: https://claude.ai/code/session_017tyTJfwGpE8fidbZSRi6uU --- crates/rmcp-macros/src/lib.rs | 32 ++++++++++++ crates/rmcp-macros/src/tool_router.rs | 72 +++++++++++++++++++++++++++ crates/rmcp/tests/test_tool_macros.rs | 44 ++++++++++++++++ 3 files changed, 148 insertions(+) diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index 156e53b..176ead7 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -56,6 +56,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream { /// | `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. | +/// | `allow_empty` | `flag` | When set, accepts an impl block with no `#[tool]` fn. Without it, an empty router is a compile error. | /// /// ## Example /// @@ -122,6 +123,37 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream { /// } /// } /// ``` +/// +/// ### Empty routers +/// +/// Collecting tools is this attribute's whole purpose, so an impl block with no `#[tool]` fn is a +/// compile error rather than a router that silently serves nothing. Pass `allow_empty` when that +/// is what you want: +/// +/// ```rust,ignore +/// #[tool_router(allow_empty)] +/// impl MyToolHandler {} +/// ``` +/// +/// The usual way to hit this by accident is a `macro_rules!` helper *inside* the impl block. An +/// attribute macro receives the unexpanded item, so `#[tool]` fns produced by such a helper are +/// invisible to `#[tool_router]`. Let the `macro_rules!` emit the whole annotated impl instead: +/// +/// ```rust,ignore +/// macro_rules! define_tools { +/// ($($name:ident => $description:literal),* $(,)?) => { +/// #[tool_router] +/// impl MyToolHandler { +/// $( +/// #[tool(description = $description)] +/// async fn $name(&self) -> String { stringify!($name).to_owned() } +/// )* +/// } +/// }; +/// } +/// +/// define_tools!(my_tool => "what my tool does"); +/// ``` #[proc_macro_attribute] pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream { tool_router::tool_router(attr.into(), input.into()) diff --git a/crates/rmcp-macros/src/tool_router.rs b/crates/rmcp-macros/src/tool_router.rs index edc8630..6efd692 100644 --- a/crates/rmcp-macros/src/tool_router.rs +++ b/crates/rmcp-macros/src/tool_router.rs @@ -17,6 +17,8 @@ pub struct ToolRouterAttribute { /// 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, + /// When set, accept an impl block with no `#[tool]` fn instead of reporting an error. + pub allow_empty: bool, } impl Default for ToolRouterAttribute { @@ -25,6 +27,7 @@ impl Default for ToolRouterAttribute { router: format_ident!("tool_router"), vis: None, server_handler: false, + allow_empty: false, } } } @@ -35,6 +38,7 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result(input)?; // find all function marked with `#[rmcp::tool]` @@ -58,6 +62,19 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result<()> { + let input = quote! { + impl Probe { + #[tool(description = "probe")] + async fn probe(&self) -> String { "probed".to_owned() } + } + }; + let generated = tool_router(TokenStream::new(), input)?.to_string(); + assert!(generated.contains("with_route"), "{generated}"); + Ok(()) + } + + #[test] + fn tool_router_allow_empty_generates_a_router_without_routes() -> syn::Result<()> { + let generated = tool_router(quote! { allow_empty }, quote! { impl Probe {} })?.to_string(); + assert!(generated.contains("fn tool_router"), "{generated}"); + assert!(!generated.contains("with_route"), "{generated}"); + Ok(()) + } } diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index 9b9530a..4975109 100644 --- a/crates/rmcp/tests/test_tool_macros.rs +++ b/crates/rmcp/tests/test_tool_macros.rs @@ -573,3 +573,47 @@ fn test_manual_get_info_not_overridden() { "manual resources should be preserved" ); } + +/// Server whose tools come from a `macro_rules!` helper wrapping the whole annotated impl. +#[derive(Debug, Clone)] +struct MacroGeneratedServer; + +macro_rules! define_tools { + ($($name:ident => $description:literal),* $(,)?) => { + #[tool_router] + impl MacroGeneratedServer { + $( + #[tool(description = $description)] + async fn $name(&self) -> String { + stringify!($name).to_owned() + } + )* + } + }; +} + +define_tools!(probe => "what a capability would own"); + +#[test] +fn test_macro_rules_around_the_impl_registers_tools() { + let tools = MacroGeneratedServer::tool_router().list_all(); + + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "probe"); + assert_eq!( + tools[0].description.as_deref(), + Some("what a capability would own") + ); +} + +/// Server that opts in to a router with no tools. +#[derive(Debug, Clone)] +struct EmptyRouterServer; + +#[tool_router(allow_empty)] +impl EmptyRouterServer {} + +#[test] +fn test_allow_empty_builds_a_router_without_tools() { + assert!(EmptyRouterServer::tool_router().list_all().is_empty()); +} From f9a68bfa5cad065727bf847086010d5bf7f2028d Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:05:04 -0400 Subject: [PATCH 07/29] feat: add ServerHandler::negotiate_initialize (#1247) --- crates/rmcp/src/handler/server.rs | 66 ++++++++++-- crates/rmcp/src/model.rs | 84 ++++++++++++++- crates/rmcp/src/service/server.rs | 4 +- .../test_protocol_version_negotiation.rs | 100 +++++++++++++++++- examples/servers/src/common/counter.rs | 5 +- 5 files changed, 246 insertions(+), 13 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index b5f70be..7098552 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -321,16 +321,59 @@ macro_rules! server_handler_methods { context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { context.peer.set_peer_info(request.clone()); + std::future::ready(self.negotiate_initialize(&request)) + } + /// Build the `initialize` response for `request`, negotiating the + /// protocol version against [`Self::supported_protocol_versions`]. + /// + /// This is the whole body of the default [`Self::initialize`] minus its + /// `set_peer_info` side effect, so a server that overrides `initialize` + /// to add its own can call this instead of restating the negotiation + /// rule: + /// + /// ``` + /// use rmcp::{ + /// ErrorData as McpError, RoleServer, ServerHandler, + /// model::{InitializeRequestParams, InitializeResult, ServerInfo}, + /// service::RequestContext, + /// }; + /// + /// struct MyServer; + /// + /// impl ServerHandler for MyServer { + /// fn get_info(&self) -> ServerInfo { + /// ServerInfo::default() + /// } + /// + /// async fn initialize( + /// &self, + /// request: InitializeRequestParams, + /// context: RequestContext, + /// ) -> Result { + /// // ... record telemetry, register the peer, etc. + /// context.peer.set_peer_info(request.clone()); + /// self.negotiate_initialize(&request) + /// } + /// } + /// ``` + /// + /// # Errors + /// + /// Returns [`ErrorCode::UNSUPPORTED_PROTOCOL_VERSION`] when this server + /// supports no version that still has an `initialize` handshake. + /// + /// [`ErrorCode::UNSUPPORTED_PROTOCOL_VERSION`]: crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION + fn negotiate_initialize( + &self, + request: &InitializeRequestParams, + ) -> Result { let mut info = self.get_info(); - let negotiated = negotiate_protocol_version( + info.protocol_version = negotiate_protocol_version( &request.protocol_version, std::mem::take(&mut info.protocol_version), &self.supported_protocol_versions(), - ); - std::future::ready(negotiated.map(|version| { - info.protocol_version = version; - info - })) + )?; + Ok(info) } /// Return the protocol versions supported by this server. /// @@ -339,6 +382,10 @@ macro_rules! server_handler_methods { /// list is advertised by [`Self::discover`], bounds what `initialize` /// negotiation may agree to, and is what per-request versions are /// validated against. + /// + /// To support everything up to some ceiling, use + /// [`ProtocolVersion::known_up_to`] rather than filtering + /// [`ProtocolVersion::KNOWN_VERSIONS`] by hand. fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS) } @@ -621,6 +668,13 @@ macro_rules! impl_server_handler_for_wrapper { (**self).initialize(request, context) } + fn negotiate_initialize( + &self, + request: &InitializeRequestParams, + ) -> Result { + (**self).negotiate_initialize(request) + } + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { (**self).supported_protocol_versions() } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 6a14098..be911f7 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -177,7 +177,7 @@ impl ProtocolVersion { /// 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. + /// All protocol versions known to this SDK, oldest first. pub const KNOWN_VERSIONS: &[Self] = &[ Self::V_2024_11_05, Self::V_2025_03_26, @@ -190,6 +190,43 @@ impl ProtocolVersion { pub fn as_str(&self) -> &str { &self.0 } + + /// The known versions up to and including `max`, oldest first. + /// + /// Servers that implement every revision up to some ceiling can return + /// this from `supported_protocol_versions` instead of filtering + /// [`Self::KNOWN_VERSIONS`] by hand. `max` itself need not be a known + /// version; the result is empty when it predates all of them. + /// + /// The result borrows from [`Self::KNOWN_VERSIONS`], so call it directly + /// in the method body — it needs no `static` and no `LazyLock`: + /// + /// ```rust,ignore + /// const MAX_SUPPORTED: ProtocolVersion = ProtocolVersion::V_2025_11_25; + /// + /// fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + /// Cow::Borrowed(ProtocolVersion::known_up_to(&MAX_SUPPORTED)) + /// } + /// ``` + /// + /// ``` + /// # use rmcp::model::ProtocolVersion; + /// assert_eq!( + /// ProtocolVersion::known_up_to(&ProtocolVersion::V_2025_06_18), + /// &[ + /// ProtocolVersion::V_2024_11_05, + /// ProtocolVersion::V_2025_03_26, + /// ProtocolVersion::V_2025_06_18, + /// ], + /// ); + /// ``` + pub fn known_up_to(max: &Self) -> &'static [Self] { + let count = Self::KNOWN_VERSIONS + .iter() + .take_while(|version| version.as_str() <= max.as_str()) + .count(); + &Self::KNOWN_VERSIONS[..count] + } } impl Serialize for ProtocolVersion { @@ -4643,6 +4680,51 @@ mod tests { use super::*; + #[test] + fn known_versions_are_ordered_oldest_first() { + // `known_up_to` walks the list as a sorted prefix. + assert!( + ProtocolVersion::KNOWN_VERSIONS + .windows(2) + .all(|pair| pair[0].as_str() < pair[1].as_str()) + ); + } + + #[test] + fn known_up_to_includes_the_ceiling_itself() { + assert_eq!( + ProtocolVersion::known_up_to(&ProtocolVersion::V_2024_11_05), + &[ProtocolVersion::V_2024_11_05] + ); + } + + #[test] + fn known_up_to_the_newest_version_yields_every_known_version() { + assert_eq!( + ProtocolVersion::known_up_to(&ProtocolVersion::V_2026_07_28), + ProtocolVersion::KNOWN_VERSIONS + ); + } + + #[test] + fn known_up_to_an_unknown_ceiling_stops_at_the_versions_below_it() { + let unknown = ProtocolVersion(Cow::Borrowed("2025-07-01")); + assert_eq!( + ProtocolVersion::known_up_to(&unknown), + &[ + ProtocolVersion::V_2024_11_05, + ProtocolVersion::V_2025_03_26, + ProtocolVersion::V_2025_06_18, + ] + ); + } + + #[test] + fn known_up_to_a_ceiling_below_every_known_version_is_empty() { + let ancient = ProtocolVersion(Cow::Borrowed("1999-01-01")); + assert!(ProtocolVersion::known_up_to(&ancient).is_empty()); + } + #[cfg(feature = "transport-streamable-http-client")] #[test] fn transport_closed_marker_accepts_only_the_process_local_token() { diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 70a1486..29e4690 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -499,7 +499,9 @@ pub(crate) fn negotiate_protocol_version( server_supported, )); }; - tracing::warn!( + // Falling back is the designed answer for a pinned client, and stateless + // HTTP re-runs it on every request, so this is not a warning. + tracing::debug!( client_requested = %client_requested, server_fallback = %legacy_fallback, "client requested a protocol version unavailable over initialize; falling back to server default" diff --git a/crates/rmcp/tests/test_protocol_version_negotiation.rs b/crates/rmcp/tests/test_protocol_version_negotiation.rs index 717a63f..aa4607a 100644 --- a/crates/rmcp/tests/test_protocol_version_negotiation.rs +++ b/crates/rmcp/tests/test_protocol_version_negotiation.rs @@ -5,13 +5,19 @@ #![cfg(not(feature = "local"))] #![cfg(feature = "client")] -use std::borrow::Cow; +use std::{ + borrow::Cow, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; use rmcp::{ ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt, model::{ - ClientInfo, ErrorCode, InitializeRequestParams, InitializeResult, ProtocolVersion, - ServerInfo, + ClientCapabilities, ClientInfo, ErrorCode, Implementation, InitializeRequestParams, + InitializeResult, ProtocolVersion, ServerInfo, }, service::{ClientInitializeError, RequestContext}, }; @@ -232,3 +238,91 @@ async fn narrowed_server_caps_even_when_it_overrides_initialize() { "the handshake layer should not raise the version above what the server supports" ); } + +/// Overrides `initialize` to run a side effect, then delegates the version +/// answer back to the SDK with [`ServerHandler::negotiate_initialize`]. +#[derive(Debug, Clone, Default)] +struct DelegatingServer { + initializations: Arc, +} + +impl ServerHandler for DelegatingServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::default() + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(HANDSHAKE_VERSIONS) + } + + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + self.initializations.fetch_add(1, Ordering::Relaxed); + context.peer.set_peer_info(request.clone()); + self.negotiate_initialize(&request) + } +} + +fn initialize_params(protocol_version: ProtocolVersion) -> InitializeRequestParams { + let mut params = InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("test-client", "0.0.0"), + ); + params.protocol_version = protocol_version; + params +} + +#[test] +fn negotiate_initialize_echoes_a_supported_version() { + let result = NarrowedServer + .negotiate_initialize(&initialize_params(ProtocolVersion::V_2025_06_18)) + .expect("a supported handshake version should negotiate"); + assert_eq!(result.protocol_version, ProtocolVersion::V_2025_06_18); +} + +#[test] +fn negotiate_initialize_caps_at_supported_versions() { + let result = NarrowedServer + .negotiate_initialize(&initialize_params(ProtocolVersion::V_2026_07_28)) + .expect("an unsupported version should fall back rather than fail"); + assert_eq!(result.protocol_version, ProtocolVersion::V_2025_11_25); +} + +#[test] +fn negotiate_initialize_keeps_the_rest_of_get_info() { + let server = NarrowedServer; + let result = server + .negotiate_initialize(&initialize_params(ProtocolVersion::V_2026_07_28)) + .expect("an unsupported version should fall back rather than fail"); + assert_eq!(result.capabilities, server.get_info().capabilities); +} + +#[test] +fn negotiate_initialize_rejects_when_no_handshake_version_is_supported() { + let error = ModernOnlyServer + .negotiate_initialize(&initialize_params(ProtocolVersion::V_2026_07_28)) + .expect_err("a server with no handshake version cannot answer initialize"); + assert_eq!(error.code, ErrorCode::UNSUPPORTED_PROTOCOL_VERSION); +} + +#[tokio::test] +async fn delegating_server_negotiates_like_the_default_initialize() { + let negotiated = + negotiated_version_with(DelegatingServer::default(), ProtocolVersion::V_2026_07_28).await; + assert_eq!( + negotiated, + ProtocolVersion::V_2025_11_25, + "an override that delegates should answer what the default initialize would" + ); +} + +#[tokio::test] +async fn delegating_server_still_runs_its_own_side_effect() { + let server = DelegatingServer::default(); + let initializations = Arc::clone(&server.initializations); + negotiated_version_with(server, ProtocolVersion::V_2025_06_18).await; + assert_eq!(initializations.load(Ordering::Relaxed), 1); +} diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index c660277..3bd1617 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -270,7 +270,7 @@ impl ServerHandler for Counter { async fn initialize( &self, - _request: InitializeRequestParams, + request: InitializeRequestParams, context: RequestContext, ) -> Result { if let Some(http_request_part) = context.extensions.get::() { @@ -278,7 +278,8 @@ impl ServerHandler for Counter { let initialize_uri = &http_request_part.uri; tracing::info!(?initialize_headers, %initialize_uri, "initialize from http server"); } - Ok(self.get_info()) + context.peer.set_peer_info(request.clone()); + self.negotiate_initialize(&request) } } From 6fa40536deb44ceaedad28f783444c4700207270 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:05:29 -0400 Subject: [PATCH 08/29] fix: resolve clippy warnings across workspace (#1195) * fix: resolve clippy warnings across workspace * ci: also run clippy on rmcp with all features except local --all-features enables the local feature, which cfg-gates out tower.rs and most of the test suite, so the existing clippy step never actually lints them. --- .github/workflows/ci.yml | 10 +- conformance/src/bin/client.rs | 5 + .../transport/streamable_http_server/tower.rs | 202 ++++++++---------- crates/rmcp/tests/test_custom_headers.rs | 90 ++++---- crates/rmcp/tests/test_prompt_macros.rs | 6 +- crates/rmcp/tests/test_prompt_routers.rs | 6 - crates/rmcp/tests/test_sampling.rs | 2 +- .../test_sep_2260_request_association.rs | 9 +- crates/rmcp/tests/test_task.rs | 6 +- crates/rmcp/tests/test_tool_routers.rs | 6 - .../rmcp/tests/test_unix_socket_transport.rs | 86 ++++---- examples/clients/src/progress_client.rs | 32 +-- examples/clients/src/sampling_stdio.rs | 5 + examples/servers/src/cimd_auth_streamhttp.rs | 20 +- examples/servers/src/common/progress_demo.rs | 4 +- examples/servers/src/completion_stdio.rs | 30 +-- .../servers/src/complex_auth_streamhttp.rs | 8 +- .../servers/src/elicitation_enum_inference.rs | 4 +- examples/servers/src/elicitation_stdio.rs | 4 +- examples/servers/src/prompt_stdio.rs | 26 +-- examples/servers/src/sampling_stdio.rs | 5 +- 21 files changed, 277 insertions(+), 289 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c71d9d5..340cf9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,9 +61,17 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - name: Run clippy + - name: Run clippy (all features) run: cargo clippy --all-targets --all-features -- -D warnings + - name: Run clippy (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 clippy --package rmcp --all-targets --no-default-features --features "$FEATURES" -- -D warnings + semver: name: SemVer Check runs-on: ubuntu-latest diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index ea98a8d..92e4a94 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -1,3 +1,8 @@ +#![expect( + deprecated, + reason = "The conformance suite still exercises deprecated sampling scenarios" +)] + use anyhow::Context; use oauth2::{ClientSecret, RefreshToken}; use rmcp::{ diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index e15dfd4..f03014e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -55,6 +55,24 @@ use crate::{ pub(crate) const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024; const STATELESS_STREAM_CHANNEL_CAPACITY: usize = 16; +struct ErrorResponse(Box); + +impl ErrorResponse { + fn into_response(self) -> BoxResponse { + *self.0 + } +} + +impl From for ErrorResponse { + fn from(response: BoxResponse) -> Self { + Self(Box::new(response)) + } +} + +type HttpResult = Result; +type RestoreResultSender = tokio::sync::watch::Sender>; +type PendingRestores = Arc>>; + #[non_exhaustive] #[derive(Debug, Clone)] pub struct StreamableHttpServerConfig { @@ -247,10 +265,6 @@ impl 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: @@ -259,7 +273,7 @@ impl StreamableHttpServerConfig { fn validate_protocol_version_header( headers: &http::HeaderMap, allow_unknown: bool, -) -> Result<(), BoxResponse> { +) -> HttpResult<()> { if let Some(value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) { let version_str = value.to_str().map_err(|_| { Response::builder() @@ -284,7 +298,8 @@ fn validate_protocol_version_header( ))) .boxed(), ) - .expect("valid response")); + .expect("valid response") + .into()); } } Ok(()) @@ -349,28 +364,24 @@ impl> Service for NegotiatingStatelessHttpSer } } -#[expect( - clippy::result_large_err, - reason = "BoxResponse is intentionally large; matches other handlers in this file" -)] // 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, -) -> Result { +) -> HttpResult { 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()), - )?; - } + if let ClientJsonRpcMessage::Request(req) = message + && 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)?; } @@ -430,10 +441,10 @@ async fn persist_and_forward_event( 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; - } + if let Some(sender) = output + && sender.send(event).await.is_err() + { + *output = None; } Ok(()) } @@ -464,16 +475,12 @@ fn invalid_params_jsonrpc_response( .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> { +) -> HttpResult<()> { let Some(header_value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { return Ok(()); }; @@ -494,19 +501,16 @@ fn validate_header_matches_init_body( format!( "Invalid Request: MCP-Protocol-Version header ({header_str}) does not match initialize params.protocolVersion ({body_version})" ), - )); + ) + .into()); } 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> { +) -> HttpResult<()> { let ClientJsonRpcMessage::Request(request) = message else { return Ok(()); }; @@ -530,7 +534,8 @@ fn validate_request_protocol_version_meta( "Invalid params: request _meta is missing or has malformed required fields: {}", missing.join(", ") ), - )); + ) + .into()); } return Ok(()); }; @@ -538,7 +543,8 @@ fn validate_request_protocol_version_meta( return Err(header_mismatch_jsonrpc_response( Some(request.id.clone()), "request _meta protocolVersion requires MCP-Protocol-Version header", - )); + ) + .into()); }; if header_version != meta_version.as_str() { return Err(header_mismatch_jsonrpc_response( @@ -546,7 +552,8 @@ fn validate_request_protocol_version_meta( format!( "MCP-Protocol-Version header ({header_version}) does not match request _meta protocolVersion ({meta_version})" ), - )); + ) + .into()); } Ok(()) } @@ -557,15 +564,11 @@ fn validate_request_protocol_version_meta( /// 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> { +) -> HttpResult<()> { if !config.stateless_protocol_metadata_required { return Ok(()); } @@ -583,7 +586,8 @@ fn validate_required_protocol_header( Err(header_mismatch_jsonrpc_response( Some(request.id.clone()), "Missing MCP-Protocol-Version header for request requiring per-request protocol metadata", - )) + ) + .into()) } /// When `stateless_protocol_metadata_required` is enabled in stateless mode, @@ -593,14 +597,10 @@ fn validate_required_protocol_header( /// `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> { +) -> HttpResult<()> { if !config.stateless_protocol_metadata_required { return Ok(()); } @@ -619,7 +619,8 @@ fn validate_required_protocol_meta( Err(invalid_params_jsonrpc_response( Some(request.id.clone()), "Invalid params: request requires protocolVersion in request _meta", - )) + ) + .into()) } fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { @@ -640,7 +641,7 @@ fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { fn jsonrpc_message_response( message: ServerJsonRpcMessage, map_protocol_status: bool, -) -> Result { +) -> HttpResult { let status = if map_protocol_status { jsonrpc_http_status(&message) } else { @@ -674,15 +675,11 @@ fn header_mismatch_jsonrpc_response( /// 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> { +) -> HttpResult<()> { let version_requires_headers = headers .get(HEADER_MCP_PROTOCOL_VERSION) .and_then(|value| value.to_str().ok()) @@ -715,7 +712,7 @@ fn validate_standard_headers( .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)); + return Err(header_mismatch_jsonrpc_response(request_id, reason).into()); } Ok(()) } @@ -839,10 +836,7 @@ fn bad_request_response(message: &str) -> BoxResponse { .expect("failed to build bad request response") } -fn parse_host_header( - uri: &http::Uri, - headers: &HeaderMap, -) -> Result { +fn parse_host_header(uri: &http::Uri, headers: &HeaderMap) -> HttpResult { if let Some(host) = headers.get(http::header::HOST) { let host_str = host .to_str() @@ -873,23 +867,20 @@ fn validate_dns_rebinding_headers( uri: &http::Uri, headers: &HeaderMap, config: &StreamableHttpServerConfig, -) -> Result<(), BoxResponse> { +) -> HttpResult<()> { let host = parse_host_header(uri, 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")); + return Err(forbidden_response("Forbidden: Host header is not allowed").into()); } validate_origin_header(headers, &config.allowed_origins)?; Ok(()) } -fn validate_origin_header( - headers: &HeaderMap, - allowed_origins: &[String], -) -> Result<(), BoxResponse> { +fn validate_origin_header(headers: &HeaderMap, allowed_origins: &[String]) -> HttpResult<()> { if allowed_origins.is_empty() { return Ok(()); } @@ -914,9 +905,7 @@ fn validate_origin_header( origin = ?origin, "rejected request with disallowed Origin header (possible cross-origin attack)", ); - return Err(forbidden_response( - "Forbidden: Origin header is not allowed", - )); + return Err(forbidden_response("Forbidden: Origin header is not allowed").into()); } Ok(()) } @@ -1012,9 +1001,7 @@ pub struct StreamableHttpService { /// 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>>>>, - >, + pending_restores: Option, /// 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. @@ -1067,10 +1054,9 @@ where /// `result` defaults to `false` (failure / cancellation). Only the success path /// needs to set it to `true` before returning. struct PendingRestoreGuard { - pending_restores: - Arc>>>>, + pending_restores: PendingRestores, session_id: SessionId, - watch_tx: tokio::sync::watch::Sender>, + watch_tx: RestoreResultSender, /// The value that will be broadcast to waiting tasks on drop. result: bool, } @@ -1098,12 +1084,10 @@ 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())) - }); + let pending_restores = config + .session_store + .is_some() + .then(|| Arc::new(tokio::sync::RwLock::new(HashMap::new()))); Self { config, session_manager, @@ -1130,19 +1114,18 @@ where tokio::spawn(async move { let mut sender = Some(sender); - if let Some(retry) = retry { - if let Err(error) = persist_and_forward_event( + if let Some(retry) = retry + && 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; - } + { + tracing::error!(%stream_id, %error, "failed to persist SSE priming event"); + request_ct.cancel(); + return; } let mut first = first; @@ -1214,7 +1197,7 @@ where service: S, mut request: crate::model::JsonRpcRequest, parts: http::request::Parts, - ) -> Result { + ) -> HttpResult { let peer_info = Self::peer_info_for_stateless_request(&request, &parts.headers); request.request.extensions_mut().insert(parts); let (transport, mut receiver) = @@ -1279,10 +1262,10 @@ where /// 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(); - } + if let Ok(cache) = self.tool_schemas.read() + && let Some(schema) = cache.get(name) + { + return schema.clone(); } let schema = self .get_service() @@ -1472,23 +1455,15 @@ where Some(init_done_tx), ); - if let Err(e) = self - .session_manager + self.session_manager .initialize_session(session_id, restore_init) .await - .map_err(|e| std::io::Error::other(e.to_string())) - { - return Err(e); - } + .map_err(|e| std::io::Error::other(e.to_string()))?; - if let Err(e) = self - .session_manager + self.session_manager .accept_message(session_id, restore_initialized) .await - .map_err(|e| std::io::Error::other(e.to_string())) - { - return Err(e); - } + .map_err(|e| std::io::Error::other(e.to_string()))?; if init_done_rx.await.is_err() { return Err(std::io::Error::other( @@ -1513,7 +1488,7 @@ where if let Err(response) = validate_dns_rebinding_headers(request.uri(), request.headers(), &self.config) { - return response; + return response.into_response(); } let method = request.method().clone(); let supports_stateless_replay = self.session_manager.event_store().is_some(); @@ -1540,10 +1515,10 @@ where }; match result { Ok(response) => response, - Err(response) => response, + Err(response) => response.into_response(), } } - async fn handle_get(&self, request: Request) -> Result + async fn handle_get(&self, request: Request) -> HttpResult where B: Body + Send + 'static, B::Error: Display, @@ -1686,7 +1661,7 @@ where )) } - async fn handle_post(&self, request: Request) -> Result + async fn handle_post(&self, request: Request) -> HttpResult where B: Body + Send + 'static, B::Error: Display, @@ -1838,7 +1813,7 @@ where 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")); + return Err(unexpected_message_response("initialize request").into()); }; // Reject mismatched MCP-Protocol-Version header before binding the session to anything. validate_header_matches_init_body( @@ -1856,7 +1831,7 @@ where stored_init_params } _ => { - return Err(unexpected_message_response("initialize request")); + return Err(unexpected_message_response("initialize request").into()); } }; let service = self @@ -2013,7 +1988,8 @@ where std::io::ErrorKind::UnexpectedEof, "no response message received from handler", ), - )); + ) + .into()); }; tracing::trace!(?message); if matches!( @@ -2045,7 +2021,7 @@ where } } - async fn handle_delete(&self, request: Request) -> Result + async fn handle_delete(&self, request: Request) -> HttpResult where B: Body + Send + 'static, B::Error: Display, diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 736dce1..cb10182 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -380,10 +380,10 @@ async fn test_mcp_custom_headers_sent_to_server() -> anyhow::Result<()> { 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()); - } + if name_str.starts_with("x-") + && let Ok(v) = value.to_str() + { + headers_map.insert(name_str.to_string(), v.to_string()); } } @@ -392,48 +392,48 @@ async fn test_mcp_custom_headers_sent_to_server() -> anyhow::Result<()> { 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" - } + if let Ok(json_body) = serde_json::from_slice::(&body) + && 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(), - ); - } + } + }); + 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(), + ); } } diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs index 7a00249..642ae88 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -4,14 +4,12 @@ use std::sync::Arc; use rmcp::{ - ClientHandler, RoleServer, ServerHandler, ServiceExt, + ClientHandler, ServerHandler, ServiceExt, handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, model::{ - ClientInfo, ContentBlock, GetPromptRequestParams, GetPromptResult, ListPromptsResult, - PaginatedRequestParams, PromptMessage, Role, + ClientInfo, ContentBlock, GetPromptRequestParams, GetPromptResult, PromptMessage, Role, }, prompt, prompt_handler, prompt_router, - service::RequestContext, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/rmcp/tests/test_prompt_routers.rs b/crates/rmcp/tests/test_prompt_routers.rs index eecdb1e..e730579 100644 --- a/crates/rmcp/tests/test_prompt_routers.rs +++ b/crates/rmcp/tests/test_prompt_routers.rs @@ -20,12 +20,6 @@ struct Request { fields: HashMap, } -#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -struct Sum { - a: i32, - b: i32, -} - #[rmcp::prompt_router(router = "test_router")] impl TestHandler { #[rmcp::prompt] diff --git a/crates/rmcp/tests/test_sampling.rs b/crates/rmcp/tests/test_sampling.rs index b108ff4..3c82fd4 100644 --- a/crates/rmcp/tests/test_sampling.rs +++ b/crates/rmcp/tests/test_sampling.rs @@ -375,7 +375,7 @@ fn test_tool_result_content_requires_content() { #[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::float(serde_json::json!(3.5))] #[case::boolean(serde_json::json!(true))] fn tool_result_content_round_trips_non_object_structured_content( #[case] structured: serde_json::Value, diff --git a/crates/rmcp/tests/test_sep_2260_request_association.rs b/crates/rmcp/tests/test_sep_2260_request_association.rs index b0e7af9..b02cff0 100644 --- a/crates/rmcp/tests/test_sep_2260_request_association.rs +++ b/crates/rmcp/tests/test_sep_2260_request_association.rs @@ -1,5 +1,8 @@ #![cfg(all(feature = "server", feature = "client", not(feature = "local")))] -#![allow(deprecated)] +#![expect( + deprecated, + reason = "This test verifies request association for the deprecated sampling API" +)] use std::sync::{Arc, Mutex}; @@ -18,9 +21,11 @@ use tokio::{ sync::oneshot, }; +type RequestResultSender = oneshot::Sender>; + #[derive(Clone)] struct SamplingServer { - outside: Arc>>>>, + outside: Arc>>, } impl ServerHandler for SamplingServer { diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index ea1a259..55ba4cc 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -13,9 +13,9 @@ use rmcp::{ use serde_json::json; #[derive(Debug, serde::Deserialize, rmcp::schemars::JsonSchema)] -pub struct SumArgs { - pub a: i32, - pub b: i32, +struct SumArgs { + a: i32, + b: i32, } #[derive(Clone)] diff --git a/crates/rmcp/tests/test_tool_routers.rs b/crates/rmcp/tests/test_tool_routers.rs index d2bbe86..12a5d40 100644 --- a/crates/rmcp/tests/test_tool_routers.rs +++ b/crates/rmcp/tests/test_tool_routers.rs @@ -21,12 +21,6 @@ struct Request { fields: HashMap, } -#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)] -struct Sum { - a: i32, - b: i32, -} - #[rmcp::tool_router(router = test_router_1)] impl TestHandler { #[rmcp::tool] diff --git a/crates/rmcp/tests/test_unix_socket_transport.rs b/crates/rmcp/tests/test_unix_socket_transport.rs index 4c4ad52..d0e5397 100644 --- a/crates/rmcp/tests/test_unix_socket_transport.rs +++ b/crates/rmcp/tests/test_unix_socket_transport.rs @@ -35,10 +35,10 @@ async fn mcp_handler( 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()); - } + if (name_str.starts_with("x-") || name_str == "host") + && let Ok(v) = value.to_str() + { + headers_map.insert(name_str.to_string(), v.to_string()); } } @@ -46,46 +46,46 @@ async fn mcp_handler( 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" - } + if let Ok(json_body) = serde_json::from_slice::(&body) + && 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(), - ); - } + } + }); + 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(), + ); } } diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index a9f68c1..ad00dea 100644 --- a/examples/clients/src/progress_client.rs +++ b/examples/clients/src/progress_client.rs @@ -100,10 +100,10 @@ impl ProgressAwareClient { } fn stop_tracking(&self) { - if let Ok(mut tracker_opt) = self.tracker.lock() { - if let Some(tracker) = tracker_opt.take() { - tracker.print_summary(); - } + if let Ok(mut tracker_opt) = self.tracker.lock() + && let Some(tracker) = tracker_opt.take() + { + tracker.print_summary(); } } } @@ -114,10 +114,10 @@ impl ClientHandler for ProgressAwareClient { params: ProgressNotificationParam, _context: NotificationContext, ) { - if let Ok(tracker_opt) = self.tracker.lock() { - if let Some(tracker) = tracker_opt.as_ref() { - tracker.handle_progress(¶ms); - } + if let Ok(tracker_opt) = self.tracker.lock() + && let Some(tracker) = tracker_opt.as_ref() + { + tracker.handle_progress(¶ms); } } @@ -182,10 +182,10 @@ async fn test_stdio_transport(records: u32) -> Result<()> { .call_tool(CallToolRequestParams::new("stream_processor")) .await?; - if let Some(content) = tool_result.content.first() { - if let Some(text) = content.as_text() { - tracing::info!("Processing completed: {}", text.text); - } + if let Some(content) = tool_result.content.first() + && let Some(text) = content.as_text() + { + tracing::info!("Processing completed: {}", text.text); } service.cancel().await?; @@ -236,10 +236,10 @@ async fn test_http_transport(http_url: &str, records: u32) -> Result<()> { .call_tool(CallToolRequestParams::new("stream_processor")) .await?; - if let Some(content) = tool_result.content.first() { - if let Some(text) = content.as_text() { - tracing::info!("processing completed: {}", text.text); - } + if let Some(content) = tool_result.content.first() + && let Some(text) = content.as_text() + { + tracing::info!("processing completed: {}", text.text); } client.cancel().await?; diff --git a/examples/clients/src/sampling_stdio.rs b/examples/clients/src/sampling_stdio.rs index cc7c5f1..1079586 100644 --- a/examples/clients/src/sampling_stdio.rs +++ b/examples/clients/src/sampling_stdio.rs @@ -1,3 +1,8 @@ +#![expect( + deprecated, + reason = "This example demonstrates the deprecated MCP sampling API" +)] + use anyhow::Result; use rmcp::{ ClientHandler, ServiceExt, diff --git a/examples/servers/src/cimd_auth_streamhttp.rs b/examples/servers/src/cimd_auth_streamhttp.rs index 6a634d8..a812977 100644 --- a/examples/servers/src/cimd_auth_streamhttp.rs +++ b/examples/servers/src/cimd_auth_streamhttp.rs @@ -151,16 +151,16 @@ async fn fetch_and_validate_client_metadata(client_id_url: &str) -> Result(progress_token.clone()) else { return Err(McpError::internal_error( - format!("Invalid format of the progress token"), + "Invalid format of the progress token", None, )); }; diff --git a/examples/servers/src/completion_stdio.rs b/examples/servers/src/completion_stdio.rs index 812ed31..ffaeee5 100644 --- a/examples/servers/src/completion_stdio.rs +++ b/examples/servers/src/completion_stdio.rs @@ -123,23 +123,23 @@ impl SqlQueryServer { .collect(); // If no uppercase letters found, just use first letter - if first_chars.is_empty() && !candidate.is_empty() { - if let Some(first) = candidate.chars().next() { - first_chars.push(first.to_lowercase().next().unwrap_or('\0')); - } + if first_chars.is_empty() + && let Some(first) = candidate.chars().next() + { + first_chars.push(first.to_lowercase().next().unwrap_or('\0')); } } // Special case: if query is 2 chars and we only got 1 char, try matching first 2 letters - if query_chars.len() == 2 && first_chars.len() == 1 { - if let Some(first) = candidate.chars().nth(0) { - if let Some(second) = candidate.chars().nth(1) { - first_chars = vec![ - first.to_lowercase().next().unwrap_or('\0'), - second.to_lowercase().next().unwrap_or('\0'), - ]; - } - } + if query_chars.len() == 2 + && first_chars.len() == 1 + && let Some(first) = candidate.chars().next() + && let Some(second) = candidate.chars().nth(1) + { + first_chars = vec![ + first.to_lowercase().next().unwrap_or('\0'), + second.to_lowercase().next().unwrap_or('\0'), + ]; } if query_chars.len() != first_chars.len() { @@ -193,7 +193,7 @@ impl SqlQueryServer { } } -#[prompt_router] +#[prompt_router(router = "prompt_router")] impl SqlQueryServer { #[prompt(name = "sql_query", description = "Smart SQL query builder")] async fn sql_query( @@ -308,7 +308,7 @@ impl SqlQueryServer { } } -#[prompt_handler] +#[prompt_handler(router = self.prompt_router)] impl ServerHandler for SqlQueryServer { fn get_info(&self) -> ServerInfo { ServerInfo::new( diff --git a/examples/servers/src/complex_auth_streamhttp.rs b/examples/servers/src/complex_auth_streamhttp.rs index 34c4b15..bf763d3 100644 --- a/examples/servers/src/complex_auth_streamhttp.rs +++ b/examples/servers/src/complex_auth_streamhttp.rs @@ -69,10 +69,10 @@ impl McpOAuthStore { redirect_uri: &str, ) -> Option { let clients = self.clients.read().await; - if let Some(client) = clients.get(client_id) { - if client.redirect_uri.contains(&redirect_uri.to_string()) { - return Some(client.clone()); - } + if let Some(client) = clients.get(client_id) + && client.redirect_uri == redirect_uri + { + return Some(client.clone()); } None } diff --git a/examples/servers/src/elicitation_enum_inference.rs b/examples/servers/src/elicitation_enum_inference.rs index bed5c1d..50d2844 100644 --- a/examples/servers/src/elicitation_enum_inference.rs +++ b/examples/servers/src/elicitation_enum_inference.rs @@ -97,7 +97,7 @@ struct ElicitationEnumFormServer { tool_router: ToolRouter, } -#[tool_router] +#[tool_router(router = tool_router)] impl ElicitationEnumFormServer { pub fn new() -> Self { Self { @@ -153,7 +153,7 @@ impl ElicitationEnumFormServer { } } -#[tool_handler] +#[tool_handler(router = self.tool_router)] impl ServerHandler for ElicitationEnumFormServer { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index d506a9c..16b4773 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -64,7 +64,7 @@ impl Default for ElicitationServer { } } -#[tool_router] +#[tool_router(router = tool_router)] impl ElicitationServer { #[tool(description = "Greet user with name collection")] async fn greet_user( @@ -145,7 +145,7 @@ impl ElicitationServer { } } -#[tool_handler] +#[tool_handler(router = self.tool_router)] impl ServerHandler for ElicitationServer { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) diff --git a/examples/servers/src/prompt_stdio.rs b/examples/servers/src/prompt_stdio.rs index 7b6e285..6ef24e9 100644 --- a/examples/servers/src/prompt_stdio.rs +++ b/examples/servers/src/prompt_stdio.rs @@ -112,7 +112,7 @@ impl Default for PromptServer { } } -#[prompt_router] +#[prompt_router(router = "prompt_router")] impl PromptServer { /// Simple greeting prompt without parameters #[prompt( @@ -305,17 +305,17 @@ impl PromptServer { ]; // Add tried solutions if any - if let Some(tried) = args.tried_solutions { - if !tried.is_empty() { - messages.push(PromptMessage::new_text( - Role::User, - format!("I've already tried: {}", tried.join(", ")), - )); - messages.push(PromptMessage::new_text( - Role::Assistant, - "I see you've already attempted some solutions. Let me suggest different approaches.", - )); - } + if let Some(tried) = args.tried_solutions + && !tried.is_empty() + { + messages.push(PromptMessage::new_text( + Role::User, + format!("I've already tried: {}", tried.join(", ")), + )); + messages.push(PromptMessage::new_text( + Role::Assistant, + "I see you've already attempted some solutions. Let me suggest different approaches.", + )); } messages.push(PromptMessage::new_text( @@ -361,7 +361,7 @@ impl PromptServer { } } -#[prompt_handler] +#[prompt_handler(router = self.prompt_router)] impl ServerHandler for PromptServer { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_prompts().build()).with_instructions( diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index be230ad..2be7f5d 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -1,4 +1,7 @@ -#![allow(deprecated)] +#![expect( + deprecated, + reason = "This example demonstrates the deprecated MCP sampling API" +)] use std::sync::Arc; use anyhow::Result; From e8a0d7099aad673377b1d0e83bf192df218b2bd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:24:09 -0400 Subject: [PATCH 09/29] chore(deps): bump taiki-e/install-action from 2.86.7 to 2.87.6 (#1250) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.86.7 to 2.87.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/b6ff580856c41316412a0b9b60540fbc6f8c82cc...7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.6 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 340cf9a..da17529 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7 + uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6 with: tool: cargo-semver-checks @@ -141,7 +141,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Install cargo-public-api - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7 + uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6 with: tool: cargo-public-api From 744b9f904c7f17d589326e61ddbe126cb9d58888 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:24:44 -0400 Subject: [PATCH 10/29] chore(deps): bump crate-ci/typos (#1249) Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1a51d4b5a03bb97576af186c813af67e9137ba7c to d43b6c087ac471e2ea7b8af622ff15f05c0c365b. - [Release notes](https://github.com/crate-ci/typos/releases) - [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md) - [Commits](https://github.com/crate-ci/typos/compare/1a51d4b5a03bb97576af186c813af67e9137ba7c...d43b6c087ac471e2ea7b8af622ff15f05c0c365b) --- updated-dependencies: - dependency-name: crate-ci/typos dependency-version: d43b6c087ac471e2ea7b8af622ff15f05c0c365b dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da17529..e085219 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Spell Check Repo - uses: crate-ci/typos@1a51d4b5a03bb97576af186c813af67e9137ba7c # master + uses: crate-ci/typos@d43b6c087ac471e2ea7b8af622ff15f05c0c365b # master msrv: name: Check MSRV From 46db531df975ba1bd44fceabf2b3ff9cc9a21514 Mon Sep 17 00:00:00 2001 From: ump45nose <52391318+ump45nose@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:24:29 +0800 Subject: [PATCH 11/29] fix(sse): saturate exponential reconnect backoff to avoid overflow panic (#1231) * fix(sse): saturate exponential reconnect backoff ExponentialBackoff::retry computed the reconnect multiplier with 2u32.pow(current_times). With max_times unset, current_times can reach the bit width, panicking in debug builds and wrapping to a zero delay in release builds for long-lived SSE clients. Use saturating_pow and Duration::saturating_mul so the delay stays monotonic and panic-free. * fix(sse): cap exponential reconnect backoff at a bounded max delay Saturating the multiplier alone can still yield decades-long sleeps once current_times reaches the bit width, pinning the stream in tokio::time::sleep without reconnecting or terminating. Add an optional max_delay (default 30s) that clamps the computed delay, keeping the backoff monotonic and panic-free while guaranteeing the client retries. * fix(sse): default max_delay to None to preserve unbounded backoff Per maintainer feedback, leave ExponentialBackoff::default() unbounded so the fix stays a pure overflow bug-fix. The saturating multiplier removes the debug panic / release wrap from #1198, while max_delay stays opt-in for callers that want a bounded reconnect delay. --- .../src/transport/common/client_side_sse.rs | 88 ++++++++++++++++++- .../src/transport/streamable_http_client.rs | 2 + 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/common/client_side_sse.rs b/crates/rmcp/src/transport/common/client_side_sse.rs index e668d63..2ee37aa 100644 --- a/crates/rmcp/src/transport/common/client_side_sse.rs +++ b/crates/rmcp/src/transport/common/client_side_sse.rs @@ -205,6 +205,11 @@ impl Default for FixedInterval { pub struct ExponentialBackoff { pub max_times: Option, pub base_duration: Duration, + /// Optional upper bound on a single reconnect delay. `None` (the default) preserves the + /// pre-existing unbounded doubling behavior. Once the multiplier saturates near the bit + /// width that can still produce very long sleeps, so callers that need the client to + /// actually reconnect can set `Some(...)` to clamp the delay. + pub max_delay: Option, } impl ExponentialBackoff { @@ -216,6 +221,7 @@ impl Default for ExponentialBackoff { Self { max_times: None, base_duration: Self::DEFAULT_DURATION, + max_delay: None, } } } @@ -227,7 +233,16 @@ impl SseRetryPolicy for ExponentialBackoff { { return None; } - Some(self.base_duration * (2u32.pow(current_times as u32))) + // `current_times` is unbounded when `max_times` is unset, so the exponent can reach + // the bit width. Saturate the multiplier at `u32::MAX` and use saturating multiplication + // for the base duration so the delay stays monotonic and panic-free instead of an + // overflow panic (debug) or a wrapped-to-zero backoff (release). + let multiplier = 2u32.saturating_pow(current_times as u32); + let delay = self.base_duration.saturating_mul(multiplier); + Some(match self.max_delay { + Some(max_delay) => delay.min(max_delay), + None => delay, + }) } } @@ -775,4 +790,75 @@ mod tests { assert!(stream.next().await.is_none()); assert_eq!(attempts.load(Ordering::Relaxed), 0); } + + #[test] + fn exponential_backoff_saturates_at_high_retry_counts() { + // With `max_times` unset, `current_times` can reach the bit width. The old + // `2u32.pow(current_times)` panicked in debug builds and wrapped in release; + // the saturating implementation must return a monotonic, non-zero delay instead. + let policy = ExponentialBackoff { + max_times: None, + base_duration: Duration::from_millis(1), + max_delay: None, + }; + let mut previous = Duration::ZERO; + for current_times in [31usize, 32, 63, 64, 100] { + let delay = policy + .retry(current_times) + .expect("unbounded policy never gives up"); + assert!( + !delay.is_zero(), + "delay must stay non-zero at {current_times}" + ); + assert!( + delay >= previous, + "delay must stay monotonic at {current_times}" + ); + previous = delay; + } + } + + #[test] + fn exponential_backoff_caps_delay_at_max_delay() { + // An explicit cap keeps the unbounded doubling policy from producing decades-long + // sleeps once the multiplier saturates. The delay must grow monotonically, stop at + // the configured ceiling, and never exceed it. + let policy = ExponentialBackoff { + max_times: None, + base_duration: Duration::from_secs(1), + max_delay: Some(Duration::from_secs(30)), + }; + let mut previous = Duration::ZERO; + for current_times in [0usize, 1, 2, 3, 4, 5, 10, 32, 64, 100] { + let delay = policy + .retry(current_times) + .expect("unbounded policy never gives up"); + assert!( + delay >= previous, + "delay must stay monotonic at {current_times}" + ); + assert!( + delay <= Duration::from_secs(30), + "delay must respect max_delay at {current_times}" + ); + previous = delay; + } + // Beyond the ceiling the delay stays pinned at max_delay. + assert_eq!( + policy.retry(100).expect("never gives up"), + Duration::from_secs(30) + ); + } + + #[test] + fn exponential_backoff_respects_max_times() { + let policy = ExponentialBackoff { + max_times: Some(3), + base_duration: Duration::from_millis(1), + max_delay: None, + }; + assert!(policy.retry(0).is_some()); + assert!(policy.retry(2).is_some()); + assert!(policy.retry(3).is_none()); + } } diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index d702bc1..6f27abf 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -2293,6 +2293,7 @@ mod tests { Arc::new(ExponentialBackoff { max_times: Some(1), base_duration: Duration::ZERO, + max_delay: None, }), ); let mut stream = std::pin::pin!(stream); @@ -2397,6 +2398,7 @@ mod tests { Arc::new(ExponentialBackoff { max_times: Some(1), base_duration: Duration::ZERO, + max_delay: None, }), ); From b2c90b1edc85d0ef2b47d344e9505b9732b06d9b Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 10 Sep 2026 10:05:55 -0400 Subject: [PATCH 12/29] ci: pin commitlint dependencies (#1217) --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e085219..1edfe69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,12 +31,10 @@ jobs: node-version: '22' - name: Install commitlint - run: | - npm install --save-dev @commitlint/cli @commitlint/config-conventional - echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js - + run: npm install --global @commitlint/cli@20.4.3 @commitlint/config-conventional@20.4.3 + - name: Lint commit messages - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + run: commitlint --extends @commitlint/config-conventional --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose fmt: name: Code Formatting From 3e636cab26c013eca5131103c03d20237f12c4df Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:23:32 -0400 Subject: [PATCH 13/29] chore: release v3.3.0 (#1252) 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 | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b3a0da3..261b77e 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.2.0", path = "./crates/rmcp" } -rmcp-macros = { version = "3.2.0", path = "./crates/rmcp-macros" } +rmcp = { version = "3.3.0", path = "./crates/rmcp" } +rmcp-macros = { version = "3.3.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.2.0" +version = "3.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 4f59b75..67154ba 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.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.2.0...rmcp-macros-v3.3.0) - 2026-09-10 + +### Added + +- *(macros)* reject empty tool_router ([#1233](https://github.com/modelcontextprotocol/rust-sdk/pull/1233)) + ## [3.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.1.4...rmcp-macros-v3.2.0) - 2026-08-31 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 86ab5b1..5465cf2 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.2.0...rmcp-v3.3.0) - 2026-09-10 + +### Added + +- add ServerHandler::negotiate_initialize ([#1247](https://github.com/modelcontextprotocol/rust-sdk/pull/1247)) +- *(macros)* reject empty tool_router ([#1233](https://github.com/modelcontextprotocol/rust-sdk/pull/1233)) +- *(auth)* add enterprise refresh-token and ID-JAG exchanges ([#1234](https://github.com/modelcontextprotocol/rust-sdk/pull/1234)) + +### Fixed + +- *(sse)* saturate exponential reconnect backoff to avoid overflow panic ([#1231](https://github.com/modelcontextprotocol/rust-sdk/pull/1231)) +- resolve clippy warnings across workspace ([#1195](https://github.com/modelcontextprotocol/rust-sdk/pull/1195)) +- *(auth)* unify refresh checks and error handling ([#1236](https://github.com/modelcontextprotocol/rust-sdk/pull/1236)) + +### Other + +- *(deps)* update process-wrap requirement from 9.0 to 10.0 ([#1229](https://github.com/modelcontextprotocol/rust-sdk/pull/1229)) + ## [3.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.1.4...rmcp-v3.2.0) - 2026-08-31 ### Added From e27c5d1c2484cf553e59a070b1c4ef619cf1a546 Mon Sep 17 00:00:00 2001 From: Luca Date: Fri, 11 Sep 2026 02:38:27 +0100 Subject: [PATCH 14/29] fix(http): enforce Origin validation semantics (#1192) Return HTTP 403 for malformed and non-UTF-8 Origin headers when validation is enabled. Add enforce_origin_validation() for strict empty-allowlist validation while preserving the legacy default and non-empty allowlist behavior. Configure the conformance server to opt in explicitly. Signed-off-by: lucarlig --- conformance/src/bin/server.rs | 4 +- .../transport/streamable_http_server/tower.rs | 40 +++++--- crates/rmcp/tests/test_custom_headers.rs | 93 ++++++++++++++++++- 3 files changed, 117 insertions(+), 20 deletions(-) diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 0d2baf4..7b31c1d 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -1744,7 +1744,9 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting conformance server on {}", bind_addr); let server = ConformanceServer::new(); - let config = StreamableHttpServerConfig::default(); + let config = StreamableHttpServerConfig::default() + .with_allowed_origins([format!("http://{bind_addr}")]) + .enforce_origin_validation(); let service = StreamableHttpService::new( move || Ok(server.clone()), LocalSessionManager::default().into(), diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index f03014e..9506a29 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -110,13 +110,18 @@ pub struct StreamableHttpServerConfig { 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`. + /// Defaults to an empty list, which disables Origin validation for backward + /// compatibility. A non-empty list enables validation. 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`. + /// + /// Call [`StreamableHttpServerConfig::enforce_origin_validation`] to enable + /// validation with an empty list, rejecting every present Origin value. /// examples: /// allowed_origins = ["https://app.example.com", "http://localhost:8080"] pub allowed_origins: Vec, + validate_empty_origin_allowlist: bool, /// Optional external session store for cross-instance recovery. /// /// When set, [`SessionState`] (the client's `initialize` parameters) is @@ -189,6 +194,7 @@ impl Default for StreamableHttpServerConfig { cancellation_token: CancellationToken::new(), allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], allowed_origins: vec![], + validate_empty_origin_allowlist: false, session_store: None, max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, stateless_protocol_metadata_required: false, @@ -216,9 +222,15 @@ impl StreamableHttpServerConfig { self.allowed_origins = allowed_origins.into_iter().map(Into::into).collect(); self } - /// Disable Origin validation, reverting to the default ignore-Origin behavior. + /// Enable Origin validation, including when the allowed Origins list is empty. + pub fn enforce_origin_validation(mut self) -> Self { + self.validate_empty_origin_allowlist = true; + self + } + /// Disable Origin validation, allowing requests with any `Origin` header. pub fn disable_allowed_origins(mut self) -> Self { self.allowed_origins.clear(); + self.validate_empty_origin_allowlist = false; self } pub fn with_sse_keep_alive(mut self, duration: Option) -> Self { @@ -802,9 +814,6 @@ fn parse_origin_value(value: &str) -> Option { } 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)) @@ -876,12 +885,15 @@ fn validate_dns_rebinding_headers( ); return Err(forbidden_response("Forbidden: Host header is not allowed").into()); } - validate_origin_header(headers, &config.allowed_origins)?; + validate_origin_header(headers, config)?; Ok(()) } -fn validate_origin_header(headers: &HeaderMap, allowed_origins: &[String]) -> HttpResult<()> { - if allowed_origins.is_empty() { +fn validate_origin_header( + headers: &HeaderMap, + config: &StreamableHttpServerConfig, +) -> HttpResult<()> { + if !config.validate_empty_origin_allowlist && config.allowed_origins.is_empty() { return Ok(()); } let Some(origin_header) = headers.get(http::header::ORIGIN) else { @@ -892,15 +904,15 @@ fn validate_origin_header(headers: &HeaderMap, allowed_origins: &[String]) -> Ht .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"))?; + .map_err(|_| forbidden_response("Forbidden: Invalid Origin header encoding"))?; 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") + forbidden_response("Forbidden: Invalid Origin header") })?; - if !origin_is_allowed(&origin, allowed_origins) { + if !origin_is_allowed(&origin, &config.allowed_origins) { tracing::warn!( origin = ?origin, "rejected request with disallowed Origin header (possible cross-origin attack)", diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index cb10182..9d5ba86 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -880,10 +880,11 @@ fn test_protocol_version_utilities() { assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28)); } -/// Integration test: Verify server validates only the Host header for DNS rebinding protection +/// Integration test: Verify Host validation remains enabled when Origin validation is +/// disabled by default #[tokio::test] #[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] -async fn test_server_validates_host_header_for_dns_rebinding_protection() { +async fn test_server_validates_host_when_origin_validation_is_disabled_by_default() { use std::sync::Arc; use bytes::Bytes; @@ -1127,7 +1128,7 @@ mod origin_validation { use std::sync::Arc; use bytes::Bytes; - use http::{Method, Request, header::CONTENT_TYPE}; + use http::{HeaderValue, Method, Request, header::CONTENT_TYPE}; use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, @@ -1147,12 +1148,20 @@ mod origin_validation { } } - fn service_with_allowed_origins( - origins: &[&str], + fn service_with_config( + config: StreamableHttpServerConfig, ) -> StreamableHttpService { StreamableHttpService::new( || Ok(TestHandler), Arc::new(LocalSessionManager::default()), + config, + ) + } + + fn service_with_allowed_origins( + origins: &[&str], + ) -> StreamableHttpService { + service_with_config( StreamableHttpServerConfig::default().with_allowed_origins(origins.iter().copied()), ) } @@ -1199,6 +1208,80 @@ mod origin_validation { assert_eq!(response.status(), http::StatusCode::FORBIDDEN); } + #[tokio::test] + async fn malformed_origin_is_forbidden() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let response = service.handle(init_request(Some("not-an-origin"))).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn non_utf8_origin_is_forbidden() { + let service = service_with_allowed_origins(&["http://localhost:8080"]); + let mut request = init_request(None); + request.headers_mut().insert( + http::header::ORIGIN, + HeaderValue::from_bytes(b"\xff").unwrap(), + ); + let response = service.handle(request).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn enabled_empty_allowlist_forbids_present_origin() { + let service = + service_with_config(StreamableHttpServerConfig::default().enforce_origin_validation()); + let response = service + .handle(init_request(Some("http://localhost:8080"))) + .await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn enabled_empty_allowlist_allows_missing_origin() { + let service = + service_with_config(StreamableHttpServerConfig::default().enforce_origin_validation()); + let response = service.handle(init_request(None)).await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn empty_allowed_origins_preserves_disabled_validation() { + let service = service_with_config( + StreamableHttpServerConfig::default().with_allowed_origins(std::iter::empty::<&str>()), + ); + let response = service + .handle(init_request(Some("http://attacker.example"))) + .await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn nonempty_public_allowed_origins_field_enables_validation() { + let mut config = StreamableHttpServerConfig::default(); + config + .allowed_origins + .push("http://localhost:8080".to_string()); + let service = service_with_config(config); + let response = service + .handle(init_request(Some("http://attacker.example"))) + .await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn explicitly_disabled_validation_allows_present_origin() { + let service = service_with_config( + StreamableHttpServerConfig::default() + .enforce_origin_validation() + .disable_allowed_origins(), + ); + let response = service + .handle(init_request(Some("http://attacker.example"))) + .await; + assert_eq!(response.status(), http::StatusCode::OK); + } + #[tokio::test] async fn missing_origin_passes_through() { let service = service_with_allowed_origins(&["http://localhost:8080"]); From b86995aebd346842665aa6f225166306c99c151b Mon Sep 17 00:00:00 2001 From: voidstack <143511464+voidstackloop@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:54:40 +0300 Subject: [PATCH 15/29] fix(streamable-http-server): map handler-generated HeaderMismatch to HTTP 400 (#1259) `jsonrpc_http_status` maps several protocol errors (unsupported protocol version, missing required client capability, invalid params) to HTTP 400 on the modern per-request Streamable HTTP path, but fell through to the default HTTP 200 for `ErrorCode::HEADER_MISMATCH`. When a `ServerHandler` returns `Err(ErrorData::header_mismatch(...))`, RMCP serialized the correct JSON-RPC -32020 error but sent it with HTTP 200. The legacy/transport-generated header validation path (SEP-2243 Mcp-Method/Mcp-Name/Mcp-Param-* mismatches) was unaffected, since `header_mismatch_jsonrpc_response` constructs an HTTP 400 response directly rather than going through `jsonrpc_http_status`. Only handler-generated HeaderMismatch errors on the modern path hit the bug. Add `ErrorCode::HEADER_MISMATCH` to the existing `BAD_REQUEST` match arm, matching the Streamable HTTP server-validation rules in the 2026-07-28 spec. Added unit tests covering the mapper directly (header-mismatch to 400, method-not-found to 404, an unmapped code defaulting to 200). Closes #1225 Co-authored-by: Claude Sonnet 5 --- .../transport/streamable_http_server/tower.rs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 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 9506a29..35b0d9e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -644,12 +644,52 @@ fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { match error.error.code { ErrorCode::UNSUPPORTED_PROTOCOL_VERSION | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY - | ErrorCode::INVALID_PARAMS => http::StatusCode::BAD_REQUEST, + | ErrorCode::INVALID_PARAMS + | ErrorCode::HEADER_MISMATCH => http::StatusCode::BAD_REQUEST, ErrorCode::METHOD_NOT_FOUND => http::StatusCode::NOT_FOUND, _ => http::StatusCode::OK, } } +#[cfg(test)] +mod jsonrpc_http_status_tests { + use super::*; + + fn error_message(code: ErrorCode) -> ServerJsonRpcMessage { + ServerJsonRpcMessage::Error(JsonRpcError::new( + Some(RequestId::Number(1)), + ErrorData::new(code, "test error", None), + )) + } + + /// A handler returning `ErrorData::header_mismatch(..)` on the modern per-request path + /// must map to HTTP 400, not the default HTTP 200. Regression test for + /// https://github.com/modelcontextprotocol/rust-sdk/issues/1225 + #[test] + fn header_mismatch_maps_to_bad_request() { + assert_eq!( + jsonrpc_http_status(&error_message(ErrorCode::HEADER_MISMATCH)), + http::StatusCode::BAD_REQUEST + ); + } + + #[test] + fn method_not_found_maps_to_not_found() { + assert_eq!( + jsonrpc_http_status(&error_message(ErrorCode::METHOD_NOT_FOUND)), + http::StatusCode::NOT_FOUND + ); + } + + #[test] + fn unmapped_error_defaults_to_ok() { + assert_eq!( + jsonrpc_http_status(&error_message(ErrorCode::INTERNAL_ERROR)), + http::StatusCode::OK + ); + } +} + fn jsonrpc_message_response( message: ServerJsonRpcMessage, map_protocol_status: bool, From 4fa84a51306f9b9dd87caa841b4ffd1a269ab6e1 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 12 Sep 2026 07:42:12 -0700 Subject: [PATCH 16/29] fix: do not treat malformed JSON 200 as Accepted for requests (#1208) * fix: do not treat malformed JSON 200 as Accepted for requests Streamable HTTP mapped any JSON body that failed to parse as ServerJsonRpcMessage to Accepted, which is correct for notification POSTs. For a request the worker then waits on SSE forever, so call_tool hangs when the server returns 200 application/json with an empty or non-JSON-RPC body. Signed-off-by: Sebastien Tardif * fix: include a short body preview in JSON-RPC parse errors Request POST parse failures now include a 256-byte prefix of the unusable body so empty, HTML, and `{}` are distinguishable from the serde diagnostic. HTTP reads bytes() then from_slice, matching Unix. Signed-off-by: Sebastien Tardif --------- Signed-off-by: Sebastien Tardif --- crates/rmcp/Cargo.toml | 9 + .../common/reqwest/streamable_http_client.rs | 24 ++- .../rmcp/src/transport/common/unix_socket.rs | 14 +- .../src/transport/streamable_http_client.rs | 55 +++++ ..._streamable_http_malformed_json_request.rs | 195 ++++++++++++++++++ .../rmcp/tests/test_unix_socket_transport.rs | 58 +++++- 6 files changed, 346 insertions(+), 9 deletions(-) create mode 100644 crates/rmcp/tests/test_streamable_http_malformed_json_request.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index ac51ebf..0d00d14 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -348,6 +348,15 @@ 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_streamable_http_malformed_json_request" +required-features = [ + "client", + "transport-streamable-http-client", + "transport-streamable-http-client-reqwest", +] +path = "tests/test_streamable_http_malformed_json_request.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 b0b5090..1b47560 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -308,17 +308,29 @@ 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()) => { - // 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) => { + // A notification/response/error POST does not await a JSON-RPC + // reply. Treat an unusable JSON body as Accepted. A request + // still needs a reply; the same body is an error so the worker + // does not wait on SSE forever. + let body = response.bytes().await?; + match serde_json::from_slice::(&body) { + Ok(parsed) => Ok(StreamableHttpPostResponse::Json(parsed, session_id)), + Err(e) + if matches!( + message, + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) + ) => + { tracing::warn!( "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" ); Ok(StreamableHttpPostResponse::Accepted) } + Err(e) => Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + json_rpc_parse_error_message(&e, &body), + ))), } } _ => { diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs index e3b3489..688c9ad 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -322,13 +322,23 @@ impl StreamableHttpClient for UnixSocketHttpClient { .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) => { + Ok(parsed) => Ok(StreamableHttpPostResponse::Json(parsed, session_id)), + Err(e) + if matches!( + message, + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) + ) => + { tracing::warn!( "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" ); Ok(StreamableHttpPostResponse::Accepted) } + Err(e) => Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( + json_rpc_parse_error_message(&e, &body), + ))), } } _ => Err(StreamableHttpError::UnexpectedContentType( diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 6f27abf..8af045f 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -222,6 +222,35 @@ pub enum StreamableHttpError { ControlRequestTimeout, } +/// Bytes of an unusable JSON body kept in +/// [`StreamableHttpError::UnexpectedServerResponse`]. +pub(crate) const JSON_RPC_BODY_PREVIEW_LEN: usize = 256; + +/// Short, lossy view of a body that failed to parse as JSON-RPC. +pub(crate) fn json_rpc_body_preview(body: &[u8]) -> Cow<'static, str> { + if body.is_empty() { + return Cow::Borrowed(""); + } + let lossy = String::from_utf8_lossy(body); + if lossy.len() <= JSON_RPC_BODY_PREVIEW_LEN { + return Cow::Owned(lossy.into_owned()); + } + let mut end = JSON_RPC_BODY_PREVIEW_LEN; + while end > 0 && !lossy.is_char_boundary(end) { + end -= 1; + } + Cow::Owned(lossy[..end].to_owned()) +} + +/// Request-POST parse failure, including a body preview so empty / HTML / `{}` +/// are distinguishable from the serde diagnostic alone. +pub(crate) fn json_rpc_parse_error_message(err: &serde_json::Error, body: &[u8]) -> String { + format!( + "could not parse JSON response as ServerJsonRpcMessage: {err}: {}", + json_rpc_body_preview(body) + ) +} + impl StreamableHttpError { /// The `WWW-Authenticate` challenge carried by this error, when the /// server answered 401 ([`AuthRequired`](Self::AuthRequired)) or 403 @@ -2155,6 +2184,32 @@ mod tests { service::InboundStreamOrigin, }; + #[test] + fn json_rpc_body_preview_empty_html_and_object() { + assert_eq!(json_rpc_body_preview(b""), ""); + assert_eq!(json_rpc_body_preview(b"{}"), "{}"); + assert_eq!( + json_rpc_body_preview(b"not json"), + "not json" + ); + } + + #[test] + fn json_rpc_body_preview_truncates_long_bodies() { + let long = vec![b'x'; JSON_RPC_BODY_PREVIEW_LEN + 40]; + let preview = json_rpc_body_preview(&long); + assert_eq!(preview.len(), JSON_RPC_BODY_PREVIEW_LEN); + assert!(preview.chars().all(|c| c == 'x')); + } + + #[test] + fn json_rpc_parse_error_message_includes_preview() { + let err = serde_json::from_slice::(b"{}").unwrap_err(); + let msg = json_rpc_parse_error_message(&err, b"{}"); + assert!(msg.contains("could not parse JSON response as ServerJsonRpcMessage")); + assert!(msg.contains("{}"), "missing body preview: {msg}"); + } + #[expect( deprecated, reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request" diff --git a/crates/rmcp/tests/test_streamable_http_malformed_json_request.rs b/crates/rmcp/tests/test_streamable_http_malformed_json_request.rs new file mode 100644 index 0000000..c34e96e --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_malformed_json_request.rs @@ -0,0 +1,195 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + feature = "client", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use axum::{Router, body::Bytes, http::StatusCode, response::IntoResponse, routing::post}; +use rmcp::{ + ServiceError, ServiceExt, + model::{ + CallToolRequestParams, ClientJsonRpcMessage, ClientNotification, ClientRequest, + InitializedNotification, PingRequest, RequestId, + }, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::{ + StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, + }, +}; +use rstest::rstest; + +/// Initialize succeeds; every later POST (including `tools/call`) returns +/// HTTP 200 + `application/json` with a body that is not a JSON-RPC message. +async fn spawn_malformed_json_request_server(call_body: &'static str) -> String { + let router = Router::new().route( + "/mcp", + post(move |body: Bytes| async move { + let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let method = parsed.get("method").and_then(|m| m.as_str()).unwrap_or(""); + if method == "initialize" { + let id = parsed.get("id").cloned().unwrap_or(serde_json::json!(1)); + return ( + StatusCode::OK, + [ + (http::header::CONTENT_TYPE, "application/json"), + ( + http::HeaderName::from_static("mcp-session-id"), + "test-session", + ), + ], + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": "2025-03-26", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "mock", "version": "0.0.1" } + } + }) + .to_string(), + ) + .into_response(); + } + if method == "notifications/initialized" { + return StatusCode::ACCEPTED.into_response(); + } + ( + StatusCode::OK, + [(http::header::CONTENT_TYPE, "application/json")], + call_body, + ) + .into_response() + }), + ); + + 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 spawn_json_body_server(body: &'static str) -> String { + let router = Router::new().route( + "/mcp", + post(move || async move { + ( + StatusCode::OK, + [(http::header::CONTENT_TYPE, "application/json")], + body, + ) + }), + ); + + 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") +} + +/// Public-API repro: `call_tool` must return a transport error instead of +/// hanging when the server answers a request POST with 200 JSON that is not +/// a JSON-RPC message. +#[rstest] +#[case::empty_object("{}")] +#[case::empty_body("")] +#[case::invalid_json("{")] +#[case::html("not json")] +#[tokio::test] +async fn call_tool_errors_on_malformed_json_200(#[case] call_body: &'static str) { + let url = spawn_malformed_json_request_server(call_body).await; + let transport = StreamableHttpClientTransport::from_uri(url); + let client = ().serve(transport).await.expect("initialize should succeed"); + let peer = client.peer().clone(); + + let result = tokio::time::timeout( + Duration::from_secs(2), + peer.call_tool(CallToolRequestParams::new("anything")), + ) + .await + .expect("call_tool must return instead of hanging on malformed JSON 200"); + + match result { + Err(ServiceError::TransportSend(ref dyn_err)) => { + let err_msg = format!("{dyn_err}"); + assert!( + err_msg.contains("unexpected server response"), + "expected UnexpectedServerResponse, got: {err_msg}" + ); + let preview = if call_body.is_empty() { + "" + } else { + call_body + }; + assert!( + err_msg.contains(preview), + "expected body preview {preview:?} in error, got: {err_msg}" + ); + } + other => panic!("expected TransportSend(UnexpectedServerResponse), got: {other:?}"), + } + + let _ = client.cancel().await; +} + +/// Direct `post_message` check: a request POST cannot treat a JSON parse +/// failure as Accepted. +#[tokio::test] +async fn post_request_malformed_json_is_unexpected_server_response() { + let url = spawn_json_body_server("{}").await; + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Err(StreamableHttpError::UnexpectedServerResponse(ref msg)) => { + assert!( + msg.contains("{}"), + "expected body preview in error, got: {msg}" + ); + } + other => panic!("expected UnexpectedServerResponse, got: {other:?}"), + } +} + +/// Notification POSTs still treat an unusable JSON body as Accepted. +/// That fallback is for messages that do not await a reply. +#[tokio::test] +async fn post_notification_malformed_json_is_still_accepted() { + let url = spawn_json_body_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:?}"), + } +} diff --git a/crates/rmcp/tests/test_unix_socket_transport.rs b/crates/rmcp/tests/test_unix_socket_transport.rs index d0e5397..134bb1e 100644 --- a/crates/rmcp/tests/test_unix_socket_transport.rs +++ b/crates/rmcp/tests/test_unix_socket_transport.rs @@ -13,9 +13,12 @@ use http::{HeaderName, HeaderValue}; use hyper_util::rt::TokioIo; use rmcp::{ ServiceExt, + model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, transport::{ StreamableHttpClientTransport, UnixSocketHttpClient, - streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_client::{ + StreamableHttpClient, StreamableHttpClientTransportConfig, StreamableHttpError, + }, }, }; use serde_json::json; @@ -296,3 +299,56 @@ async fn test_unix_socket_convenience_constructor() -> anyhow::Result<()> { Ok(()) } + +/// A request POST that gets 200 + `application/json` + `{}` must error. +/// Mapping that body to Accepted would hang the caller waiting on SSE. +#[tokio::test] +async fn test_unix_socket_request_malformed_json_is_unexpected() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join(format!("rmcp-test-bad-json-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let socket_path = dir.join("mcp.sock"); + let _ = std::fs::remove_file(&socket_path); + + async fn bad_json_handler() -> impl IntoResponse { + ( + StatusCode::OK, + [(http::header::CONTENT_TYPE, "application/json")], + "{}", + ) + } + + let app = Router::new().route("/mcp", post(bad_json_handler)); + 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://localhost/mcp"; + let client = UnixSocketHttpClient::new(socket_str, uri); + let result = client + .post_message( + Arc::from(uri), + ClientJsonRpcMessage::request( + ClientRequest::PingRequest(PingRequest::default()), + RequestId::Number(1), + ), + None, + None, + HashMap::new(), + ) + .await; + + server_handle.abort(); + let _ = std::fs::remove_file(&socket_path); + let _ = std::fs::remove_dir(&dir); + + match result { + Err(StreamableHttpError::UnexpectedServerResponse(ref msg)) => { + assert!( + msg.contains("{}"), + "expected body preview in error, got: {msg}" + ); + } + other => panic!("expected UnexpectedServerResponse, got: {other:?}"), + } + Ok(()) +} From a5d1169e20810fff4cb5ceb90940dc3319f95f0f Mon Sep 17 00:00:00 2001 From: easyinplay Date: Sat, 12 Sep 2026 23:21:03 +0800 Subject: [PATCH 17/29] fix(auth): ignore non-metadata JSON when probing for protected resource metadata (#1204) * fix(auth): ignore non-metadata JSON when probing for protected resource metadata The base URL is probed first when looking for RFC 9728 protected resource metadata, and any 200 there is taken to mean "this URL is the metadata document". Every field of ResourceServerMetadata is optional, so an unrelated JSON object deserializes into an all-None value and validation then fails hard with "Protected resource metadata missing required resource field". The error propagates out of resolve_metadata, so the .well-known fallbacks never run. Servers that answer GET / with a JSON health payload hit this even when they publish valid metadata at both well-known locations. Treat a parsed document that carries none of resource, authorization_server or authorization_servers as a soft failure, the same way this function already treats a non-200 status and a body that is not JSON. A document carrying any of those fields still goes through validate_resource_metadata_resource unchanged. This is the JSON-object half of #810, which made a non-JSON body at the base URL a soft failure for the same reason. * fix(auth): stop a 200 from the resource ending metadata discovery probe_resource_metadata_url treats any 200 as "this url is the metadata document". That holds for the .well-known candidates it is called with in the loop, and not for the first call, which is passed the resource itself. RFC 9728 publishes the document at the well-known URI and advertises it through the resource_metadata parameter of a WWW-Authenticate challenge, so a 200 from the resource is the resource answering and nothing more. Because that first probe returned Some(base_url), discovery ended before the .well-known candidates were tried, and a valid document published there was never reached. Rejecting the body later could not recover it: by then the candidates had already been skipped. Split the first probe into probe_resource_endpoint_for_challenge, which reads only the 401 branch. The .well-known probe keeps its behaviour. The check added in the previous commit stays. A .well-known url can also answer 200 with something that is not a metadata document, and every field of ResourceServerMetadata being optional makes that deserialize into an all-None value that then fails validation fatally. resolve_metadata_reaches_the_well_known_document_past_a_non_metadata_base_url asserts the well-known url is actually requested; without this change it fails with the base url requested twice and the protected-resource candidate never probed. resolve_metadata_ignores_a_well_known_url_that_is_not_a_metadata_document covers the remaining guard; without it the run ends in the original "Protected resource metadata missing required resource field". * fix(auth): keep trying the well-known candidates past a non-metadata document discover_resource_metadata_url returned the first .well-known candidate that answered 200 and left the loop; the document itself was fetched afterwards, outside the loop. A candidate answering 200 with something that is not metadata is only recognised at that point, by which time the remaining candidates have been skipped and discovery gives up with nothing. The probe already had the body in hand and threw it away, so the winning candidate was requested twice. Read the body where the candidate is probed instead: a candidate that is not the document costs one request and the loop moves on to the next one, and the candidate that is the document is requested once. The second half of the old function, which walks the authorization servers a document names, is unchanged; it now takes the document as an argument so the challenge path keeps sharing it. resolve_metadata_tries_the_next_well_known_candidate_past_a_non_metadata_document covers the loop; without this change the second candidate is never requested and the run ends on the issuer of an authorization server it was never meant to reach. * fix(auth): report an advertised url that is not the metadata document A WWW-Authenticate challenge naming a resource_metadata url is the server saying the document is there. The previous commits made a document carrying neither resource nor an authorization server reference a soft failure everywhere, which on that path drops out of resolve_metadata_from_challenge, continues with authorization server discovery and settles on the legacy endpoints, silently losing the RFC 8707 resource binding the document was supposed to carry. Before those commits it surfaced as "Protected resource metadata missing required resource field". Where the url came from decides what that document means. A .well-known candidate is a guess, so its answer only rules out that candidate and the loop goes on. An advertised url has no better alternative to move on to, so say what the server got wrong instead of degrading quietly. Reading a non-200 or a body that is not JSON stays a soft failure on both paths. resolve_metadata_from_challenge_reports_an_advertised_url_without_metadata covers the challenge path; without this change it resolves to LegacyEndpointFallback. * fix(auth): let a candidate fail validation without ending discovery parse_resource_metadata decides what a body that is not the metadata document means from where the url came from, and it decides it on the presence of resource, authorization_server and authorization_servers alone. What comes after is fatal whatever the origin: validate_resource_metadata_resource rejects a missing resource, a resource that is not a URL, one carrying a fragment, and one that does not match the base url, and each of those propagates out of resolve_metadata. A .well-known candidate handled by a catch-all route reaches it. The {"error":"not_found","resource":"/.well-known/oauth-protected-resource"} such a route answers with carries resource, so the presence check lets it through, and the relative path then fails to parse as a URL. That is the shape this branch opened on, one field further along: the remaining candidates are skipped, authorization server discovery is skipped, and the run ends on an error instead of the legacy endpoints. Read the body and validate it in the same place, now read_resource_metadata, so the origin governing the first decision governs the second one as well. A guess that fails validation rules out that candidate and the loop goes on; an advertised url still reports what the server got wrong. authorization_metadata_from_resource_metadata takes a document that has already been accepted. resolve_metadata_tries_the_next_well_known_candidate_past_an_unusable_document covers it; without this change it fails with "Protected resource metadata resource field is not a valid URL". * fix(auth): keep probing the candidates when an advertised url answers nothing An advertised url reading a non-200 or a body that is not JSON is a soft failure: the server said the document is at that url and nothing is being served there, which rules out the url and not the document. The .well-known candidates are derived from the base url rather than from that pointer, so they are still worth probing, but the pointer was read ahead of the loop and returned out of discover_resource_metadata either way. A challenge naming https://host/.well-known/oauth-protected-resource that 404s takes the run straight to authorization server discovery, while the document sits unread on https://host/mcp/.well-known/oauth-protected-resource. Fall through to the loop instead. Carrying on past a candidate's own 401 already let one url be requested twice, because the pointer that challenge names can be a later candidate of the same run, and it is requested again when the loop reaches it; not returning on the first pointer adds the same overlap. Keep the urls this run has requested and skip the ones already read. resolve_metadata_probes_the_candidates_past_an_advertised_url_that_is_not_served covers the fall-through; without it the run reaches the authorization server metadata of a server it was never pointed at. resolve_metadata_requests_an_advertised_url_that_is_also_a_candidate_once covers the repeat. * fix(auth): walk an authorization server named by both fields once A protected resource metadata document can name its authorization server in authorization_servers, and servers written against the earlier draft also fill the singular authorization_server. Filling both with the same value is common, and the two are concatenated into the candidate list unfiltered, so every well-known form of that one server's discovery url is requested twice before the walk gives up on it. well_known_paths already keeps its candidates distinct. Do the same here, comparing the trimmed value the loop goes on to use. resolve_metadata_requests_an_authorization_server_named_twice_once covers it; without this change the discovery url is requested twice. --------- Co-authored-by: easyinplay --- crates/rmcp/src/transport/auth.rs | 681 +++++++++++++++++++++++++++--- 1 file changed, 620 insertions(+), 61 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 759044b..c94538c 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, future::Future, net::{IpAddr, Ipv4Addr, Ipv6Addr}, pin::Pin, @@ -685,6 +685,18 @@ struct ResourceServerMetadata { scopes_supported: Option>, } +/// How a url that may hold protected resource metadata was arrived at, which +/// decides what a document that is not metadata means there. +#[derive(Debug, Clone, Copy)] +enum ResourceMetadataUrlOrigin { + /// The server named this url in the `resource_metadata` parameter of a + /// `WWW-Authenticate` challenge. + Advertised, + /// The url was derived from the base url, on the chance that the document is + /// published there. + WellKnownGuess, +} + /// Parameters extracted from WWW-Authenticate header #[derive(Debug, Clone, Default)] #[non_exhaustive] @@ -2525,26 +2537,48 @@ 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, resource_metadata)) = + self.discover_resource_metadata().await? + else { return Ok(None); }; - self.discover_oauth_server_from_resource_metadata_url(&resource_metadata_url) - .await + self.authorization_metadata_from_resource_metadata( + &resource_metadata_url, + resource_metadata, + ) + .await } + /// Read protected resource metadata from the url a `WWW-Authenticate` + /// challenge advertised. 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, + ResourceMetadataUrlOrigin::Advertised, + ) .await? else { return Ok(None); }; - self.validate_resource_metadata_resource(&resource_metadata)?; + self.authorization_metadata_from_resource_metadata(resource_metadata_url, resource_metadata) + .await + } + /// Walk the authorization servers a protected resource metadata document + /// names, keeping the first one that answers with usable metadata. + /// + /// The document arrives here through `read_resource_metadata`, which is where + /// it is decided to be this resource's metadata at all. + async fn authorization_metadata_from_resource_metadata( + &self, + resource_metadata_url: &Url, + resource_metadata: ResourceServerMetadata, + ) -> Result, AuthError> { self.discovered_resource .write() .await @@ -2557,24 +2591,28 @@ impl AuthorizationManager { *self.resource_scopes.write().await = scopes; } - let mut candidates = Vec::new(); + // A server naming the same authorization server in both the singular draft + // field and the list would otherwise have each of that server's well-known + // forms requested twice. + let mut candidates: Vec = Vec::new(); + let mut push_candidate = |candidate: String| { + let candidate = candidate.trim(); + if !candidate.is_empty() && !candidates.iter().any(|kept| kept == candidate) { + candidates.push(candidate.to_string()); + } + }; if let Some(single) = resource_metadata.authorization_server { - candidates.push(single); + push_candidate(single); } - if let Some(list) = resource_metadata.authorization_servers { - candidates.extend(list); + for candidate in resource_metadata.authorization_servers.unwrap_or_default() { + push_candidate(candidate); } for candidate in candidates { - let candidate = candidate.trim(); - if candidate.is_empty() { - continue; - } - - let candidate_url = match Url::parse(candidate) { + let candidate_url = match Url::parse(&candidate) { Ok(url) => url, - Err(_) => match resource_metadata_url.join(candidate) { + Err(_) => match resource_metadata_url.join(&candidate) { Ok(url) => url, Err(e) => { debug!("Failed to resolve authorization server URL `{candidate}`: {e}"); @@ -2668,52 +2706,113 @@ impl AuthorizationManager { || expected_path.as_bytes().get(actual_path.len()) == Some(&b'/')) } - 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 Ok(Some(resource_metadata_url)); + /// Look for the protected resource metadata document, reading it where it is + /// found so that a candidate answering with something else only costs that + /// candidate. + async fn discover_resource_metadata( + &self, + ) -> Result, AuthError> { + // A url the resource points at can also be one of the candidates below. + let mut requested = HashSet::new(); + + if let Some(advertised_url) = self.probe_resource_endpoint_for_challenge().await? { + requested.insert(advertised_url.clone()); + if let Some(metadata) = self + .fetch_resource_metadata_from_url( + &advertised_url, + ResourceMetadataUrlOrigin::Advertised, + ) + .await? + { + return Ok(Some((advertised_url, metadata))); + } + // Nothing was published there. The candidates below are reached from the + // base url rather than from that pointer, so they are still worth trying. } - // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. + // The other place the document can be is the well-known location. // https://www.rfc-editor.org/rfc/rfc9728.html#name-obtaining-protected-resourc for candidate_path in Self::well_known_paths(self.base_url.path(), "oauth-protected-resource") { - let mut discovery_url = self.base_url.clone(); - discovery_url.set_query(None); - 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? - { - return Ok(Some(resource_metadata_url)); + let mut candidate_url = self.base_url.clone(); + candidate_url.set_query(None); + candidate_url.set_fragment(None); + candidate_url.set_path(&candidate_path); + + if !requested.insert(candidate_url.clone()) { + continue; + } + + let response = self + .discovery_get(&candidate_url) + .await + .map_err(|error| Self::discovery_failed(&candidate_url, error))?; + + match response.status() { + // The candidate url is the document itself, so read the body here + // instead of requesting the same url again. + StatusCode::OK => { + if let Some(metadata) = self.read_resource_metadata( + &candidate_url, + response.body(), + ResourceMetadataUrlOrigin::WellKnownGuess, + )? { + return Ok(Some((candidate_url, metadata))); + } + } + StatusCode::UNAUTHORIZED => { + let Some(advertised_url) = self + .extract_resource_metadata_url_from_www_authenticate(&response) + .await + else { + continue; + }; + if !requested.insert(advertised_url.clone()) { + continue; + } + if let Some(metadata) = self + .fetch_resource_metadata_from_url( + &advertised_url, + ResourceMetadataUrlOrigin::Advertised, + ) + .await? + { + return Ok(Some((advertised_url, metadata))); + } + } + status => debug!("resource metadata probe returned unexpected status: {status}"), } } 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. + /// Probe the resource itself, looking only for a `WWW-Authenticate` challenge + /// that carries a `resource_metadata` pointer. + /// + /// A 200 here says nothing about metadata. RFC 9728 publishes the document at + /// the well-known URI and advertises it through the challenge parameter, so the + /// resource answering its own GET is not the document and must not end + /// discovery before the well-known candidates are tried. /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for - async fn probe_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { + async fn probe_resource_endpoint_for_challenge(&self) -> Result, AuthError> { let response = self - .discovery_get(url) + .discovery_get(&self.base_url) .await - .map_err(|error| Self::discovery_failed(url, error))?; + .map_err(|error| Self::discovery_failed(&self.base_url, error))?; - match response.status() { - StatusCode::OK => Ok(Some(url.clone())), - StatusCode::UNAUTHORIZED => Ok(self + if response.status() == StatusCode::UNAUTHORIZED { + return Ok(self .extract_resource_metadata_url_from_www_authenticate(&response) - .await), - status => { - debug!("resource metadata probe returned unexpected status: {status}"); - Ok(None) - } + .await); } + + debug!( + "resource endpoint probe returned {}, no WWW-Authenticate pointer to follow", + response.status() + ); + Ok(None) } async fn extract_resource_metadata_url_from_www_authenticate( @@ -2739,6 +2838,7 @@ impl AuthorizationManager { async fn fetch_resource_metadata_from_url( &self, resource_metadata_url: &Url, + origin: ResourceMetadataUrlOrigin, ) -> Result, AuthError> { debug!( "resource metadata discovery url: {:?}", @@ -2757,13 +2857,69 @@ impl AuthorizationManager { return Ok(None); } - let metadata = match serde_json::from_slice::(response.body()) { + self.read_resource_metadata(resource_metadata_url, response.body(), origin) + } + + /// Read a response body as this resource's protected resource metadata. + /// + /// A body that is not that document rules out the url it came from, and where + /// that url came from decides whether ruling it out leaves anything to try. + fn read_resource_metadata( + &self, + resource_metadata_url: &Url, + body: &[u8], + origin: ResourceMetadataUrlOrigin, + ) -> Result, AuthError> { + let metadata = match serde_json::from_slice::(body) { Ok(metadata) => metadata, Err(e) => { debug!("failed to parse resource metadata as JSON: {}", e); return Ok(None); } }; + + // Every field of `ResourceServerMetadata` is optional, so an unrelated JSON + // object deserializes into an all-`None` value and then fails validation + // fatally. RFC 9728 requires `resource`, and MCP requires an authorization + // server reference, so a document carrying neither is not a protected + // resource metadata document. + if metadata.resource.is_none() + && metadata.authorization_server.is_none() + && metadata.authorization_servers.is_none() + { + return match origin { + // The server named this url, so there is nothing better to move on + // to: the alternatives all drop the resource binding the document + // was supposed to carry. Report it instead. + ResourceMetadataUrlOrigin::Advertised => Err(AuthError::MetadataError(format!( + "the server advertised {resource_metadata_url} as protected resource metadata, but the document carries neither `resource` nor an authorization server reference" + ))), + // Nothing advertised this url, so its answer only rules out this + // candidate. + ResourceMetadataUrlOrigin::WellKnownGuess => { + debug!( + "response at {resource_metadata_url} is not a protected resource metadata document" + ); + Ok(None) + } + }; + } + + // Carrying those fields only makes the body a metadata document; validation + // is what makes it this resource's. Both answers rule out the url the same + // way, so both are read the same way. + if let Err(error) = self.validate_resource_metadata_resource(&metadata) { + return match origin { + ResourceMetadataUrlOrigin::Advertised => Err(error), + ResourceMetadataUrlOrigin::WellKnownGuess => { + debug!( + "document at {resource_metadata_url} is not this resource's metadata: {error}" + ); + Ok(None) + } + }; + } + Ok(Some(metadata)) } @@ -4441,13 +4597,6 @@ mod tests { "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!({ @@ -4482,7 +4631,6 @@ mod tests { 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", ], ) @@ -4500,13 +4648,6 @@ mod tests { "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!({ @@ -4970,6 +5111,424 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_reaches_the_well_known_document_past_a_non_metadata_base_url() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the well-known candidate carries the real document + 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" + }), + ), + ]); + let recorder = client.clone(); + 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::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + assert!( + recorder.requests().iter().any(|request| { + request.uri == "https://mcp.example.com/.well-known/oauth-protected-resource" + }), + "the well-known candidate was never probed: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_ignores_a_well_known_url_that_is_not_a_metadata_document() { + let health = || { + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ) + }; + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + health(), + // so does the well-known candidate + health(), + 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", + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_tries_the_next_well_known_candidate_past_a_non_metadata_document() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // so does the first well-known candidate + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the second candidate carries the real document + 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 recorder = client.clone(); + 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", + ) + ); + let document_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://mcp.example.com/mcp/.well-known/oauth-protected-resource" + }) + .count(); + assert_eq!( + document_requests, + 1, + "the candidate holding the document should be requested exactly once: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_from_challenge_reports_an_advertised_url_without_metadata() { + let mut responses = vec![http_response(200, serde_json::json!({}))]; + // enough responses for the fallback to reach the legacy endpoints, so that + // treating the document as a soft failure would resolve rather than error + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(8)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let error = manager + .resolve_metadata_from_challenge(Some( + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + )) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "Metadata error: the server advertised https://mcp.example.com/.well-known/oauth-protected-resource as protected resource metadata, but the document carries neither `resource` nor an authorization server reference" + ); + } + + #[tokio::test] + async fn resolve_metadata_tries_the_next_well_known_candidate_past_an_unusable_document() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // a catch-all handler answers the first candidate with its own error + // shape, which carries a `resource` that only validation rejects + http_response( + 200, + serde_json::json!({ + "error": "not_found", + "resource": "/.well-known/oauth-protected-resource" + }), + ), + // the second candidate carries the real document + 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 recorder = client.clone(); + 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", + ) + ); + assert!( + recorder.requests().iter().any(|request| { + request.uri == "https://mcp.example.com/mcp/.well-known/oauth-protected-resource" + }), + "the candidate after the rejected one was never probed: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_probes_the_candidates_past_an_advertised_url_that_is_not_served() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/prm""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + // the advertised url is not where the document is served + empty_response(404), + // neither is the first candidate + empty_response(404), + // the second candidate carries the document + 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 recorder = client.clone(); + 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", + ) + ); + assert!( + recorder.requests().iter().any(|request| { + request.uri == "https://mcp.example.com/mcp/.well-known/oauth-protected-resource" + }), + "the candidates were skipped after the advertised url answered 404: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_requests_an_advertised_url_that_is_also_a_candidate_once() { + 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 mut responses = vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the first candidate points at the last candidate of the same run + challenge, + ]; + // the document is served nowhere, so the run walks every candidate and + // settles on the legacy endpoints + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let recorder = client.clone(); + 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, + AuthorizationMetadataSource::LegacyEndpointFallback + ); + let advertised_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://mcp.example.com/.well-known/oauth-protected-resource" + }) + .count(); + assert_eq!( + advertised_requests, + 1, + "the url the challenge named was requested again as a candidate: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_requests_an_authorization_server_named_twice_once() { + let mut responses = vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the document names the same authorization server in both the singular + // draft field and the list + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_server": "https://auth.example.com", + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]; + // the authorization server publishes no metadata, so every form of its + // discovery url is tried before the run settles on the legacy endpoints + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let recorder = client.clone(); + 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, + AuthorizationMetadataSource::LegacyEndpointFallback + ); + let discovery_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://auth.example.com/.well-known/oauth-authorization-server" + }) + .count(); + assert_eq!( + discovery_requests, + 1, + "the authorization server was walked once per field naming it: {:?}", + recorder.requests() + ); + } + #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata, From 3075dc9152d4678775f20634fcb467a7b995dbab Mon Sep 17 00:00:00 2001 From: Teddy Tennant Date: Sat, 12 Sep 2026 12:11:30 -0400 Subject: [PATCH 18/29] fix(model): deprecate ServerInfo and ClientInfo aliases (#1156) * fix(model): deprecate ServerInfo and ClientInfo aliases Those names collide with the protocol's serverInfo/clientInfo fields, which are only Implementation identity. Prefer InitializeResult and InitializeRequestParams. Handler trait signatures, the tool_handler macro output, tests, examples and docs now use the canonical types; the aliases remain as deprecated shims for the rest of 3.x. Fixes #1082 * fix(model): keep public signatures on the deprecated aliases The public API check compares rendered signatures, and rustdoc records a type alias by the name it was written with. Respelling `ServerHandler::get_info`, `ClientHandler::get_info`, `DiscoverResult::from_server_info` and `impl ClientHandler for ClientInfo` onto `InitializeResult`/`InitializeRequestParams` therefore reads as changed public items even though the aliases resolve to those exact types and nothing downstream breaks. Put those four signatures back on the aliases so the published API is byte-identical, and note why at the alias definitions. The deprecation still steers new code, and the macro output, tests, examples and docs keep using the canonical names. * fix(model): migrate leftover ClientInfo test sites Two tests landed on main after the alias deprecation and still named ClientInfo. Clippy -D warnings and the no-local feature test job both failed on them. --- README.md | 36 +++++++++---------- conformance/src/bin/client.rs | 8 ++--- conformance/src/bin/server.rs | 4 +-- crates/rmcp-macros/src/lib.rs | 4 +-- crates/rmcp-macros/src/tool_handler.rs | 6 ++-- crates/rmcp/src/handler/client.rs | 2 +- crates/rmcp/src/handler/server.rs | 2 +- crates/rmcp/src/model.rs | 29 ++++++++++++--- crates/rmcp/src/service/client.rs | 18 +++++----- crates/rmcp/src/service/server.rs | 19 +++++----- .../transport/streamable_http_server/tower.rs | 8 ++--- crates/rmcp/tests/common/calculator.rs | 6 ++-- crates/rmcp/tests/common/handlers.rs | 4 +-- crates/rmcp/tests/test_cancelled_response.rs | 8 ++--- crates/rmcp/tests/test_custom_headers.rs | 30 ++++++++-------- .../test_discover_http_client_startup.rs | 8 ++--- crates/rmcp/tests/test_handler_cache_hints.rs | 9 ++--- .../tests/test_inflight_response_drain.rs | 10 +++--- crates/rmcp/tests/test_mrtr_behavior.rs | 21 +++++------ crates/rmcp/tests/test_notification.rs | 8 ++--- crates/rmcp/tests/test_prompt_macros.rs | 7 ++-- .../test_protocol_version_negotiation.rs | 28 +++++++-------- .../tests/test_resource_not_found_version.rs | 10 +++--- crates/rmcp/tests/test_result_type_version.rs | 10 +++--- .../test_sep_2260_request_association.rs | 15 ++++---- .../tests/test_sep_2260_stream_enforcement.rs | 11 +++--- .../tests/test_sep_2260_stream_routing.rs | 6 ++-- .../rmcp/tests/test_server_discover_client.rs | 8 ++--- .../rmcp/tests/test_server_discover_http.rs | 6 ++-- .../rmcp/tests/test_server_initialization.rs | 6 ++-- .../rmcp/tests/test_sse_concurrent_streams.rs | 6 ++-- .../tests/test_stateless_protocol_version.rs | 12 +++---- .../tests/test_stdio_response_concurrency.rs | 8 ++--- ...test_streamable_http_client_concurrency.rs | 25 +++++++++---- .../test_streamable_http_connection_reuse.rs | 8 ++--- .../test_streamable_http_disconnect_cancel.rs | 8 ++--- .../tests/test_streamable_http_event_store.rs | 8 ++--- .../test_streamable_http_json_response.rs | 8 ++--- .../test_streamable_http_protocol_version.rs | 4 +-- .../test_streamable_http_stale_session.rs | 8 ++--- .../test_streamable_http_standard_headers.rs | 6 ++-- crates/rmcp/tests/test_subscriptions.rs | 32 ++++++++--------- .../test_subscriptions_streamable_http.rs | 17 ++++----- crates/rmcp/tests/test_task.rs | 12 +++---- .../tests/test_tool_disable_notification.rs | 6 ++-- crates/rmcp/tests/test_tool_macros.rs | 10 +++--- docs/OAUTH_SUPPORT.md | 2 +- .../clients/src/auth/client_credentials.rs | 4 +-- examples/clients/src/auth/oauth_client.rs | 10 +++--- examples/clients/src/progress_client.rs | 6 ++-- examples/clients/src/streamable_http.rs | 5 +-- .../clients/src/subscriptions_streamhttp.rs | 4 +-- examples/clients/src/task_stdio.rs | 2 +- examples/servers/src/common/counter.rs | 4 +-- examples/servers/src/common/progress_demo.rs | 4 +-- examples/servers/src/common/task_demo.rs | 4 +-- examples/servers/src/completion_stdio.rs | 4 +-- .../servers/src/elicitation_enum_inference.rs | 4 +-- examples/servers/src/elicitation_stdio.rs | 4 +-- examples/servers/src/mrtr.rs | 8 ++--- examples/servers/src/prompt_stdio.rs | 9 ++--- examples/servers/src/sampling_stdio.rs | 4 +-- .../servers/src/subscriptions_streamhttp.rs | 6 ++-- 63 files changed, 323 insertions(+), 286 deletions(-) diff --git a/README.md b/README.md index 390f96c..128388c 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,10 @@ Use [`ClientServiceExt::serve_with_lifecycle`](crates/rmcp/src/service/client.rs select another lifecycle explicitly: ```rust, ignore -use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion}; +use rmcp::{ClientLifecycleMode, ClientServiceExt, InitializeRequestParams, ProtocolVersion}; // Start directly with server/discover and include client metadata on every request. -let client = ClientInfo::default() +let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -113,7 +113,7 @@ let client = ClientInfo::default() // Or probe the discover lifecycle and fall back when a legacy server reports // that server/discover is not implemented or does not respond within 10 seconds. -let client = ClientInfo::default() +let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Auto { @@ -369,8 +369,8 @@ use serde_json::json; struct MyServer; impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_resources() .build(), @@ -582,8 +582,8 @@ impl MyServer { #[prompt_handler] impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_prompts().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_prompts().build()) } } ``` @@ -934,8 +934,8 @@ Enable the logging capability, handle level changes from the client, and send lo use rmcp::{ServerHandler, model::*, service::RequestContext}; impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_logging() .build(), @@ -1007,8 +1007,8 @@ Enable the completions capability and implement the `complete()` handler. Use `r use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_completions() .enable_prompts() @@ -1253,8 +1253,8 @@ use rmcp::{ }; impl ServerHandler for MyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -1584,7 +1584,7 @@ use rmcp::transport::StreamableHttpClientTransport; // Defaults are stateless-friendly. let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); -let client = ClientInfo::default().serve(transport).await?; +let client = InitializeRequestParams::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) @@ -1638,7 +1638,7 @@ server example). The client transport connects with a single URI: use rmcp::transport::StreamableHttpClientTransport; let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); -let client = ClientInfo::default().serve(transport).await?; +let client = InitializeRequestParams::default().serve(transport).await?; ``` The client allows up to 16 ordinary http POSTs at once. Configure this with @@ -1742,10 +1742,10 @@ knows what the other supports. Declare yours with the `ServerCapabilities` builder in `get_info()`: ```rust,ignore -use rmcp::model::{ServerCapabilities, ServerInfo}; +use rmcp::model::{InitializeResult, ServerCapabilities}; -fn get_info(&self) -> ServerInfo { - ServerInfo::new( +fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_prompts() diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 92e4a94..1c66467 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -74,8 +74,8 @@ impl ClientHandler for BasicClientHandler {} struct ElicitationDefaultsClientHandler; impl ClientHandler for ElicitationDefaultsClientHandler { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); + fn get_info(&self) -> InitializeRequestParams { + let mut info = InitializeRequestParams::default(); info.capabilities.elicitation = Some( ElicitationCapability::new() .with_form(FormElicitationCapability::new().with_schema_validation(true)), @@ -163,8 +163,8 @@ impl ClientHandler for ElicitationDefaultsClientHandler { struct FullClientHandler; impl ClientHandler for FullClientHandler { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); + fn get_info(&self) -> InitializeRequestParams { + let mut info = InitializeRequestParams::default(); info.capabilities.elicitation = Some( ElicitationCapability::new() .with_form(FormElicitationCapability::new().with_schema_validation(true)), diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 7b31c1d..a89ccc7 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -704,8 +704,8 @@ impl ServerHandler for ConformanceServer { (name == "test_custom_header").then(custom_header_tool) } - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_prompts() .enable_prompts_list_changed() diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index 176ead7..f37200a 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -217,8 +217,8 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream { /// ```rust,ignore /// #[tool_handler] /// impl ServerHandler for MyToolHandler { -/// fn get_info(&self) -> ServerInfo { -/// ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) +/// fn get_info(&self) -> InitializeResult { +/// InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) /// } /// } /// ``` diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index e274c10..02cf6bb 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -116,7 +116,7 @@ pub(crate) enum CallerCapability { Prompts, } -/// Build a `get_info()` method that returns `ServerInfo` with the appropriate capabilities. +/// Build a `get_info()` method that returns `InitializeResult` with the appropriate capabilities. /// /// The caller declares its own capability via `caller`. Sibling handler attributes /// (`prompt_handler`, `tool_handler`) are detected automatically @@ -157,8 +157,8 @@ pub(crate) fn build_get_info( } syn::parse2::(quote! { - fn get_info(&self) -> rmcp::model::ServerInfo { - rmcp::model::ServerInfo::new( + fn get_info(&self) -> rmcp::model::InitializeResult { + rmcp::model::InitializeResult::new( rmcp::model::ServerCapabilities::builder() #(#capability_calls)* .build() diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 4143870..4af1a47 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -275,7 +275,7 @@ macro_rules! client_handler_methods { } fn get_info(&self) -> ClientInfo { - ClientInfo::default() + InitializeRequestParams::default() } }; } diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 7098552..a6dd83b 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -601,7 +601,7 @@ macro_rules! server_handler_methods { } fn get_info(&self) -> ServerInfo { - ServerInfo::default() + InitializeResult::default() } /// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state. diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index be911f7..9981658 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1119,7 +1119,26 @@ impl InitializeResult { } } +/// Full server initialize payload (`InitializeResult`). +/// +/// Prefer [`InitializeResult`]. The name collides with the protocol's +/// `serverInfo` field, which is only the [`Implementation`] identity (#1082). +// +// The signatures this crate publishes (`ServerHandler::get_info`, +// `DiscoverResult::from_server_info`, and the `ClientInfo` equivalents below) +// keep spelling the alias. It resolves to the same type, so the spelling makes +// no difference to callers, but rustdoc records the name as written and the +// public API check treats a respelling as a changed item. Moving those +// signatures onto the canonical names is a documented API change and belongs in +// the next major release. +#[deprecated(note = "use `InitializeResult` instead")] pub type ServerInfo = InitializeResult; + +/// Full client initialize params (`InitializeRequestParams`). +/// +/// Prefer [`InitializeRequestParams`]. The name collides with the protocol's +/// `clientInfo` field, which is only the [`Implementation`] identity (#1082). +#[deprecated(note = "use `InitializeRequestParams` instead")] pub type ClientInfo = InitializeRequestParams; /// Information negotiated about a server peer. @@ -1284,7 +1303,7 @@ impl DiscoverResult { supported_versions: Vec, server_info: ServerInfo, ) -> Self { - let ServerInfo { + let InitializeResult { capabilities, server_info, instructions, @@ -1332,9 +1351,9 @@ impl ServerPeerInfo { } #[allow(clippy::derivable_impls)] -impl Default for ServerInfo { +impl Default for InitializeResult { fn default() -> Self { - ServerInfo { + InitializeResult { protocol_version: ProtocolVersion::default(), capabilities: ServerCapabilities::default(), server_info: Implementation::from_build_env(), @@ -1345,9 +1364,9 @@ impl Default for ServerInfo { } #[allow(clippy::derivable_impls)] -impl Default for ClientInfo { +impl Default for InitializeRequestParams { fn default() -> Self { - ClientInfo { + InitializeRequestParams { meta: None, protocol_version: ProtocolVersion::default(), capabilities: ClientCapabilities::default(), diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 520410f..47eeace 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -13,13 +13,13 @@ use crate::{ model::{ 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, + CancelledNotificationParam, 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, + GetTaskResult, InitializeRequest, InitializeRequestParams, InitializedNotification, + InputRequest, InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest, ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotification, ProgressNotificationParam, @@ -266,7 +266,7 @@ impl ServiceRole for RoleClient { type PeerReq = ServerRequest; type PeerResp = ServerResult; type PeerNot = ServerNotification; - type Info = ClientInfo; + type Info = InitializeRequestParams; type PeerInfo = ServerPeerInfo; type InitializeError = ClientInitializeError; const IS_CLIENT: bool = true; @@ -870,7 +870,7 @@ async fn legacy_startup( transport: &mut T, id_provider: &Arc, peer: &Peer, - client_info: ClientInfo, + client_info: InitializeRequestParams, ) -> Result<(), ClientInitializeError> where S: Service, @@ -919,7 +919,7 @@ async fn discover_startup( transport: &mut T, id_provider: &Arc, peer: &Peer, - client_info: &ClientInfo, + client_info: &InitializeRequestParams, preferred_versions: Vec, ) -> Result where diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 29e4690..2b44345 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -13,14 +13,15 @@ use super::*; use crate::model::{ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction}; use crate::{ model::{ - CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, + CancelledNotification, CancelledNotificationParam, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CreateMessageRequest, - CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, ListRootsRequest, - ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam, - ProgressNotification, ProgressNotificationParam, PromptListChangedNotification, - ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification, - ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest, - ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification, + CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, + InitializeRequestParams, InitializeResult, ListRootsRequest, ListRootsResult, + LoggingMessageNotification, LoggingMessageNotificationParam, ProgressNotification, + ProgressNotificationParam, PromptListChangedNotification, ProtocolVersion, + ResourceListChangedNotification, ResourceUpdatedNotification, + ResourceUpdatedNotificationParam, ServerNotification, ServerRequest, ServerResult, + SubscriptionFilter, SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification, }, transport::DynamicTransportError, @@ -37,8 +38,8 @@ impl ServiceRole for RoleServer { type PeerReq = ClientRequest; type PeerResp = ClientResult; type PeerNot = ClientNotification; - type Info = ServerInfo; - type PeerInfo = ClientInfo; + type Info = InitializeResult; + type PeerInfo = InitializeRequestParams; type InitializeError = ServerInitializeError; const IS_CLIENT: bool = false; diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 35b0d9e..6a980b2 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -27,8 +27,8 @@ use crate::{ model::{ ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode, ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest, - InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, - ProtocolVersion, RequestId, ServerInfo, ServerJsonRpcMessage, ServerResult, + InitializeRequestParams, InitializeResult, InitializedNotification, JsonObject, + JsonRpcError, ProtocolVersion, RequestId, ServerJsonRpcMessage, ServerResult, }, serve_server, service::{ @@ -367,7 +367,7 @@ impl> Service for NegotiatingStatelessHttpSer self.0.handle_notification(notification, context).await } - fn get_info(&self) -> ServerInfo { + fn get_info(&self) -> InitializeResult { self.0.get_info() } @@ -2111,7 +2111,7 @@ where Ok(accepted_response()) } - /// Build a `ClientInfo` (peer_info) for a stateless request so that + /// Build a `InitializeRequestParams` (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`, diff --git a/crates/rmcp/tests/common/calculator.rs b/crates/rmcp/tests/common/calculator.rs index 22c6d38..5a51374 100644 --- a/crates/rmcp/tests/common/calculator.rs +++ b/crates/rmcp/tests/common/calculator.rs @@ -2,7 +2,7 @@ use rmcp::{ ServerHandler, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{ServerCapabilities, ServerInfo}, + model::{InitializeResult, ServerCapabilities}, schemars, tool, tool_router, }; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -52,8 +52,8 @@ impl Calculator { } impl ServerHandler for Calculator { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::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 7928fa6..4cf6799 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -115,8 +115,8 @@ impl TestServer { impl ServerHandler for TestServer { #[allow(deprecated)] - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_logging().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_logging().build()) } #[allow(deprecated)] diff --git a/crates/rmcp/tests/test_cancelled_response.rs b/crates/rmcp/tests/test_cancelled_response.rs index 5961a7b..e46cddb 100644 --- a/crates/rmcp/tests/test_cancelled_response.rs +++ b/crates/rmcp/tests/test_cancelled_response.rs @@ -8,8 +8,8 @@ use std::{collections::BTreeSet, process::Stdio, time::Duration}; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerInfo, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, + ServerCapabilities, }, service::RequestContext, }; @@ -91,8 +91,8 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { struct WaitForCancelServer; impl ServerHandler for WaitForCancelServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 9d5ba86..a790b23 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -719,7 +719,7 @@ async fn test_server_rejects_unsupported_protocol_version() { use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, + model::{InitializeResult, ServerCapabilities}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -730,8 +730,8 @@ async fn test_server_rejects_unsupported_protocol_version() { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().build()) } } @@ -892,7 +892,7 @@ async fn test_server_validates_host_when_origin_validation_is_disabled_by_defaul use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, + model::{InitializeResult, ServerCapabilities}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -903,8 +903,8 @@ async fn test_server_validates_host_when_origin_validation_is_disabled_by_defaul struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().build()) } } @@ -975,7 +975,7 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, + model::{InitializeResult, ServerCapabilities}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -986,8 +986,8 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().build()) } } @@ -1046,7 +1046,7 @@ async fn test_server_falls_back_to_uri_authority_when_host_header_missing() { use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, + model::{InitializeResult, ServerCapabilities}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -1057,8 +1057,8 @@ async fn test_server_falls_back_to_uri_authority_when_host_header_missing() { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().build()) } } @@ -1132,7 +1132,7 @@ mod origin_validation { use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{ServerCapabilities, ServerInfo}, + model::{InitializeResult, ServerCapabilities}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -1143,8 +1143,8 @@ mod origin_validation { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().build()) } } diff --git a/crates/rmcp/tests/test_discover_http_client_startup.rs b/crates/rmcp/tests/test_discover_http_client_startup.rs index c2825f8..fc0ad25 100644 --- a/crates/rmcp/tests/test_discover_http_client_startup.rs +++ b/crates/rmcp/tests/test_discover_http_client_startup.rs @@ -16,7 +16,7 @@ use axum::{ }; use rmcp::{ ClientLifecycleMode, ClientServiceExt, ServerHandler, - model::{ClientInfo, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion}, + model::{DiscoverResult, ErrorCode, ErrorData, InitializeRequestParams, ProtocolVersion}, service::{MaybeSendFuture, RequestContext, RoleServer}, transport::{ StreamableHttpClientTransport, @@ -132,7 +132,7 @@ async fn discover_http_client_bootstraps_headers_without_initialize() { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), ); - let client = ClientInfo::default() + let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -176,7 +176,7 @@ async fn auto_http_client_falls_back_to_stateful_legacy_startup() { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), ); - let client = ClientInfo::default() + let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Auto { @@ -217,7 +217,7 @@ async fn auto_http_client_falls_back_after_plain_text_4xx_rejection() { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), ); - let client = ClientInfo::default() + let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Auto { diff --git a/crates/rmcp/tests/test_handler_cache_hints.rs b/crates/rmcp/tests/test_handler_cache_hints.rs index 7f44e82..efefd9c 100644 --- a/crates/rmcp/tests/test_handler_cache_hints.rs +++ b/crates/rmcp/tests/test_handler_cache_hints.rs @@ -5,7 +5,8 @@ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, handler::server::router::{prompt::PromptRouter, tool::ToolRouter}, model::{ - CacheScope, ClientInfo, ListPromptsResult, ListToolsResult, ProtocolVersion, ServerInfo, + CacheScope, InitializeRequestParams, InitializeResult, ListPromptsResult, ListToolsResult, + ProtocolVersion, }, prompt_handler, service::serve_directly, @@ -37,8 +38,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); + fn get_info(&self) -> InitializeRequestParams { + let mut info = InitializeRequestParams::default(); info.protocol_version = self.protocol_version.clone(); info } @@ -53,7 +54,7 @@ async fn list_results(protocol_version: ProtocolVersion) -> (ListToolsResult, Li let client_handler = VersionedClient { protocol_version: protocol_version.clone(), }; - let mut server_peer_info = ServerInfo::default(); + let mut server_peer_info = InitializeResult::default(); server_peer_info.protocol_version = protocol_version; let server = serve_directly::( diff --git a/crates/rmcp/tests/test_inflight_response_drain.rs b/crates/rmcp/tests/test_inflight_response_drain.rs index c75e17c..d21f678 100644 --- a/crates/rmcp/tests/test_inflight_response_drain.rs +++ b/crates/rmcp/tests/test_inflight_response_drain.rs @@ -14,7 +14,7 @@ use std::{ use rmcp::{ ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, + model::{CallToolRequestParams, InitializeRequestParams, InitializeResult, ServerCapabilities}, service::QuitReason, tool, tool_handler, tool_router, }; @@ -55,8 +55,8 @@ impl SlowToolServer { #[tool_handler] impl ServerHandler for SlowToolServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } } @@ -64,8 +64,8 @@ impl ServerHandler for SlowToolServer { struct DummyClientHandler; impl rmcp::ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientInfo { - ClientInfo::default() + fn get_info(&self) -> InitializeRequestParams { + InitializeRequestParams::default() } } diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs index 1cbbc01..db70537 100644 --- a/crates/rmcp/tests/test_mrtr_behavior.rs +++ b/crates/rmcp/tests/test_mrtr_behavior.rs @@ -114,8 +114,8 @@ impl MacroMrtrServer { #[tool_handler] impl ServerHandler for MacroMrtrServer { - fn get_info(&self) -> ServerInfo { - let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> InitializeResult { + let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); info.protocol_version = ProtocolVersion::V_2026_07_28; info } @@ -221,8 +221,8 @@ impl MrtrServer { } impl ServerHandler for MrtrServer { - fn get_info(&self) -> ServerInfo { - let mut info = ServerInfo::new( + fn get_info(&self) -> InitializeResult { + let mut info = InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_prompts() @@ -328,16 +328,16 @@ impl ClientHandler for MrtrClient { // Harness // ============================================================================= -fn client_info(protocol_version: ProtocolVersion) -> ClientInfo { - ClientInfo::new( +fn client_info(protocol_version: ProtocolVersion) -> InitializeRequestParams { + InitializeRequestParams::new( ClientCapabilities::builder().enable_elicitation().build(), Implementation::new("mrtr-test-client", "0.0.0"), ) .with_protocol_version(protocol_version) } -fn server_info(protocol_version: ProtocolVersion) -> ServerInfo { - let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); +fn server_info(protocol_version: ProtocolVersion) -> InitializeResult { + let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); info.protocol_version = protocol_version; info } @@ -609,8 +609,9 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re struct SealingServer; impl ServerHandler for SealingServer { - fn get_info(&self) -> ServerInfo { - let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> InitializeResult { + let mut info = + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); info.protocol_version = ProtocolVersion::V_2026_07_28; info } diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index 9aafd3c..a095ba4 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ - ClientNotification, CustomNotification, ResourceUpdatedNotificationParam, - ServerCapabilities, ServerInfo, ServerNotification, SubscribeRequestParams, + ClientNotification, CustomNotification, InitializeResult, ResourceUpdatedNotificationParam, + ServerCapabilities, ServerNotification, SubscribeRequestParams, }, }; use serde_json::json; @@ -16,8 +16,8 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; struct Server {} impl ServerHandler for Server { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_resources() .enable_resources_subscribe() diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs index 642ae88..46be406 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -7,7 +7,8 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceExt, handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, model::{ - ClientInfo, ContentBlock, GetPromptRequestParams, GetPromptResult, PromptMessage, Role, + ContentBlock, GetPromptRequestParams, GetPromptResult, InitializeRequestParams, + PromptMessage, Role, }, prompt, prompt_handler, prompt_router, }; @@ -297,8 +298,8 @@ fn test_optional_field_schema_generation_via_macro() { struct DummyClientHandler {} impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientInfo { - ClientInfo::default() + fn get_info(&self) -> InitializeRequestParams { + InitializeRequestParams::default() } } diff --git a/crates/rmcp/tests/test_protocol_version_negotiation.rs b/crates/rmcp/tests/test_protocol_version_negotiation.rs index aa4607a..cf9af90 100644 --- a/crates/rmcp/tests/test_protocol_version_negotiation.rs +++ b/crates/rmcp/tests/test_protocol_version_negotiation.rs @@ -16,8 +16,8 @@ use std::{ use rmcp::{ ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt, model::{ - ClientCapabilities, ClientInfo, ErrorCode, Implementation, InitializeRequestParams, - InitializeResult, ProtocolVersion, ServerInfo, + ClientCapabilities, ErrorCode, Implementation, InitializeRequestParams, InitializeResult, + ProtocolVersion, }, service::{ClientInitializeError, RequestContext}, }; @@ -26,8 +26,8 @@ use rmcp::{ struct EchoServer; impl ServerHandler for EchoServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() + fn get_info(&self) -> InitializeResult { + InitializeResult::default() } } @@ -45,8 +45,8 @@ const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[ struct NarrowedServer; impl ServerHandler for NarrowedServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() + fn get_info(&self) -> InitializeResult { + InitializeResult::default() } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { @@ -61,8 +61,8 @@ struct ModernOnlyServer; const MODERN_ONLY_VERSIONS: &[ProtocolVersion] = &[ProtocolVersion::V_2026_07_28]; impl ServerHandler for ModernOnlyServer { - fn get_info(&self) -> ServerInfo { - let mut info = ServerInfo::default(); + fn get_info(&self) -> InitializeResult { + let mut info = InitializeResult::default(); info.protocol_version = ProtocolVersion::V_2026_07_28; info } @@ -79,8 +79,8 @@ impl ServerHandler for ModernOnlyServer { struct NarrowedOverridingServer; impl ServerHandler for NarrowedOverridingServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() + fn get_info(&self) -> InitializeResult { + InitializeResult::default() } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { @@ -102,8 +102,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); + fn get_info(&self) -> InitializeRequestParams { + let mut info = InitializeRequestParams::default(); info.protocol_version = self.protocol_version.clone(); info } @@ -247,8 +247,8 @@ struct DelegatingServer { } impl ServerHandler for DelegatingServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::default() + fn get_info(&self) -> InitializeResult { + InitializeResult::default() } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { diff --git a/crates/rmcp/tests/test_resource_not_found_version.rs b/crates/rmcp/tests/test_resource_not_found_version.rs index 1d86ca8..7e79f31 100644 --- a/crates/rmcp/tests/test_resource_not_found_version.rs +++ b/crates/rmcp/tests/test_resource_not_found_version.rs @@ -8,8 +8,8 @@ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError, model::{ - ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams, - ReadResourceResponse, ServerInfo, + ErrorCode, ErrorData, InitializeRequestParams, InitializeResult, ProtocolVersion, + ReadResourceRequestParams, ReadResourceResponse, }, service::{RequestContext, serve_directly}, }; @@ -33,8 +33,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); + fn get_info(&self) -> InitializeRequestParams { + let mut info = InitializeRequestParams::default(); info.protocol_version = self.protocol_version.clone(); info } @@ -49,7 +49,7 @@ async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode { let client_handler = VersionedClient { protocol_version: client_version.clone(), }; - let mut server_peer_info = ServerInfo::default(); + let mut server_peer_info = InitializeResult::default(); server_peer_info.protocol_version = client_version; let server = serve_directly::( diff --git a/crates/rmcp/tests/test_result_type_version.rs b/crates/rmcp/tests/test_result_type_version.rs index ab779d2..556758f 100644 --- a/crates/rmcp/tests/test_result_type_version.rs +++ b/crates/rmcp/tests/test_result_type_version.rs @@ -8,8 +8,8 @@ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, - ErrorData, ProtocolVersion, ResultType, ServerInfo, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorData, + InitializeRequestParams, InitializeResult, ProtocolVersion, ResultType, }, service::{RequestContext, serve_directly}, }; @@ -33,8 +33,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); + fn get_info(&self) -> InitializeRequestParams { + let mut info = InitializeRequestParams::default(); info.protocol_version = self.protocol_version.clone(); info } @@ -49,7 +49,7 @@ async fn call_tool_result_type(client_version: ProtocolVersion) -> Option( diff --git a/crates/rmcp/tests/test_sep_2260_request_association.rs b/crates/rmcp/tests/test_sep_2260_request_association.rs index b02cff0..a8a1302 100644 --- a/crates/rmcp/tests/test_sep_2260_request_association.rs +++ b/crates/rmcp/tests/test_sep_2260_request_association.rs @@ -9,9 +9,10 @@ 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, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, + InitializeRequestParams, InitializeResult, ProtocolVersion, SamplingMessage, + ServerCapabilities, ServerRequest, }, service::{RequestContext, RunningService, serve_directly}, }; @@ -29,8 +30,8 @@ struct SamplingServer { } impl ServerHandler for SamplingServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( @@ -93,8 +94,8 @@ impl ClientHandler for SamplingClient { .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) } - fn get_info(&self) -> ClientInfo { - let mut info = ClientInfo::default(); + fn get_info(&self) -> InitializeRequestParams { + let mut info = InitializeRequestParams::default(); info.protocol_version = ProtocolVersion::V_2026_07_28; info } diff --git a/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs b/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs index 36595e0..b652aa8 100644 --- a/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs +++ b/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs @@ -26,8 +26,9 @@ use http::{HeaderName, HeaderValue}; use rmcp::{ ClientHandler, model::{ - ClientInfo, ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, - ProtocolVersion, SamplingMessage, ServerCapabilities, ServerInfo, ServerJsonRpcMessage, + ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, + InitializeRequestParams, InitializeResult, ProtocolVersion, SamplingMessage, + ServerCapabilities, ServerJsonRpcMessage, }, service::{ClientLifecycleMode, RequestContext, RoleClient, serve_client_with_lifecycle}, transport::streamable_http_client::{ @@ -80,7 +81,7 @@ impl StreamableHttpClient for ScriptedServer { // 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()); + let mut info = InitializeResult::new(ServerCapabilities::default()); info.protocol_version = ProtocolVersion::V_2026_07_28; let response = ServerJsonRpcMessage::response( rmcp::model::ServerResult::InitializeResult(info), @@ -152,8 +153,8 @@ impl ClientHandler for SamplingClient { )) } - fn get_info(&self) -> ClientInfo { - ClientInfo::default() + fn get_info(&self) -> InitializeRequestParams { + InitializeRequestParams::default() } } diff --git a/crates/rmcp/tests/test_sep_2260_stream_routing.rs b/crates/rmcp/tests/test_sep_2260_stream_routing.rs index 824ef0c..1e04a88 100644 --- a/crates/rmcp/tests/test_sep_2260_stream_routing.rs +++ b/crates/rmcp/tests/test_sep_2260_stream_routing.rs @@ -9,7 +9,7 @@ use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ElicitRequestParams, - ElicitationSchema, ServerCapabilities, ServerInfo, + ElicitationSchema, InitializeResult, ServerCapabilities, }, service::RequestContext, transport::streamable_http_server::{ @@ -23,8 +23,8 @@ use tokio_util::sync::CancellationToken; struct ElicitingServer; impl ServerHandler for ElicitingServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_server_discover_client.rs b/crates/rmcp/tests/test_server_discover_client.rs index 3b309dd..f8f8727 100644 --- a/crates/rmcp/tests/test_server_discover_client.rs +++ b/crates/rmcp/tests/test_server_discover_client.rs @@ -3,8 +3,8 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ - ClientCapabilities, Implementation, ProtocolVersion, RequestMetaObject, ServerCapabilities, - ServerInfo, + ClientCapabilities, Implementation, InitializeResult, ProtocolVersion, RequestMetaObject, + ServerCapabilities, }, select_protocol_version, }; @@ -13,8 +13,8 @@ use rmcp::{ struct DiscoveryServer; impl ServerHandler for DiscoveryServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("discovery-server", "1.0.0")) } } diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs index 7a42f06..89afbd6 100644 --- a/crates/rmcp/tests/test_server_discover_http.rs +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use rmcp::{ ServerHandler, - model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}, + model::{Implementation, InitializeResult, ProtocolVersion, ServerCapabilities}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -20,8 +20,8 @@ use tokio_util::sync::CancellationToken; struct DiscoveryServer; impl ServerHandler for DiscoveryServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("discovery-server", "1.0.0")) .with_instructions("Use the tools carefully") } diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index 6542e29..4ebffc5 100644 --- a/crates/rmcp/tests/test_server_initialization.rs +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -6,7 +6,7 @@ use common::handlers::TestServer; use rmcp::{ ServerHandler, ServiceExt, model::{ - ClientJsonRpcMessage, ProtocolVersion, ServerCapabilities, ServerInfo, + ClientJsonRpcMessage, InitializeResult, ProtocolVersion, ServerCapabilities, ServerJsonRpcMessage, ServerResult, }, transport::{IntoTransport, Transport}, @@ -281,8 +281,8 @@ async fn server_falls_back_when_client_protocol_version_unknown() { struct PinnedServer; impl ServerHandler for PinnedServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().build()) .with_protocol_version(ProtocolVersion::V_2025_06_18) } } diff --git a/crates/rmcp/tests/test_sse_concurrent_streams.rs b/crates/rmcp/tests/test_sse_concurrent_streams.rs index e1e8852..c839bc4 100644 --- a/crates/rmcp/tests/test_sse_concurrent_streams.rs +++ b/crates/rmcp/tests/test_sse_concurrent_streams.rs @@ -18,7 +18,7 @@ use std::time::Duration; use futures::StreamExt; use rmcp::{ RoleServer, ServerHandler, - model::{Implementation, ServerCapabilities, ServerInfo, ToolsCapability}, + model::{Implementation, InitializeResult, ServerCapabilities, ToolsCapability}, service::NotificationContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -45,8 +45,8 @@ impl TestServer { } impl ServerHandler for TestServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools_with({ let mut tools = ToolsCapability::default(); diff --git a/crates/rmcp/tests/test_stateless_protocol_version.rs b/crates/rmcp/tests/test_stateless_protocol_version.rs index dbfcaf2..4437d28 100644 --- a/crates/rmcp/tests/test_stateless_protocol_version.rs +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -10,9 +10,7 @@ use std::borrow::Cow; use rmcp::{ ErrorData, RoleServer, ServerHandler, - model::{ - InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities, ServerInfo, - }, + model::{InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities}, service::RequestContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -24,8 +22,8 @@ use tokio_util::sync::CancellationToken; struct OverridingInitialize; impl ServerHandler for OverridingInitialize { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::default()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::default()) } async fn initialize( @@ -53,8 +51,8 @@ const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[ struct NarrowedOverridingInitialize; impl ServerHandler for NarrowedOverridingInitialize { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::default()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::default()) } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { diff --git a/crates/rmcp/tests/test_stdio_response_concurrency.rs b/crates/rmcp/tests/test_stdio_response_concurrency.rs index 2e1e40a..39855ad 100644 --- a/crates/rmcp/tests/test_stdio_response_concurrency.rs +++ b/crates/rmcp/tests/test_stdio_response_concurrency.rs @@ -5,8 +5,8 @@ use std::{collections::BTreeSet, process::Stdio, time::Duration}; use rmcp::{ ErrorData as McpError, ServerHandler, ServiceExt, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerInfo, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, + ServerCapabilities, }, }; use serde_json::{Value, json}; @@ -82,8 +82,8 @@ struct LargeResponseServer; impl ServerHandler for LargeResponseServer { #[allow(deprecated)] - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_client_concurrency.rs b/crates/rmcp/tests/test_streamable_http_client_concurrency.rs index 20b7258..38b98c0 100644 --- a/crates/rmcp/tests/test_streamable_http_client_concurrency.rs +++ b/crates/rmcp/tests/test_streamable_http_client_concurrency.rs @@ -16,9 +16,9 @@ use futures::{StreamExt, stream::BoxStream}; use http::{HeaderName, HeaderValue}; use rmcp::{ model::{ - CallToolRequestParams, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, - ClientRequest, DiscoverResult, ProtocolVersion, Request, RequestId, RequestMetaObject, - ServerJsonRpcMessage, + CallToolRequestParams, CancelledNotificationParam, ClientJsonRpcMessage, ClientRequest, + DiscoverResult, InitializeRequestParams, ProtocolVersion, Request, RequestId, + RequestMetaObject, ServerJsonRpcMessage, }, service::{ ClientLifecycleMode, PeerRequestOptions, RequestHandle, RoleClient, RunningService, @@ -345,7 +345,7 @@ impl StreamableHttpClient for ScriptedClient { } struct Harness { - client: RunningService, + client: RunningService, started: mpsc::UnboundedReceiver, controls: mpsc::UnboundedReceiver, incoming: mpsc::UnboundedSender>, @@ -402,7 +402,8 @@ impl Harness { config, ); let client = - serve_client_with_lifecycle(ClientInfo::default(), transport, lifecycle).await?; + serve_client_with_lifecycle(InitializeRequestParams::default(), transport, lifecycle) + .await?; Ok(Self { client, started: requests, @@ -601,7 +602,12 @@ async fn common_stream_response_keeps_numeric_and_string_ids_distinct() -> anyho config(), ); for message in [ - json!({ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": ClientInfo::default() }), + json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": InitializeRequestParams::default() + }), json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), ] { timeout( @@ -820,7 +826,12 @@ async fn dropping_a_parked_barrier_send_unblocks_the_ordinary_queue() -> anyhow: config, ); for message in [ - json!({ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": ClientInfo::default() }), + json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": InitializeRequestParams::default() + }), json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), ] { timeout( diff --git a/crates/rmcp/tests/test_streamable_http_connection_reuse.rs b/crates/rmcp/tests/test_streamable_http_connection_reuse.rs index 553448e..9febc02 100644 --- a/crates/rmcp/tests/test_streamable_http_connection_reuse.rs +++ b/crates/rmcp/tests/test_streamable_http_connection_reuse.rs @@ -5,7 +5,7 @@ use std::time::Instant; use rmcp::{ ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, + model::{CallToolRequestParams, InitializeRequestParams, InitializeResult, ServerCapabilities}, schemars, tool, tool_handler, tool_router, transport::{ StreamableHttpClientTransport, @@ -46,8 +46,8 @@ impl SumServer { #[tool_handler(router = self.tool_router)] impl ServerHandler for SumServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } } @@ -83,7 +83,7 @@ async fn test_subsequent_tool_calls_reuse_connections() -> anyhow::Result<()> { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), ); - let client = ClientInfo::default().serve(transport).await?; + let client = InitializeRequestParams::default().serve(transport).await?; // Warm up: first call may include one-time setup costs. let args: serde_json::Map = diff --git a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs index 31e7413..5bc808c 100644 --- a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs +++ b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs @@ -18,8 +18,8 @@ use std::{sync::Arc, time::Duration}; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerInfo, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, + ServerCapabilities, }, service::RequestContext, transport::streamable_http_server::{ @@ -37,8 +37,8 @@ struct CancelProbe { impl ServerHandler for CancelProbe { #[allow(deprecated)] - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_event_store.rs b/crates/rmcp/tests/test_streamable_http_event_store.rs index cc88f10..191466a 100644 --- a/crates/rmcp/tests/test_streamable_http_event_store.rs +++ b/crates/rmcp/tests/test_streamable_http_event_store.rs @@ -18,8 +18,8 @@ use futures::StreamExt; use rmcp::{ ErrorData, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - ProgressNotificationParam, ServerCapabilities, ServerInfo, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, + ProgressNotificationParam, ServerCapabilities, }, service::RequestContext, transport::streamable_http_server::{ @@ -109,8 +109,8 @@ impl EventStore for InMemoryEventStore { struct ProgressServer; impl ServerHandler for ProgressServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_json_response.rs b/crates/rmcp/tests/test_streamable_http_json_response.rs index b1c09f5..5fc5f49 100644 --- a/crates/rmcp/tests/test_streamable_http_json_response.rs +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -2,8 +2,8 @@ use rmcp::{ ErrorData, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - ProgressNotificationParam, ServerCapabilities, ServerInfo, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, + ProgressNotificationParam, ServerCapabilities, }, service::RequestContext, transport::streamable_http_server::{ @@ -57,8 +57,8 @@ const NEGOTIATED_CALL_WITH_PROGRESS_BODY: &str = r#"{ struct ProgressServer; impl ServerHandler for ProgressServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index 373549a..16cbc7b 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -481,8 +481,8 @@ impl CountingServer { } impl ServerHandler for CountingServer { - fn get_info(&self) -> rmcp::model::ServerInfo { - rmcp::model::ServerInfo::new( + fn get_info(&self) -> rmcp::model::InitializeResult { + rmcp::model::InitializeResult::new( rmcp::model::ServerCapabilities::builder() .enable_tools() .build(), diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index be1ac02..233128f 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -15,9 +15,9 @@ use http::{HeaderName, HeaderValue}; use rmcp::{ ServiceError, ServiceExt, model::{ - CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode, - ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities, - ServerJsonRpcMessage, ServerResult, + CallToolRequestParams, ClientJsonRpcMessage, ClientRequest, ErrorCode, ErrorData, + InitializeRequestParams, InitializeResult, PingRequest, ProtocolVersion, RequestId, + ServerCapabilities, ServerJsonRpcMessage, ServerResult, }, transport::{ StreamableHttpClientTransport, @@ -205,7 +205,7 @@ async fn test_reinitialization_completes_accepted_sse_request_instead_of_hanging mock_client, StreamableHttpClientTransportConfig::with_uri("mock://mcp"), ); - let mut client = ClientInfo::default().serve(transport).await?; + let mut client = InitializeRequestParams::default().serve(transport).await?; let peer = client.peer().clone(); let pending_call = tokio::spawn(async move { diff --git a/crates/rmcp/tests/test_streamable_http_standard_headers.rs b/crates/rmcp/tests/test_streamable_http_standard_headers.rs index 510433e..655efad 100644 --- a/crates/rmcp/tests/test_streamable_http_standard_headers.rs +++ b/crates/rmcp/tests/test_streamable_http_standard_headers.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use rmcp::{ ServerHandler, - model::{ServerCapabilities, ServerInfo, Tool}, + model::{InitializeResult, ServerCapabilities, Tool}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -18,8 +18,8 @@ const SEP_VERSION: &str = "2026-07-28"; struct HeaderValidationServer; impl ServerHandler for HeaderValidationServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } fn get_tool(&self, name: &str) -> Option { diff --git a/crates/rmcp/tests/test_subscriptions.rs b/crates/rmcp/tests/test_subscriptions.rs index 8ceef93..99d8b9e 100644 --- a/crates/rmcp/tests/test_subscriptions.rs +++ b/crates/rmcp/tests/test_subscriptions.rs @@ -18,8 +18,8 @@ use rmcp::{ ClientHandler, ClientServiceExt, ServerHandler, ServiceExt, model::{ ClientNotification, ClientRequest, DiscoverResult, GetMeta, Implementation, - NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, ServerCapabilities, - ServerInfo, ServerNotification, ServerResult, SubscriptionFilter, + InitializeResult, NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, + ServerCapabilities, ServerNotification, ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenResult, }, @@ -44,8 +44,8 @@ impl ClientHandler for CountingClient { } impl ServerHandler for ToolsOnlyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -80,8 +80,8 @@ impl ServerHandler for ToolsOnlyServer { struct ToolsAndPromptsServer; impl ServerHandler for ToolsAndPromptsServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -120,8 +120,8 @@ impl ServerHandler for ToolsAndPromptsServer { struct ResourceSubscriptionServer; impl ServerHandler for ResourceSubscriptionServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_resources() .enable_resources_subscribe() @@ -182,8 +182,8 @@ impl ServerHandler for RemoteCancellationServer { struct FloodServer; impl ServerHandler for FloodServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -217,8 +217,8 @@ struct ClosedSinkServer { struct LeakyServer; impl ServerHandler for LeakyServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -314,14 +314,14 @@ impl rmcp::service::Service for MalformedAcknowledgmentServer { Ok(()) } - fn get_info(&self) -> rmcp::model::ServerInfo { - ServerInfo::default() + fn get_info(&self) -> rmcp::model::InitializeResult { + InitializeResult::default() } } impl ServerHandler for ClosedSinkServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() diff --git a/crates/rmcp/tests/test_subscriptions_streamable_http.rs b/crates/rmcp/tests/test_subscriptions_streamable_http.rs index bfb9313..04d443a 100644 --- a/crates/rmcp/tests/test_subscriptions_streamable_http.rs +++ b/crates/rmcp/tests/test_subscriptions_streamable_http.rs @@ -18,8 +18,9 @@ use std::{ use rmcp::{ ClientLifecycleMode, ClientServiceExt, ServerHandler, model::{ - ClientInfo, ClientRequest, Implementation, ListToolsRequest, ProtocolVersion, - RequestMetaObject, ServerCapabilities, ServerInfo, ServerNotification, SubscriptionFilter, + ClientRequest, Implementation, InitializeRequestParams, InitializeResult, ListToolsRequest, + ProtocolVersion, RequestMetaObject, ServerCapabilities, ServerNotification, + SubscriptionFilter, }, service::{PeerRequestOptions, SubscriptionContext, SubscriptionEnd}, transport::{ @@ -52,8 +53,8 @@ impl ServerHandler for HttpSubscriptionServer { Cow::Borrowed(&[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25]) } - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -159,7 +160,7 @@ async fn modern_http_listen_uses_post_stream_and_cancels_by_closing_it() -> anyh let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url.clone()), ); - let client = ClientInfo::default() + let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -204,7 +205,7 @@ async fn modern_http_graceful_close_returns_final_listen_result() -> anyhow::Res let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = ClientInfo::default() + let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -240,7 +241,7 @@ async fn modern_http_stream_close_without_result_is_abrupt() -> anyhow::Result<( let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = ClientInfo::default() + let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -273,7 +274,7 @@ async fn modern_http_lifecycle_stays_sessionless_for_older_application_version() let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = ClientInfo::default() + let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 55ba4cc..b7fb660 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -107,8 +107,8 @@ impl ServerHandler for TaskServer { self.tasks.cancel_task(&request.task_id) } - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() @@ -117,8 +117,8 @@ impl ServerHandler for TaskServer { } } -fn tasks_client_info() -> ClientInfo { - ClientInfo::new( +fn tasks_client_info() -> InitializeRequestParams { + InitializeRequestParams::new( ClientCapabilities::builder().enable_tasks().build(), Implementation::from_build_env(), ) @@ -275,8 +275,8 @@ impl ServerHandler for AlwaysTaskServer { Ok(CallToolResponse::Task(CreateTaskResult::new(task))) } - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() diff --git a/crates/rmcp/tests/test_tool_disable_notification.rs b/crates/rmcp/tests/test_tool_disable_notification.rs index cd87805..1d5ad84 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::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool}, + model::{CallToolResponse, CallToolResult, InitializeResult, ServerCapabilities, Tool}, service::{MaybeSendFuture, NotificationContext}, }; use tokio::sync::{Notify, RwLock}; @@ -41,8 +41,8 @@ impl TestToolServer { } impl ServerHandler for TestToolServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index 4975109..ae16677 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, ServerCapabilities, ServerInfo}, + model::{CallToolRequestParams, InitializeRequestParams, InitializeResult, ServerCapabilities}, tool, tool_handler, tool_router, }; use schemars::JsonSchema; @@ -287,8 +287,8 @@ fn test_optional_field_schema_generation_via_macro() { struct DummyClientHandler {} impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientInfo { - ClientInfo::default() + fn get_info(&self) -> InitializeRequestParams { + InitializeRequestParams::default() } } @@ -549,8 +549,8 @@ impl ManualInfoServer { #[tool_handler] impl ServerHandler for ManualInfoServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_resources() diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 5d32be3..279d725 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -253,7 +253,7 @@ let transport = StreamableHttpClientTransport::with_client( ); // create client and connect to MCP server -let client_service = ClientInfo::default(); +let client_service = InitializeRequestParams::default(); let client = client_service.serve(transport).await?; ``` diff --git a/examples/clients/src/auth/client_credentials.rs b/examples/clients/src/auth/client_credentials.rs index 55aa615..7d9c9b4 100644 --- a/examples/clients/src/auth/client_credentials.rs +++ b/examples/clients/src/auth/client_credentials.rs @@ -3,7 +3,7 @@ use std::env; use anyhow::{Context, Result}; use rmcp::{ ServiceExt, - model::ClientInfo, + model::InitializeRequestParams, transport::{ StreamableHttpClientTransport, auth::{AuthClient, ClientCredentialsConfig, OAuthState}, @@ -73,7 +73,7 @@ async fn main() -> Result<()> { ); // Connect to MCP server and list tools - let client_service = ClientInfo::default(); + let client_service = InitializeRequestParams::default(); let client = client_service.serve(transport).await?; tracing::info!("Connected to MCP server"); diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 1ab9313..9bb3e0d 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -9,7 +9,7 @@ use axum::{ }; use rmcp::{ RoleClient, ServiceExt, - model::ClientInfo, + model::InitializeRequestParams, service::RunningService, transport::{ StreamableHttpClientTransport, @@ -58,7 +58,7 @@ async fn callback_handler( enum ConnectOutcome { /// The server accepted the unauthenticated connection. - Connected(RunningService), + Connected(RunningService), /// The server answered 401; authorize with this `WWW-Authenticate` /// challenge and reconnect. AuthRequired(String), @@ -72,7 +72,7 @@ async fn try_connect(http_client: reqwest::Client, server_url: &str) -> Result Ok(ConnectOutcome::Connected(client)), Err(error) => match error.auth_challenge() { Some(challenge) => Ok(ConnectOutcome::AuthRequired(challenge.to_string())), @@ -90,7 +90,7 @@ async fn authorize_and_connect( client_metadata_url: &str, code_receiver: oneshot::Receiver, output: &mut BufWriter, -) -> Result> { +) -> Result> { tracing::info!("Server requires authorization: {challenge}"); // initialize oauth state machine @@ -156,7 +156,7 @@ async fn authorize_and_connect( auth_client, StreamableHttpClientTransportConfig::with_uri(server_url), ); - Ok(ClientInfo::default().serve(transport).await?) + Ok(InitializeRequestParams::default().serve(transport).await?) } #[tokio::main] diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index ad00dea..a9bb611 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::{ - CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, + CallToolRequestParams, ClientCapabilities, Implementation, InitializeRequestParams, ProgressNotificationParam, }, service::{NotificationContext, RoleClient}, @@ -121,8 +121,8 @@ impl ClientHandler for ProgressAwareClient { } } - fn get_info(&self) -> ClientInfo { - ClientInfo::new( + fn get_info(&self) -> InitializeRequestParams { + InitializeRequestParams::new( ClientCapabilities::default(), Implementation::new("progress-test-client", "1.0.0"), ) diff --git a/examples/clients/src/streamable_http.rs b/examples/clients/src/streamable_http.rs index d2c88dc..bf2b7e9 100644 --- a/examples/clients/src/streamable_http.rs +++ b/examples/clients/src/streamable_http.rs @@ -2,7 +2,8 @@ use anyhow::Result; use rmcp::{ ClientLifecycleMode, ClientServiceExt, model::{ - CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, ProtocolVersion, + CallToolRequestParams, ClientCapabilities, Implementation, InitializeRequestParams, + ProtocolVersion, }, transport::StreamableHttpClientTransport, }; @@ -18,7 +19,7 @@ async fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); - let client_info = ClientInfo::new( + let client_info = InitializeRequestParams::new( ClientCapabilities::default(), Implementation::new("streamable-http-client", "0.0.1"), ); diff --git a/examples/clients/src/subscriptions_streamhttp.rs b/examples/clients/src/subscriptions_streamhttp.rs index 7ab612b..57e5d9f 100644 --- a/examples/clients/src/subscriptions_streamhttp.rs +++ b/examples/clients/src/subscriptions_streamhttp.rs @@ -1,6 +1,6 @@ use rmcp::{ ClientLifecycleMode, ClientServiceExt, - model::{ClientInfo, ProtocolVersion, SubscriptionFilter}, + model::{InitializeRequestParams, ProtocolVersion, SubscriptionFilter}, transport::StreamableHttpClientTransport, }; @@ -13,7 +13,7 @@ async fn main() -> anyhow::Result<()> { .init(); let transport = StreamableHttpClientTransport::from_uri("http://127.0.0.1:8000/mcp"); - let client = ClientInfo::default() + let client = InitializeRequestParams::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { diff --git a/examples/clients/src/task_stdio.rs b/examples/clients/src/task_stdio.rs index 465cb70..3fefafb 100644 --- a/examples/clients/src/task_stdio.rs +++ b/examples/clients/src/task_stdio.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { .init(); // Declare the tasks extension in our client capabilities (SEP-2663). - let client_info = rmcp::model::ClientInfo::new( + let client_info = rmcp::model::InitializeRequestParams::new( ClientCapabilities::builder().enable_tasks().build(), rmcp::model::Implementation::from_build_env(), ); diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index 3bd1617..c72aaf5 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -206,8 +206,8 @@ 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"})))] impl ServerHandler for Counter { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_prompts() .enable_resources() diff --git a/examples/servers/src/common/progress_demo.rs b/examples/servers/src/common/progress_demo.rs index 91f0947..ba7bee9 100644 --- a/examples/servers/src/common/progress_demo.rs +++ b/examples/servers/src/common/progress_demo.rs @@ -131,8 +131,8 @@ impl ProgressDemo { #[tool_handler] impl ServerHandler for ProgressDemo { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(ProtocolVersion::V_2024_11_05) .with_server_info(Implementation::from_build_env()) .with_instructions( diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs index 4d8d53a..3be5969 100644 --- a/examples/servers/src/common/task_demo.rs +++ b/examples/servers/src/common/task_demo.rs @@ -151,8 +151,8 @@ impl ServerHandler for TaskDemo { self.tasks.cancel_task(&request.task_id) } - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() diff --git a/examples/servers/src/completion_stdio.rs b/examples/servers/src/completion_stdio.rs index ffaeee5..9ad30f4 100644 --- a/examples/servers/src/completion_stdio.rs +++ b/examples/servers/src/completion_stdio.rs @@ -310,8 +310,8 @@ impl SqlQueryServer { #[prompt_handler(router = self.prompt_router)] impl ServerHandler for SqlQueryServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_completions() .enable_prompts() diff --git a/examples/servers/src/elicitation_enum_inference.rs b/examples/servers/src/elicitation_enum_inference.rs index 50d2844..6e01bce 100644 --- a/examples/servers/src/elicitation_enum_inference.rs +++ b/examples/servers/src/elicitation_enum_inference.rs @@ -155,8 +155,8 @@ impl ElicitationEnumFormServer { #[tool_handler(router = self.tool_router)] impl ServerHandler for ElicitationEnumFormServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::from_build_env()) .with_instructions( "Simple server demonstrating elicitation for enum selection".to_string(), diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 16b4773..7a944a1 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -147,8 +147,8 @@ impl ElicitationServer { #[tool_handler(router = self.tool_router)] impl ServerHandler for ElicitationServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::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(), diff --git a/examples/servers/src/mrtr.rs b/examples/servers/src/mrtr.rs index 3165c8d..ddd7ce6 100644 --- a/examples/servers/src/mrtr.rs +++ b/examples/servers/src/mrtr.rs @@ -113,8 +113,8 @@ fn finish_weather_request( } impl ServerHandler for WeatherServer { - fn get_info(&self) -> ServerInfo { - let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> InitializeResult { + let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); // MRTR requires 2026-07-28 or newer. info.protocol_version = ProtocolVersion::V_2026_07_28; info @@ -173,8 +173,8 @@ impl ServerHandler for WeatherServer { struct InteractiveClient; impl ClientHandler for InteractiveClient { - fn get_info(&self) -> ClientInfo { - ClientInfo::new( + fn get_info(&self) -> InitializeRequestParams { + InitializeRequestParams::new( ClientCapabilities::builder().enable_elicitation().build(), Implementation::new("mrtr-example-client", env!("CARGO_PKG_VERSION")), ) diff --git a/examples/servers/src/prompt_stdio.rs b/examples/servers/src/prompt_stdio.rs index 6ef24e9..f49c54b 100644 --- a/examples/servers/src/prompt_stdio.rs +++ b/examples/servers/src/prompt_stdio.rs @@ -363,12 +363,13 @@ impl PromptServer { #[prompt_handler(router = self.prompt_router)] impl ServerHandler for PromptServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_prompts().build()).with_instructions( - "This server provides various prompt templates for code review, data analysis, \ + fn get_info(&self) -> InitializeResult { + InitializeResult::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.", - ) + ) } } diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 2be7f5d..d0a5d67 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -21,8 +21,8 @@ use tracing_subscriber::{self, EnvFilter}; pub struct SamplingDemoServer; impl ServerHandler for SamplingDemoServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> InitializeResult { + InitializeResult::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. ", diff --git a/examples/servers/src/subscriptions_streamhttp.rs b/examples/servers/src/subscriptions_streamhttp.rs index aead85f..1e5910d 100644 --- a/examples/servers/src/subscriptions_streamhttp.rs +++ b/examples/servers/src/subscriptions_streamhttp.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, time::Duration}; use rmcp::{ ErrorData, ServerHandler, - model::{ProtocolVersion, ServerCapabilities, ServerInfo, SubscriptionFilter}, + model::{InitializeResult, ProtocolVersion, ServerCapabilities, SubscriptionFilter}, service::SubscriptionContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -18,8 +18,8 @@ impl ServerHandler for SubscriptionServer { Cow::Borrowed(&[ProtocolVersion::V_2026_07_28]) } - fn get_info(&self) -> ServerInfo { - ServerInfo::new( + fn get_info(&self) -> InitializeResult { + InitializeResult::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() From fbfc5cc96eb40ab3ca743f67e71f59d3e42df087 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 14 Sep 2026 12:01:32 -0400 Subject: [PATCH 19/29] ci: add OpenSSF Scorecard analysis (#1214) --- .github/workflows/scorecard.yml | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..1369d73 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,48 @@ +# OpenSSF Scorecard supply-chain security analysis. +name: Scorecard supply-chain security + +on: + branch_protection_rule: + schedule: + - cron: '5 23 * * 5' + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + # Publishing only succeeds on the default branch. PR and manual runs + # still validate the analysis and produce SARIF without publishing. + publish_results: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + + - name: Upload SARIF artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + with: + sarif_file: results.sarif From 165ff92df1db1d54f051f9369e99d5251daa6039 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:30:41 -0400 Subject: [PATCH 20/29] feat(model): add ServerConfig and ClientConfig (#1266) --- README.md | 36 +++++----- conformance/src/bin/client.rs | 8 +-- conformance/src/bin/server.rs | 4 +- crates/rmcp-macros/src/lib.rs | 4 +- crates/rmcp-macros/src/tool_handler.rs | 6 +- crates/rmcp/src/handler/client.rs | 14 ++-- crates/rmcp/src/handler/server.rs | 12 ++-- crates/rmcp/src/model.rs | 72 +++++++++++++------ crates/rmcp/src/service/client.rs | 10 +-- crates/rmcp/src/service/server.rs | 16 ++--- .../transport/streamable_http_server/tower.rs | 6 +- crates/rmcp/tests/common/calculator.rs | 6 +- crates/rmcp/tests/common/handlers.rs | 4 +- crates/rmcp/tests/test_cancelled_response.rs | 8 +-- crates/rmcp/tests/test_custom_headers.rs | 30 ++++---- .../test_discover_http_client_startup.rs | 8 +-- crates/rmcp/tests/test_handler_cache_hints.rs | 6 +- .../tests/test_inflight_response_drain.rs | 10 +-- crates/rmcp/tests/test_mrtr_behavior.rs | 13 ++-- crates/rmcp/tests/test_notification.rs | 8 +-- crates/rmcp/tests/test_prompt_macros.rs | 7 +- .../test_protocol_version_negotiation.rs | 28 ++++---- .../tests/test_resource_not_found_version.rs | 6 +- crates/rmcp/tests/test_result_type_version.rs | 8 +-- .../test_sep_2260_request_association.rs | 15 ++-- .../tests/test_sep_2260_stream_enforcement.rs | 11 ++- .../tests/test_sep_2260_stream_routing.rs | 6 +- .../rmcp/tests/test_server_discover_client.rs | 8 +-- .../rmcp/tests/test_server_discover_http.rs | 6 +- .../rmcp/tests/test_server_initialization.rs | 6 +- .../rmcp/tests/test_sse_concurrent_streams.rs | 6 +- .../tests/test_stateless_protocol_version.rs | 13 ++-- .../tests/test_stdio_response_concurrency.rs | 8 +-- ...test_streamable_http_client_concurrency.rs | 11 ++- .../test_streamable_http_connection_reuse.rs | 8 +-- .../test_streamable_http_disconnect_cancel.rs | 8 +-- .../tests/test_streamable_http_event_store.rs | 8 +-- .../test_streamable_http_json_response.rs | 8 +-- .../test_streamable_http_protocol_version.rs | 4 +- .../test_streamable_http_stale_session.rs | 8 +-- .../test_streamable_http_standard_headers.rs | 6 +- crates/rmcp/tests/test_subscriptions.rs | 32 ++++----- .../test_subscriptions_streamable_http.rs | 16 ++--- crates/rmcp/tests/test_task.rs | 12 ++-- .../tests/test_tool_disable_notification.rs | 6 +- crates/rmcp/tests/test_tool_macros.rs | 10 +-- docs/OAUTH_SUPPORT.md | 6 +- .../clients/src/auth/client_credentials.rs | 4 +- examples/clients/src/auth/oauth_client.rs | 10 +-- examples/clients/src/progress_client.rs | 6 +- examples/clients/src/streamable_http.rs | 5 +- .../clients/src/subscriptions_streamhttp.rs | 4 +- examples/clients/src/task_stdio.rs | 2 +- examples/servers/src/common/counter.rs | 4 +- examples/servers/src/common/progress_demo.rs | 4 +- examples/servers/src/common/task_demo.rs | 4 +- examples/servers/src/completion_stdio.rs | 4 +- .../servers/src/elicitation_enum_inference.rs | 4 +- examples/servers/src/elicitation_stdio.rs | 4 +- examples/servers/src/mrtr.rs | 8 +-- examples/servers/src/prompt_stdio.rs | 9 ++- examples/servers/src/sampling_stdio.rs | 4 +- .../servers/src/subscriptions_streamhttp.rs | 6 +- 63 files changed, 331 insertions(+), 303 deletions(-) diff --git a/README.md b/README.md index 128388c..7b24d6a 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,10 @@ Use [`ClientServiceExt::serve_with_lifecycle`](crates/rmcp/src/service/client.rs select another lifecycle explicitly: ```rust, ignore -use rmcp::{ClientLifecycleMode, ClientServiceExt, InitializeRequestParams, ProtocolVersion}; +use rmcp::{ClientLifecycleMode, ClientServiceExt, ProtocolVersion, model::ClientConfig}; // Start directly with server/discover and include client metadata on every request. -let client = InitializeRequestParams::default() +let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -113,7 +113,7 @@ let client = InitializeRequestParams::default() // Or probe the discover lifecycle and fall back when a legacy server reports // that server/discover is not implemented or does not respond within 10 seconds. -let client = InitializeRequestParams::default() +let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Auto { @@ -369,8 +369,8 @@ use serde_json::json; struct MyServer; impl ServerHandler for MyServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_resources() .build(), @@ -582,8 +582,8 @@ impl MyServer { #[prompt_handler] impl ServerHandler for MyServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_prompts().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_prompts().build()) } } ``` @@ -934,8 +934,8 @@ Enable the logging capability, handle level changes from the client, and send lo use rmcp::{ServerHandler, model::*, service::RequestContext}; impl ServerHandler for MyServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_logging() .build(), @@ -1007,8 +1007,8 @@ Enable the completions capability and implement the `complete()` handler. Use `r use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer}; impl ServerHandler for MyServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_completions() .enable_prompts() @@ -1253,8 +1253,8 @@ use rmcp::{ }; impl ServerHandler for MyServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -1584,7 +1584,7 @@ use rmcp::transport::StreamableHttpClientTransport; // Defaults are stateless-friendly. let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); -let client = InitializeRequestParams::default().serve(transport).await?; +let client = ClientConfig::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) @@ -1638,7 +1638,7 @@ server example). The client transport connects with a single URI: use rmcp::transport::StreamableHttpClientTransport; let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); -let client = InitializeRequestParams::default().serve(transport).await?; +let client = ClientConfig::default().serve(transport).await?; ``` The client allows up to 16 ordinary http POSTs at once. Configure this with @@ -1742,10 +1742,10 @@ knows what the other supports. Declare yours with the `ServerCapabilities` builder in `get_info()`: ```rust,ignore -use rmcp::model::{InitializeResult, ServerCapabilities}; +use rmcp::model::{ServerCapabilities, ServerConfig}; -fn get_info(&self) -> InitializeResult { - InitializeResult::new( +fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_prompts() diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 1c66467..aef2c27 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -74,8 +74,8 @@ impl ClientHandler for BasicClientHandler {} struct ElicitationDefaultsClientHandler; impl ClientHandler for ElicitationDefaultsClientHandler { - fn get_info(&self) -> InitializeRequestParams { - let mut info = InitializeRequestParams::default(); + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); info.capabilities.elicitation = Some( ElicitationCapability::new() .with_form(FormElicitationCapability::new().with_schema_validation(true)), @@ -163,8 +163,8 @@ impl ClientHandler for ElicitationDefaultsClientHandler { struct FullClientHandler; impl ClientHandler for FullClientHandler { - fn get_info(&self) -> InitializeRequestParams { - let mut info = InitializeRequestParams::default(); + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); info.capabilities.elicitation = Some( ElicitationCapability::new() .with_form(FormElicitationCapability::new().with_schema_validation(true)), diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index a89ccc7..5c4b5c8 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -704,8 +704,8 @@ impl ServerHandler for ConformanceServer { (name == "test_custom_header").then(custom_header_tool) } - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_prompts() .enable_prompts_list_changed() diff --git a/crates/rmcp-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index f37200a..30433a8 100644 --- a/crates/rmcp-macros/src/lib.rs +++ b/crates/rmcp-macros/src/lib.rs @@ -217,8 +217,8 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream { /// ```rust,ignore /// #[tool_handler] /// impl ServerHandler for MyToolHandler { -/// fn get_info(&self) -> InitializeResult { -/// InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) +/// fn get_info(&self) -> ServerConfig { +/// ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) /// } /// } /// ``` diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index 02cf6bb..c9f4a25 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -116,7 +116,7 @@ pub(crate) enum CallerCapability { Prompts, } -/// Build a `get_info()` method that returns `InitializeResult` with the appropriate capabilities. +/// Build a `get_info()` method that returns `ServerConfig` with the appropriate capabilities. /// /// The caller declares its own capability via `caller`. Sibling handler attributes /// (`prompt_handler`, `tool_handler`) are detected automatically @@ -157,8 +157,8 @@ pub(crate) fn build_get_info( } syn::parse2::(quote! { - fn get_info(&self) -> rmcp::model::InitializeResult { - rmcp::model::InitializeResult::new( + fn get_info(&self) -> rmcp::model::ServerConfig { + rmcp::model::ServerConfig::new( rmcp::model::ServerCapabilities::builder() #(#capability_calls)* .build() diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 4af1a47..486fa3b 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -274,8 +274,8 @@ macro_rules! client_handler_methods { std::future::ready(()) } - fn get_info(&self) -> ClientInfo { - InitializeRequestParams::default() + fn get_info(&self) -> ClientConfig { + ClientConfig::default() } }; } @@ -292,12 +292,12 @@ pub trait ClientHandler: Sized + 'static { client_handler_methods!(); } -/// Do nothing, with default client info. +/// Do nothing, with the default client config. impl ClientHandler for () {} -/// Do nothing, with a specific client info. -impl ClientHandler for ClientInfo { - fn get_info(&self) -> ClientInfo { +/// Do nothing, with a specific client config. +impl ClientHandler for ClientConfig { + fn get_info(&self) -> ClientConfig { self.clone() } } @@ -422,7 +422,7 @@ macro_rules! impl_client_handler_for_wrapper { (**self).on_custom_notification(notification, context) } - fn get_info(&self) -> ClientInfo { + fn get_info(&self) -> ClientConfig { (**self).get_info() } } diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index a6dd83b..f846724 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -334,15 +334,15 @@ macro_rules! server_handler_methods { /// ``` /// use rmcp::{ /// ErrorData as McpError, RoleServer, ServerHandler, - /// model::{InitializeRequestParams, InitializeResult, ServerInfo}, + /// model::{InitializeRequestParams, InitializeResult, ServerConfig}, /// service::RequestContext, /// }; /// /// struct MyServer; /// /// impl ServerHandler for MyServer { - /// fn get_info(&self) -> ServerInfo { - /// ServerInfo::default() + /// fn get_info(&self) -> ServerConfig { + /// ServerConfig::default() /// } /// /// async fn initialize( @@ -600,8 +600,8 @@ macro_rules! server_handler_methods { std::future::ready(()) } - fn get_info(&self) -> ServerInfo { - InitializeResult::default() + fn get_info(&self) -> ServerConfig { + ServerConfig::default() } /// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state. @@ -839,7 +839,7 @@ macro_rules! impl_server_handler_for_wrapper { (**self).on_custom_notification(notification, context) } - fn get_info(&self) -> ServerInfo { + fn get_info(&self) -> ServerConfig { (**self).get_info() } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 9981658..ec7b93b 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1119,26 +1119,58 @@ impl InitializeResult { } } -/// Full server initialize payload (`InitializeResult`). +/// A server's own configuration: the protocol version, capabilities, +/// implementation identity, and instructions it advertises to clients. /// -/// Prefer [`InitializeResult`]. The name collides with the protocol's -/// `serverInfo` field, which is only the [`Implementation`] identity (#1082). -// -// The signatures this crate publishes (`ServerHandler::get_info`, -// `DiscoverResult::from_server_info`, and the `ClientInfo` equivalents below) -// keep spelling the alias. It resolves to the same type, so the spelling makes -// no difference to callers, but rustdoc records the name as written and the -// public API check treats a respelling as a changed item. Moving those -// signatures onto the canonical names is a documented API change and belongs in -// the next major release. -#[deprecated(note = "use `InitializeResult` instead")] +/// This is what `ServerHandler::get_info` returns. It is an alias for +/// [`InitializeResult`] because the same value is sent as the `initialize` +/// response on the wire. +/// +/// # Examples +/// +/// ``` +/// use rmcp::model::{ServerCapabilities, ServerConfig}; +/// +/// let config = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) +/// .with_instructions("Call `add` to sum two numbers."); +/// assert!(config.capabilities.tools.is_some()); +/// ``` +pub type ServerConfig = InitializeResult; + +/// A client's own configuration: the protocol version, capabilities, and +/// implementation identity it advertises to servers. +/// +/// This is what `ClientHandler::get_info` returns. It is an alias for +/// [`InitializeRequestParams`] because the same value is sent as the +/// `initialize` request on the wire. +/// +/// # Examples +/// +/// ``` +/// use rmcp::model::{ClientCapabilities, ClientConfig, Implementation}; +/// +/// let config = ClientConfig::new( +/// ClientCapabilities::builder().enable_elicitation().build(), +/// Implementation::new("my-client", "1.0.0"), +/// ); +/// assert!(config.capabilities.elicitation.is_some()); +/// ``` +pub type ClientConfig = InitializeRequestParams; + +/// Deprecated alias for [`ServerConfig`]. +/// +/// The name collides with the protocol's `serverInfo` field, which is only the +/// [`Implementation`] identity, so `server_info.server_info` was easy to +/// misread (#1082). +#[deprecated(note = "use `ServerConfig` instead")] pub type ServerInfo = InitializeResult; -/// Full client initialize params (`InitializeRequestParams`). +/// Deprecated alias for [`ClientConfig`]. /// -/// Prefer [`InitializeRequestParams`]. The name collides with the protocol's -/// `clientInfo` field, which is only the [`Implementation`] identity (#1082). -#[deprecated(note = "use `InitializeRequestParams` instead")] +/// The name collides with the protocol's `clientInfo` field, which is only the +/// [`Implementation`] identity, so `client_info.client_info` was easy to +/// misread (#1082). +#[deprecated(note = "use `ClientConfig` instead")] pub type ClientInfo = InitializeRequestParams; /// Information negotiated about a server peer. @@ -1298,18 +1330,18 @@ impl DiscoverResult { self } - /// Create a discovery result from the server's initialization information. + /// Create a discovery result from the server's configuration. pub fn from_server_info( supported_versions: Vec, - server_info: ServerInfo, + server_config: ServerConfig, ) -> Self { - let InitializeResult { + let ServerConfig { capabilities, server_info, instructions, meta, .. - } = server_info; + } = server_config; let mut result = Self { result_type: ResultType::COMPLETE, supported_versions, diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 47eeace..7a35360 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -13,10 +13,10 @@ use crate::{ model::{ ArgumentInfo, CacheScope, CallToolRequest, CallToolRequestParams, CallToolResponse, CallToolResult, CancelTaskParams, CancelTaskRequest, CancelledNotification, - CancelledNotificationParam, ClientJsonRpcMessage, ClientNotification, ClientRequest, - ClientResult, CompleteRequest, CompleteRequestParams, CompleteResult, CompletionContext, - CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, DiscoverRequestParams, - DiscoverResult, ErrorData, GetExtensions, GetMeta, GetPromptRequest, + CancelledNotificationParam, ClientConfig, 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, InitializeRequestParams, InitializedNotification, InputRequest, InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, @@ -266,7 +266,7 @@ impl ServiceRole for RoleClient { type PeerReq = ServerRequest; type PeerResp = ServerResult; type PeerNot = ServerNotification; - type Info = InitializeRequestParams; + type Info = ClientConfig; type PeerInfo = ServerPeerInfo; type InitializeError = ClientInitializeError; const IS_CLIENT: bool = true; diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 2b44345..cff97f5 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -16,13 +16,13 @@ use crate::{ CancelledNotification, CancelledNotificationParam, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, - InitializeRequestParams, InitializeResult, ListRootsRequest, ListRootsResult, - LoggingMessageNotification, LoggingMessageNotificationParam, ProgressNotification, - ProgressNotificationParam, PromptListChangedNotification, ProtocolVersion, - ResourceListChangedNotification, ResourceUpdatedNotification, - ResourceUpdatedNotificationParam, ServerNotification, ServerRequest, ServerResult, - SubscriptionFilter, SubscriptionsAcknowledgedNotification, - SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification, + InitializeRequestParams, ListRootsRequest, ListRootsResult, LoggingMessageNotification, + LoggingMessageNotificationParam, ProgressNotification, ProgressNotificationParam, + PromptListChangedNotification, ProtocolVersion, ResourceListChangedNotification, + ResourceUpdatedNotification, ResourceUpdatedNotificationParam, ServerConfig, + ServerNotification, ServerRequest, ServerResult, SubscriptionFilter, + SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, + ToolListChangedNotification, }, transport::DynamicTransportError, }; @@ -38,7 +38,7 @@ impl ServiceRole for RoleServer { type PeerReq = ClientRequest; type PeerResp = ClientResult; type PeerNot = ClientNotification; - type Info = InitializeResult; + type Info = ServerConfig; type PeerInfo = InitializeRequestParams; type InitializeError = ServerInitializeError; diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 6a980b2..1b53820 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -27,8 +27,8 @@ use crate::{ model::{ ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode, ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest, - InitializeRequestParams, InitializeResult, InitializedNotification, JsonObject, - JsonRpcError, ProtocolVersion, RequestId, ServerJsonRpcMessage, ServerResult, + InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, + ProtocolVersion, RequestId, ServerConfig, ServerJsonRpcMessage, ServerResult, }, serve_server, service::{ @@ -367,7 +367,7 @@ impl> Service for NegotiatingStatelessHttpSer self.0.handle_notification(notification, context).await } - fn get_info(&self) -> InitializeResult { + fn get_info(&self) -> ServerConfig { self.0.get_info() } diff --git a/crates/rmcp/tests/common/calculator.rs b/crates/rmcp/tests/common/calculator.rs index 5a51374..961d977 100644 --- a/crates/rmcp/tests/common/calculator.rs +++ b/crates/rmcp/tests/common/calculator.rs @@ -2,7 +2,7 @@ use rmcp::{ ServerHandler, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{InitializeResult, ServerCapabilities}, + model::{ServerCapabilities, ServerConfig}, schemars, tool, tool_router, }; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -52,8 +52,8 @@ impl Calculator { } impl ServerHandler for Calculator { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::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 4cf6799..7e68621 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -115,8 +115,8 @@ impl TestServer { impl ServerHandler for TestServer { #[allow(deprecated)] - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_logging().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_logging().build()) } #[allow(deprecated)] diff --git a/crates/rmcp/tests/test_cancelled_response.rs b/crates/rmcp/tests/test_cancelled_response.rs index e46cddb..57109ab 100644 --- a/crates/rmcp/tests/test_cancelled_response.rs +++ b/crates/rmcp/tests/test_cancelled_response.rs @@ -8,8 +8,8 @@ use std::{collections::BTreeSet, process::Stdio, time::Duration}; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, - ServerCapabilities, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerConfig, }, service::RequestContext, }; @@ -91,8 +91,8 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { struct WaitForCancelServer; impl ServerHandler for WaitForCancelServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index a790b23..b01223a 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -719,7 +719,7 @@ async fn test_server_rejects_unsupported_protocol_version() { use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{InitializeResult, ServerCapabilities}, + model::{ServerCapabilities, ServerConfig}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -730,8 +730,8 @@ async fn test_server_rejects_unsupported_protocol_version() { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().build()) } } @@ -892,7 +892,7 @@ async fn test_server_validates_host_when_origin_validation_is_disabled_by_defaul use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{InitializeResult, ServerCapabilities}, + model::{ServerCapabilities, ServerConfig}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -903,8 +903,8 @@ async fn test_server_validates_host_when_origin_validation_is_disabled_by_defaul struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().build()) } } @@ -975,7 +975,7 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{InitializeResult, ServerCapabilities}, + model::{ServerCapabilities, ServerConfig}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -986,8 +986,8 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().build()) } } @@ -1046,7 +1046,7 @@ async fn test_server_falls_back_to_uri_authority_when_host_header_missing() { use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{InitializeResult, ServerCapabilities}, + model::{ServerCapabilities, ServerConfig}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -1057,8 +1057,8 @@ async fn test_server_falls_back_to_uri_authority_when_host_header_missing() { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().build()) } } @@ -1132,7 +1132,7 @@ mod origin_validation { use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{InitializeResult, ServerCapabilities}, + model::{ServerCapabilities, ServerConfig}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -1143,8 +1143,8 @@ mod origin_validation { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().build()) } } diff --git a/crates/rmcp/tests/test_discover_http_client_startup.rs b/crates/rmcp/tests/test_discover_http_client_startup.rs index fc0ad25..2d92df5 100644 --- a/crates/rmcp/tests/test_discover_http_client_startup.rs +++ b/crates/rmcp/tests/test_discover_http_client_startup.rs @@ -16,7 +16,7 @@ use axum::{ }; use rmcp::{ ClientLifecycleMode, ClientServiceExt, ServerHandler, - model::{DiscoverResult, ErrorCode, ErrorData, InitializeRequestParams, ProtocolVersion}, + model::{ClientConfig, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion}, service::{MaybeSendFuture, RequestContext, RoleServer}, transport::{ StreamableHttpClientTransport, @@ -132,7 +132,7 @@ async fn discover_http_client_bootstraps_headers_without_initialize() { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), ); - let client = InitializeRequestParams::default() + let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -176,7 +176,7 @@ async fn auto_http_client_falls_back_to_stateful_legacy_startup() { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), ); - let client = InitializeRequestParams::default() + let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Auto { @@ -217,7 +217,7 @@ async fn auto_http_client_falls_back_after_plain_text_4xx_rejection() { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")), ); - let client = InitializeRequestParams::default() + let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Auto { diff --git a/crates/rmcp/tests/test_handler_cache_hints.rs b/crates/rmcp/tests/test_handler_cache_hints.rs index efefd9c..ed3cf67 100644 --- a/crates/rmcp/tests/test_handler_cache_hints.rs +++ b/crates/rmcp/tests/test_handler_cache_hints.rs @@ -5,7 +5,7 @@ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, handler::server::router::{prompt::PromptRouter, tool::ToolRouter}, model::{ - CacheScope, InitializeRequestParams, InitializeResult, ListPromptsResult, ListToolsResult, + CacheScope, ClientConfig, InitializeResult, ListPromptsResult, ListToolsResult, ProtocolVersion, }, prompt_handler, @@ -38,8 +38,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> InitializeRequestParams { - let mut info = InitializeRequestParams::default(); + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); info.protocol_version = self.protocol_version.clone(); info } diff --git a/crates/rmcp/tests/test_inflight_response_drain.rs b/crates/rmcp/tests/test_inflight_response_drain.rs index d21f678..fc5908f 100644 --- a/crates/rmcp/tests/test_inflight_response_drain.rs +++ b/crates/rmcp/tests/test_inflight_response_drain.rs @@ -14,7 +14,7 @@ use std::{ use rmcp::{ ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParams, InitializeRequestParams, InitializeResult, ServerCapabilities}, + model::{CallToolRequestParams, ClientConfig, ServerCapabilities, ServerConfig}, service::QuitReason, tool, tool_handler, tool_router, }; @@ -55,8 +55,8 @@ impl SlowToolServer { #[tool_handler] impl ServerHandler for SlowToolServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } } @@ -64,8 +64,8 @@ impl ServerHandler for SlowToolServer { struct DummyClientHandler; impl rmcp::ClientHandler for DummyClientHandler { - fn get_info(&self) -> InitializeRequestParams { - InitializeRequestParams::default() + fn get_info(&self) -> ClientConfig { + ClientConfig::default() } } diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs index db70537..d9df5af 100644 --- a/crates/rmcp/tests/test_mrtr_behavior.rs +++ b/crates/rmcp/tests/test_mrtr_behavior.rs @@ -114,8 +114,8 @@ impl MacroMrtrServer { #[tool_handler] impl ServerHandler for MacroMrtrServer { - fn get_info(&self) -> InitializeResult { - let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); info.protocol_version = ProtocolVersion::V_2026_07_28; info } @@ -221,8 +221,8 @@ impl MrtrServer { } impl ServerHandler for MrtrServer { - fn get_info(&self) -> InitializeResult { - let mut info = InitializeResult::new( + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_prompts() @@ -609,9 +609,8 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re struct SealingServer; impl ServerHandler for SealingServer { - fn get_info(&self) -> InitializeResult { - let mut info = - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); info.protocol_version = ProtocolVersion::V_2026_07_28; info } diff --git a/crates/rmcp/tests/test_notification.rs b/crates/rmcp/tests/test_notification.rs index a095ba4..dafb0df 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ - ClientNotification, CustomNotification, InitializeResult, ResourceUpdatedNotificationParam, - ServerCapabilities, ServerNotification, SubscribeRequestParams, + ClientNotification, CustomNotification, ResourceUpdatedNotificationParam, + ServerCapabilities, ServerConfig, ServerNotification, SubscribeRequestParams, }, }; use serde_json::json; @@ -16,8 +16,8 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; struct Server {} impl ServerHandler for Server { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_resources() .enable_resources_subscribe() diff --git a/crates/rmcp/tests/test_prompt_macros.rs b/crates/rmcp/tests/test_prompt_macros.rs index 46be406..e6d65b6 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -7,8 +7,7 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceExt, handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, model::{ - ContentBlock, GetPromptRequestParams, GetPromptResult, InitializeRequestParams, - PromptMessage, Role, + ClientConfig, ContentBlock, GetPromptRequestParams, GetPromptResult, PromptMessage, Role, }, prompt, prompt_handler, prompt_router, }; @@ -298,8 +297,8 @@ fn test_optional_field_schema_generation_via_macro() { struct DummyClientHandler {} impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> InitializeRequestParams { - InitializeRequestParams::default() + fn get_info(&self) -> ClientConfig { + ClientConfig::default() } } diff --git a/crates/rmcp/tests/test_protocol_version_negotiation.rs b/crates/rmcp/tests/test_protocol_version_negotiation.rs index cf9af90..1453e0f 100644 --- a/crates/rmcp/tests/test_protocol_version_negotiation.rs +++ b/crates/rmcp/tests/test_protocol_version_negotiation.rs @@ -16,8 +16,8 @@ use std::{ use rmcp::{ ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt, model::{ - ClientCapabilities, ErrorCode, Implementation, InitializeRequestParams, InitializeResult, - ProtocolVersion, + ClientCapabilities, ClientConfig, ErrorCode, Implementation, InitializeRequestParams, + InitializeResult, ProtocolVersion, ServerConfig, }, service::{ClientInitializeError, RequestContext}, }; @@ -26,8 +26,8 @@ use rmcp::{ struct EchoServer; impl ServerHandler for EchoServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::default() + fn get_info(&self) -> ServerConfig { + ServerConfig::default() } } @@ -45,8 +45,8 @@ const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[ struct NarrowedServer; impl ServerHandler for NarrowedServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::default() + fn get_info(&self) -> ServerConfig { + ServerConfig::default() } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { @@ -61,8 +61,8 @@ struct ModernOnlyServer; const MODERN_ONLY_VERSIONS: &[ProtocolVersion] = &[ProtocolVersion::V_2026_07_28]; impl ServerHandler for ModernOnlyServer { - fn get_info(&self) -> InitializeResult { - let mut info = InitializeResult::default(); + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::default(); info.protocol_version = ProtocolVersion::V_2026_07_28; info } @@ -79,8 +79,8 @@ impl ServerHandler for ModernOnlyServer { struct NarrowedOverridingServer; impl ServerHandler for NarrowedOverridingServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::default() + fn get_info(&self) -> ServerConfig { + ServerConfig::default() } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { @@ -102,8 +102,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> InitializeRequestParams { - let mut info = InitializeRequestParams::default(); + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); info.protocol_version = self.protocol_version.clone(); info } @@ -247,8 +247,8 @@ struct DelegatingServer { } impl ServerHandler for DelegatingServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::default() + fn get_info(&self) -> ServerConfig { + ServerConfig::default() } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { diff --git a/crates/rmcp/tests/test_resource_not_found_version.rs b/crates/rmcp/tests/test_resource_not_found_version.rs index 7e79f31..efae45f 100644 --- a/crates/rmcp/tests/test_resource_not_found_version.rs +++ b/crates/rmcp/tests/test_resource_not_found_version.rs @@ -8,7 +8,7 @@ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError, model::{ - ErrorCode, ErrorData, InitializeRequestParams, InitializeResult, ProtocolVersion, + ClientConfig, ErrorCode, ErrorData, InitializeResult, ProtocolVersion, ReadResourceRequestParams, ReadResourceResponse, }, service::{RequestContext, serve_directly}, @@ -33,8 +33,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> InitializeRequestParams { - let mut info = InitializeRequestParams::default(); + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); info.protocol_version = self.protocol_version.clone(); info } diff --git a/crates/rmcp/tests/test_result_type_version.rs b/crates/rmcp/tests/test_result_type_version.rs index 556758f..61240ed 100644 --- a/crates/rmcp/tests/test_result_type_version.rs +++ b/crates/rmcp/tests/test_result_type_version.rs @@ -8,8 +8,8 @@ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorData, - InitializeRequestParams, InitializeResult, ProtocolVersion, ResultType, + CallToolRequestParams, CallToolResponse, CallToolResult, ClientConfig, ContentBlock, + ErrorData, InitializeResult, ProtocolVersion, ResultType, }, service::{RequestContext, serve_directly}, }; @@ -33,8 +33,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> InitializeRequestParams { - let mut info = InitializeRequestParams::default(); + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); info.protocol_version = self.protocol_version.clone(); info } diff --git a/crates/rmcp/tests/test_sep_2260_request_association.rs b/crates/rmcp/tests/test_sep_2260_request_association.rs index a8a1302..787d634 100644 --- a/crates/rmcp/tests/test_sep_2260_request_association.rs +++ b/crates/rmcp/tests/test_sep_2260_request_association.rs @@ -9,10 +9,9 @@ use std::sync::{Arc, Mutex}; use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError, ServiceExt, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, - InitializeRequestParams, InitializeResult, ProtocolVersion, SamplingMessage, - ServerCapabilities, ServerRequest, + CallToolRequestParams, CallToolResponse, CallToolResult, ClientConfig, ContentBlock, + CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, ProtocolVersion, + SamplingMessage, ServerCapabilities, ServerConfig, ServerRequest, }, service::{RequestContext, RunningService, serve_directly}, }; @@ -30,8 +29,8 @@ struct SamplingServer { } impl ServerHandler for SamplingServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( @@ -94,8 +93,8 @@ impl ClientHandler for SamplingClient { .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) } - fn get_info(&self) -> InitializeRequestParams { - let mut info = InitializeRequestParams::default(); + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); info.protocol_version = ProtocolVersion::V_2026_07_28; info } diff --git a/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs b/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs index b652aa8..72a0832 100644 --- a/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs +++ b/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs @@ -26,9 +26,8 @@ use http::{HeaderName, HeaderValue}; use rmcp::{ ClientHandler, model::{ - ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, - InitializeRequestParams, InitializeResult, ProtocolVersion, SamplingMessage, - ServerCapabilities, ServerJsonRpcMessage, + ClientConfig, ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, + ProtocolVersion, SamplingMessage, ServerCapabilities, ServerConfig, ServerJsonRpcMessage, }, service::{ClientLifecycleMode, RequestContext, RoleClient, serve_client_with_lifecycle}, transport::streamable_http_client::{ @@ -81,7 +80,7 @@ impl StreamableHttpClient for ScriptedServer { // 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 = InitializeResult::new(ServerCapabilities::default()); + let mut info = ServerConfig::new(ServerCapabilities::default()); info.protocol_version = ProtocolVersion::V_2026_07_28; let response = ServerJsonRpcMessage::response( rmcp::model::ServerResult::InitializeResult(info), @@ -153,8 +152,8 @@ impl ClientHandler for SamplingClient { )) } - fn get_info(&self) -> InitializeRequestParams { - InitializeRequestParams::default() + fn get_info(&self) -> ClientConfig { + ClientConfig::default() } } diff --git a/crates/rmcp/tests/test_sep_2260_stream_routing.rs b/crates/rmcp/tests/test_sep_2260_stream_routing.rs index 1e04a88..e883ccd 100644 --- a/crates/rmcp/tests/test_sep_2260_stream_routing.rs +++ b/crates/rmcp/tests/test_sep_2260_stream_routing.rs @@ -9,7 +9,7 @@ use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ElicitRequestParams, - ElicitationSchema, InitializeResult, ServerCapabilities, + ElicitationSchema, ServerCapabilities, ServerConfig, }, service::RequestContext, transport::streamable_http_server::{ @@ -23,8 +23,8 @@ use tokio_util::sync::CancellationToken; struct ElicitingServer; impl ServerHandler for ElicitingServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_server_discover_client.rs b/crates/rmcp/tests/test_server_discover_client.rs index f8f8727..9046f6b 100644 --- a/crates/rmcp/tests/test_server_discover_client.rs +++ b/crates/rmcp/tests/test_server_discover_client.rs @@ -3,8 +3,8 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ - ClientCapabilities, Implementation, InitializeResult, ProtocolVersion, RequestMetaObject, - ServerCapabilities, + ClientCapabilities, Implementation, ProtocolVersion, RequestMetaObject, ServerCapabilities, + ServerConfig, }, select_protocol_version, }; @@ -13,8 +13,8 @@ use rmcp::{ struct DiscoveryServer; impl ServerHandler for DiscoveryServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("discovery-server", "1.0.0")) } } diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs index 89afbd6..7762c97 100644 --- a/crates/rmcp/tests/test_server_discover_http.rs +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use rmcp::{ ServerHandler, - model::{Implementation, InitializeResult, ProtocolVersion, ServerCapabilities}, + model::{Implementation, ProtocolVersion, ServerCapabilities, ServerConfig}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -20,8 +20,8 @@ use tokio_util::sync::CancellationToken; struct DiscoveryServer; impl ServerHandler for DiscoveryServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("discovery-server", "1.0.0")) .with_instructions("Use the tools carefully") } diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index 4ebffc5..5be178e 100644 --- a/crates/rmcp/tests/test_server_initialization.rs +++ b/crates/rmcp/tests/test_server_initialization.rs @@ -6,7 +6,7 @@ use common::handlers::TestServer; use rmcp::{ ServerHandler, ServiceExt, model::{ - ClientJsonRpcMessage, InitializeResult, ProtocolVersion, ServerCapabilities, + ClientJsonRpcMessage, ProtocolVersion, ServerCapabilities, ServerConfig, ServerJsonRpcMessage, ServerResult, }, transport::{IntoTransport, Transport}, @@ -281,8 +281,8 @@ async fn server_falls_back_when_client_protocol_version_unknown() { struct PinnedServer; impl ServerHandler for PinnedServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().build()) .with_protocol_version(ProtocolVersion::V_2025_06_18) } } diff --git a/crates/rmcp/tests/test_sse_concurrent_streams.rs b/crates/rmcp/tests/test_sse_concurrent_streams.rs index c839bc4..23ab8d0 100644 --- a/crates/rmcp/tests/test_sse_concurrent_streams.rs +++ b/crates/rmcp/tests/test_sse_concurrent_streams.rs @@ -18,7 +18,7 @@ use std::time::Duration; use futures::StreamExt; use rmcp::{ RoleServer, ServerHandler, - model::{Implementation, InitializeResult, ServerCapabilities, ToolsCapability}, + model::{Implementation, ServerCapabilities, ServerConfig, ToolsCapability}, service::NotificationContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -45,8 +45,8 @@ impl TestServer { } impl ServerHandler for TestServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools_with({ let mut tools = ToolsCapability::default(); diff --git a/crates/rmcp/tests/test_stateless_protocol_version.rs b/crates/rmcp/tests/test_stateless_protocol_version.rs index 4437d28..cc2a4b5 100644 --- a/crates/rmcp/tests/test_stateless_protocol_version.rs +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -10,7 +10,10 @@ use std::borrow::Cow; use rmcp::{ ErrorData, RoleServer, ServerHandler, - model::{InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities}, + model::{ + InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities, + ServerConfig, + }, service::RequestContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -22,8 +25,8 @@ use tokio_util::sync::CancellationToken; struct OverridingInitialize; impl ServerHandler for OverridingInitialize { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::default()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::default()) } async fn initialize( @@ -51,8 +54,8 @@ const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[ struct NarrowedOverridingInitialize; impl ServerHandler for NarrowedOverridingInitialize { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::default()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::default()) } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { diff --git a/crates/rmcp/tests/test_stdio_response_concurrency.rs b/crates/rmcp/tests/test_stdio_response_concurrency.rs index 39855ad..64be3fc 100644 --- a/crates/rmcp/tests/test_stdio_response_concurrency.rs +++ b/crates/rmcp/tests/test_stdio_response_concurrency.rs @@ -5,8 +5,8 @@ use std::{collections::BTreeSet, process::Stdio, time::Duration}; use rmcp::{ ErrorData as McpError, ServerHandler, ServiceExt, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, - ServerCapabilities, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerConfig, }, }; use serde_json::{Value, json}; @@ -82,8 +82,8 @@ struct LargeResponseServer; impl ServerHandler for LargeResponseServer { #[allow(deprecated)] - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_client_concurrency.rs b/crates/rmcp/tests/test_streamable_http_client_concurrency.rs index 38b98c0..2e5be84 100644 --- a/crates/rmcp/tests/test_streamable_http_client_concurrency.rs +++ b/crates/rmcp/tests/test_streamable_http_client_concurrency.rs @@ -16,9 +16,9 @@ use futures::{StreamExt, stream::BoxStream}; use http::{HeaderName, HeaderValue}; use rmcp::{ model::{ - CallToolRequestParams, CancelledNotificationParam, ClientJsonRpcMessage, ClientRequest, - DiscoverResult, InitializeRequestParams, ProtocolVersion, Request, RequestId, - RequestMetaObject, ServerJsonRpcMessage, + CallToolRequestParams, CancelledNotificationParam, ClientConfig, ClientJsonRpcMessage, + ClientRequest, DiscoverResult, InitializeRequestParams, ProtocolVersion, Request, + RequestId, RequestMetaObject, ServerJsonRpcMessage, }, service::{ ClientLifecycleMode, PeerRequestOptions, RequestHandle, RoleClient, RunningService, @@ -345,7 +345,7 @@ impl StreamableHttpClient for ScriptedClient { } struct Harness { - client: RunningService, + client: RunningService, started: mpsc::UnboundedReceiver, controls: mpsc::UnboundedReceiver, incoming: mpsc::UnboundedSender>, @@ -402,8 +402,7 @@ impl Harness { config, ); let client = - serve_client_with_lifecycle(InitializeRequestParams::default(), transport, lifecycle) - .await?; + serve_client_with_lifecycle(ClientConfig::default(), transport, lifecycle).await?; Ok(Self { client, started: requests, diff --git a/crates/rmcp/tests/test_streamable_http_connection_reuse.rs b/crates/rmcp/tests/test_streamable_http_connection_reuse.rs index 9febc02..aa4ae63 100644 --- a/crates/rmcp/tests/test_streamable_http_connection_reuse.rs +++ b/crates/rmcp/tests/test_streamable_http_connection_reuse.rs @@ -5,7 +5,7 @@ use std::time::Instant; use rmcp::{ ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolRequestParams, InitializeRequestParams, InitializeResult, ServerCapabilities}, + model::{CallToolRequestParams, ClientConfig, ServerCapabilities, ServerConfig}, schemars, tool, tool_handler, tool_router, transport::{ StreamableHttpClientTransport, @@ -46,8 +46,8 @@ impl SumServer { #[tool_handler(router = self.tool_router)] impl ServerHandler for SumServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } } @@ -83,7 +83,7 @@ async fn test_subsequent_tool_calls_reuse_connections() -> anyhow::Result<()> { let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")), ); - let client = InitializeRequestParams::default().serve(transport).await?; + let client = ClientConfig::default().serve(transport).await?; // Warm up: first call may include one-time setup costs. let args: serde_json::Map = diff --git a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs index 5bc808c..05efe21 100644 --- a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs +++ b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs @@ -18,8 +18,8 @@ use std::{sync::Arc, time::Duration}; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, - ServerCapabilities, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, + ServerConfig, }, service::RequestContext, transport::streamable_http_server::{ @@ -37,8 +37,8 @@ struct CancelProbe { impl ServerHandler for CancelProbe { #[allow(deprecated)] - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_event_store.rs b/crates/rmcp/tests/test_streamable_http_event_store.rs index 191466a..979ce7e 100644 --- a/crates/rmcp/tests/test_streamable_http_event_store.rs +++ b/crates/rmcp/tests/test_streamable_http_event_store.rs @@ -18,8 +18,8 @@ use futures::StreamExt; use rmcp::{ ErrorData, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, - ProgressNotificationParam, ServerCapabilities, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + ProgressNotificationParam, ServerCapabilities, ServerConfig, }, service::RequestContext, transport::streamable_http_server::{ @@ -109,8 +109,8 @@ impl EventStore for InMemoryEventStore { struct ProgressServer; impl ServerHandler for ProgressServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_json_response.rs b/crates/rmcp/tests/test_streamable_http_json_response.rs index 5fc5f49..62daa7a 100644 --- a/crates/rmcp/tests/test_streamable_http_json_response.rs +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -2,8 +2,8 @@ use rmcp::{ ErrorData, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult, - ProgressNotificationParam, ServerCapabilities, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + ProgressNotificationParam, ServerCapabilities, ServerConfig, }, service::RequestContext, transport::streamable_http_server::{ @@ -57,8 +57,8 @@ const NEGOTIATED_CALL_WITH_PROGRESS_BODY: &str = r#"{ struct ProgressServer; impl ServerHandler for ProgressServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index 16cbc7b..76005a7 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -481,8 +481,8 @@ impl CountingServer { } impl ServerHandler for CountingServer { - fn get_info(&self) -> rmcp::model::InitializeResult { - rmcp::model::InitializeResult::new( + fn get_info(&self) -> rmcp::model::ServerConfig { + rmcp::model::ServerConfig::new( rmcp::model::ServerCapabilities::builder() .enable_tools() .build(), diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index 233128f..c9ea214 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -15,9 +15,9 @@ use http::{HeaderName, HeaderValue}; use rmcp::{ ServiceError, ServiceExt, model::{ - CallToolRequestParams, ClientJsonRpcMessage, ClientRequest, ErrorCode, ErrorData, - InitializeRequestParams, InitializeResult, PingRequest, ProtocolVersion, RequestId, - ServerCapabilities, ServerJsonRpcMessage, ServerResult, + CallToolRequestParams, ClientConfig, ClientJsonRpcMessage, ClientRequest, ErrorCode, + ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities, + ServerJsonRpcMessage, ServerResult, }, transport::{ StreamableHttpClientTransport, @@ -205,7 +205,7 @@ async fn test_reinitialization_completes_accepted_sse_request_instead_of_hanging mock_client, StreamableHttpClientTransportConfig::with_uri("mock://mcp"), ); - let mut client = InitializeRequestParams::default().serve(transport).await?; + let mut client = ClientConfig::default().serve(transport).await?; let peer = client.peer().clone(); let pending_call = tokio::spawn(async move { diff --git a/crates/rmcp/tests/test_streamable_http_standard_headers.rs b/crates/rmcp/tests/test_streamable_http_standard_headers.rs index 655efad..9e70805 100644 --- a/crates/rmcp/tests/test_streamable_http_standard_headers.rs +++ b/crates/rmcp/tests/test_streamable_http_standard_headers.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use rmcp::{ ServerHandler, - model::{InitializeResult, ServerCapabilities, Tool}, + model::{ServerCapabilities, ServerConfig, Tool}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -18,8 +18,8 @@ const SEP_VERSION: &str = "2026-07-28"; struct HeaderValidationServer; impl ServerHandler for HeaderValidationServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } fn get_tool(&self, name: &str) -> Option { diff --git a/crates/rmcp/tests/test_subscriptions.rs b/crates/rmcp/tests/test_subscriptions.rs index 99d8b9e..7359530 100644 --- a/crates/rmcp/tests/test_subscriptions.rs +++ b/crates/rmcp/tests/test_subscriptions.rs @@ -18,8 +18,8 @@ use rmcp::{ ClientHandler, ClientServiceExt, ServerHandler, ServiceExt, model::{ ClientNotification, ClientRequest, DiscoverResult, GetMeta, Implementation, - InitializeResult, NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, - ServerCapabilities, ServerNotification, ServerResult, SubscriptionFilter, + NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, ServerCapabilities, + ServerConfig, ServerNotification, ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenResult, }, @@ -44,8 +44,8 @@ impl ClientHandler for CountingClient { } impl ServerHandler for ToolsOnlyServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -80,8 +80,8 @@ impl ServerHandler for ToolsOnlyServer { struct ToolsAndPromptsServer; impl ServerHandler for ToolsAndPromptsServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -120,8 +120,8 @@ impl ServerHandler for ToolsAndPromptsServer { struct ResourceSubscriptionServer; impl ServerHandler for ResourceSubscriptionServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_resources() .enable_resources_subscribe() @@ -182,8 +182,8 @@ impl ServerHandler for RemoteCancellationServer { struct FloodServer; impl ServerHandler for FloodServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -217,8 +217,8 @@ struct ClosedSinkServer { struct LeakyServer; impl ServerHandler for LeakyServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -314,14 +314,14 @@ impl rmcp::service::Service for MalformedAcknowledgmentServer { Ok(()) } - fn get_info(&self) -> rmcp::model::InitializeResult { - InitializeResult::default() + fn get_info(&self) -> rmcp::model::ServerConfig { + ServerConfig::default() } } impl ServerHandler for ClosedSinkServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() diff --git a/crates/rmcp/tests/test_subscriptions_streamable_http.rs b/crates/rmcp/tests/test_subscriptions_streamable_http.rs index 04d443a..70f102f 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::{ - ClientRequest, Implementation, InitializeRequestParams, InitializeResult, ListToolsRequest, - ProtocolVersion, RequestMetaObject, ServerCapabilities, ServerNotification, + ClientConfig, ClientRequest, Implementation, ListToolsRequest, ProtocolVersion, + RequestMetaObject, ServerCapabilities, ServerConfig, ServerNotification, SubscriptionFilter, }, service::{PeerRequestOptions, SubscriptionContext, SubscriptionEnd}, @@ -53,8 +53,8 @@ impl ServerHandler for HttpSubscriptionServer { Cow::Borrowed(&[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25]) } - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -160,7 +160,7 @@ async fn modern_http_listen_uses_post_stream_and_cancels_by_closing_it() -> anyh let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url.clone()), ); - let client = InitializeRequestParams::default() + let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -205,7 +205,7 @@ async fn modern_http_graceful_close_returns_final_listen_result() -> anyhow::Res let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = InitializeRequestParams::default() + let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -241,7 +241,7 @@ async fn modern_http_stream_close_without_result_is_abrupt() -> anyhow::Result<( let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = InitializeRequestParams::default() + let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -274,7 +274,7 @@ async fn modern_http_lifecycle_stays_sessionless_for_older_application_version() let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = InitializeRequestParams::default() + let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index b7fb660..0178bab 100644 --- a/crates/rmcp/tests/test_task.rs +++ b/crates/rmcp/tests/test_task.rs @@ -107,8 +107,8 @@ impl ServerHandler for TaskServer { self.tasks.cancel_task(&request.task_id) } - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() @@ -117,8 +117,8 @@ impl ServerHandler for TaskServer { } } -fn tasks_client_info() -> InitializeRequestParams { - InitializeRequestParams::new( +fn tasks_client_info() -> ClientConfig { + ClientConfig::new( ClientCapabilities::builder().enable_tasks().build(), Implementation::from_build_env(), ) @@ -275,8 +275,8 @@ impl ServerHandler for AlwaysTaskServer { Ok(CallToolResponse::Task(CreateTaskResult::new(task))) } - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() diff --git a/crates/rmcp/tests/test_tool_disable_notification.rs b/crates/rmcp/tests/test_tool_disable_notification.rs index 1d5ad84..14a8757 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::{CallToolResponse, CallToolResult, InitializeResult, ServerCapabilities, Tool}, + model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerConfig, Tool}, service::{MaybeSendFuture, NotificationContext}, }; use tokio::sync::{Notify, RwLock}; @@ -41,8 +41,8 @@ impl TestToolServer { } impl ServerHandler for TestToolServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_tool_macros.rs b/crates/rmcp/tests/test_tool_macros.rs index ae16677..8642496 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, InitializeRequestParams, InitializeResult, ServerCapabilities}, + model::{CallToolRequestParams, ClientConfig, ServerCapabilities, ServerConfig}, tool, tool_handler, tool_router, }; use schemars::JsonSchema; @@ -287,8 +287,8 @@ fn test_optional_field_schema_generation_via_macro() { struct DummyClientHandler {} impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> InitializeRequestParams { - InitializeRequestParams::default() + fn get_info(&self) -> ClientConfig { + ClientConfig::default() } } @@ -549,8 +549,8 @@ impl ManualInfoServer { #[tool_handler] impl ServerHandler for ManualInfoServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_resources() diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 279d725..ffd3bf2 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -253,7 +253,7 @@ let transport = StreamableHttpClientTransport::with_client( ); // create client and connect to MCP server -let client_service = InitializeRequestParams::default(); +let client_service = ClientConfig::default(); let client = client_service.serve(transport).await?; ``` @@ -325,7 +325,7 @@ bindings, not signatures; the resource authorization server verifies signatures. use oauth2::{ClientSecret, RefreshToken}; use rmcp::{ ServiceExt, - model::ClientInfo, + model::ClientConfig, transport::{ StreamableHttpClientTransport, auth::{ @@ -359,7 +359,7 @@ async fn connect( StreamableHttpClientTransportConfig::with_uri(resource) .auth_header(token.access_token.secret()), ); - let client = ClientInfo::default().serve(transport).await?; + let client = ClientConfig::default().serve(transport).await?; client.list_tools(Default::default()).await?; client.cancel().await?; Ok(()) diff --git a/examples/clients/src/auth/client_credentials.rs b/examples/clients/src/auth/client_credentials.rs index 7d9c9b4..e277b9e 100644 --- a/examples/clients/src/auth/client_credentials.rs +++ b/examples/clients/src/auth/client_credentials.rs @@ -3,7 +3,7 @@ use std::env; use anyhow::{Context, Result}; use rmcp::{ ServiceExt, - model::InitializeRequestParams, + model::ClientConfig, transport::{ StreamableHttpClientTransport, auth::{AuthClient, ClientCredentialsConfig, OAuthState}, @@ -73,7 +73,7 @@ async fn main() -> Result<()> { ); // Connect to MCP server and list tools - let client_service = InitializeRequestParams::default(); + let client_service = ClientConfig::default(); let client = client_service.serve(transport).await?; tracing::info!("Connected to MCP server"); diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index 9bb3e0d..8fa18df 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -9,7 +9,7 @@ use axum::{ }; use rmcp::{ RoleClient, ServiceExt, - model::InitializeRequestParams, + model::ClientConfig, service::RunningService, transport::{ StreamableHttpClientTransport, @@ -58,7 +58,7 @@ async fn callback_handler( enum ConnectOutcome { /// The server accepted the unauthenticated connection. - Connected(RunningService), + Connected(RunningService), /// The server answered 401; authorize with this `WWW-Authenticate` /// challenge and reconnect. AuthRequired(String), @@ -72,7 +72,7 @@ async fn try_connect(http_client: reqwest::Client, server_url: &str) -> Result Ok(ConnectOutcome::Connected(client)), Err(error) => match error.auth_challenge() { Some(challenge) => Ok(ConnectOutcome::AuthRequired(challenge.to_string())), @@ -90,7 +90,7 @@ async fn authorize_and_connect( client_metadata_url: &str, code_receiver: oneshot::Receiver, output: &mut BufWriter, -) -> Result> { +) -> Result> { tracing::info!("Server requires authorization: {challenge}"); // initialize oauth state machine @@ -156,7 +156,7 @@ async fn authorize_and_connect( auth_client, StreamableHttpClientTransportConfig::with_uri(server_url), ); - Ok(InitializeRequestParams::default().serve(transport).await?) + Ok(ClientConfig::default().serve(transport).await?) } #[tokio::main] diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index a9bb611..3990006 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::{ - CallToolRequestParams, ClientCapabilities, Implementation, InitializeRequestParams, + CallToolRequestParams, ClientCapabilities, ClientConfig, Implementation, ProgressNotificationParam, }, service::{NotificationContext, RoleClient}, @@ -121,8 +121,8 @@ impl ClientHandler for ProgressAwareClient { } } - fn get_info(&self) -> InitializeRequestParams { - InitializeRequestParams::new( + fn get_info(&self) -> ClientConfig { + ClientConfig::new( ClientCapabilities::default(), Implementation::new("progress-test-client", "1.0.0"), ) diff --git a/examples/clients/src/streamable_http.rs b/examples/clients/src/streamable_http.rs index bf2b7e9..f841a87 100644 --- a/examples/clients/src/streamable_http.rs +++ b/examples/clients/src/streamable_http.rs @@ -2,8 +2,7 @@ use anyhow::Result; use rmcp::{ ClientLifecycleMode, ClientServiceExt, model::{ - CallToolRequestParams, ClientCapabilities, Implementation, InitializeRequestParams, - ProtocolVersion, + CallToolRequestParams, ClientCapabilities, ClientConfig, Implementation, ProtocolVersion, }, transport::StreamableHttpClientTransport, }; @@ -19,7 +18,7 @@ async fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); - let client_info = InitializeRequestParams::new( + let client_info = ClientConfig::new( ClientCapabilities::default(), Implementation::new("streamable-http-client", "0.0.1"), ); diff --git a/examples/clients/src/subscriptions_streamhttp.rs b/examples/clients/src/subscriptions_streamhttp.rs index 57e5d9f..9c04531 100644 --- a/examples/clients/src/subscriptions_streamhttp.rs +++ b/examples/clients/src/subscriptions_streamhttp.rs @@ -1,6 +1,6 @@ use rmcp::{ ClientLifecycleMode, ClientServiceExt, - model::{InitializeRequestParams, ProtocolVersion, SubscriptionFilter}, + model::{ClientConfig, ProtocolVersion, SubscriptionFilter}, transport::StreamableHttpClientTransport, }; @@ -13,7 +13,7 @@ async fn main() -> anyhow::Result<()> { .init(); let transport = StreamableHttpClientTransport::from_uri("http://127.0.0.1:8000/mcp"); - let client = InitializeRequestParams::default() + let client = ClientConfig::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { diff --git a/examples/clients/src/task_stdio.rs b/examples/clients/src/task_stdio.rs index 3fefafb..a946f75 100644 --- a/examples/clients/src/task_stdio.rs +++ b/examples/clients/src/task_stdio.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { .init(); // Declare the tasks extension in our client capabilities (SEP-2663). - let client_info = rmcp::model::InitializeRequestParams::new( + let client_info = rmcp::model::ClientConfig::new( ClientCapabilities::builder().enable_tasks().build(), rmcp::model::Implementation::from_build_env(), ); diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index c72aaf5..479fcfa 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -206,8 +206,8 @@ 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"})))] impl ServerHandler for Counter { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_prompts() .enable_resources() diff --git a/examples/servers/src/common/progress_demo.rs b/examples/servers/src/common/progress_demo.rs index ba7bee9..68a32ec 100644 --- a/examples/servers/src/common/progress_demo.rs +++ b/examples/servers/src/common/progress_demo.rs @@ -131,8 +131,8 @@ impl ProgressDemo { #[tool_handler] impl ServerHandler for ProgressDemo { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(ProtocolVersion::V_2024_11_05) .with_server_info(Implementation::from_build_env()) .with_instructions( diff --git a/examples/servers/src/common/task_demo.rs b/examples/servers/src/common/task_demo.rs index 3be5969..30aed17 100644 --- a/examples/servers/src/common/task_demo.rs +++ b/examples/servers/src/common/task_demo.rs @@ -151,8 +151,8 @@ impl ServerHandler for TaskDemo { self.tasks.cancel_task(&request.task_id) } - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() diff --git a/examples/servers/src/completion_stdio.rs b/examples/servers/src/completion_stdio.rs index 9ad30f4..c7894ea 100644 --- a/examples/servers/src/completion_stdio.rs +++ b/examples/servers/src/completion_stdio.rs @@ -310,8 +310,8 @@ impl SqlQueryServer { #[prompt_handler(router = self.prompt_router)] impl ServerHandler for SqlQueryServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_completions() .enable_prompts() diff --git a/examples/servers/src/elicitation_enum_inference.rs b/examples/servers/src/elicitation_enum_inference.rs index 6e01bce..e565892 100644 --- a/examples/servers/src/elicitation_enum_inference.rs +++ b/examples/servers/src/elicitation_enum_inference.rs @@ -155,8 +155,8 @@ impl ElicitationEnumFormServer { #[tool_handler(router = self.tool_router)] impl ServerHandler for ElicitationEnumFormServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::from_build_env()) .with_instructions( "Simple server demonstrating elicitation for enum selection".to_string(), diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 7a944a1..5d7f280 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -147,8 +147,8 @@ impl ElicitationServer { #[tool_handler(router = self.tool_router)] impl ServerHandler for ElicitationServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::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(), diff --git a/examples/servers/src/mrtr.rs b/examples/servers/src/mrtr.rs index ddd7ce6..b6c87b6 100644 --- a/examples/servers/src/mrtr.rs +++ b/examples/servers/src/mrtr.rs @@ -113,8 +113,8 @@ fn finish_weather_request( } impl ServerHandler for WeatherServer { - fn get_info(&self) -> InitializeResult { - let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); // MRTR requires 2026-07-28 or newer. info.protocol_version = ProtocolVersion::V_2026_07_28; info @@ -173,8 +173,8 @@ impl ServerHandler for WeatherServer { struct InteractiveClient; impl ClientHandler for InteractiveClient { - fn get_info(&self) -> InitializeRequestParams { - InitializeRequestParams::new( + fn get_info(&self) -> ClientConfig { + ClientConfig::new( ClientCapabilities::builder().enable_elicitation().build(), Implementation::new("mrtr-example-client", env!("CARGO_PKG_VERSION")), ) diff --git a/examples/servers/src/prompt_stdio.rs b/examples/servers/src/prompt_stdio.rs index f49c54b..7f018d0 100644 --- a/examples/servers/src/prompt_stdio.rs +++ b/examples/servers/src/prompt_stdio.rs @@ -363,13 +363,12 @@ impl PromptServer { #[prompt_handler(router = self.prompt_router)] impl ServerHandler for PromptServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_prompts().build()) - .with_instructions( - "This server provides various prompt templates for code review, data analysis, \ + fn get_info(&self) -> ServerConfig { + ServerConfig::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.", - ) + ) } } diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index d0a5d67..41f74c7 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -21,8 +21,8 @@ use tracing_subscriber::{self, EnvFilter}; pub struct SamplingDemoServer; impl ServerHandler for SamplingDemoServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::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. ", diff --git a/examples/servers/src/subscriptions_streamhttp.rs b/examples/servers/src/subscriptions_streamhttp.rs index 1e5910d..7615d59 100644 --- a/examples/servers/src/subscriptions_streamhttp.rs +++ b/examples/servers/src/subscriptions_streamhttp.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, time::Duration}; use rmcp::{ ErrorData, ServerHandler, - model::{InitializeResult, ProtocolVersion, ServerCapabilities, SubscriptionFilter}, + model::{ProtocolVersion, ServerCapabilities, ServerConfig, SubscriptionFilter}, service::SubscriptionContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -18,8 +18,8 @@ impl ServerHandler for SubscriptionServer { Cow::Borrowed(&[ProtocolVersion::V_2026_07_28]) } - fn get_info(&self) -> InitializeResult { - InitializeResult::new( + fn get_info(&self) -> ServerConfig { + ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() From 47fcdf4b573180af3931a90e1a18483263a5eed4 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:30:52 -0400 Subject: [PATCH 21/29] fix(auth): let url origin decide prm discovery (#1264) --- crates/rmcp/src/transport/auth.rs | 226 ++++++++++++++++++++++++++---- 1 file changed, 199 insertions(+), 27 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index c94538c..0727368 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -689,11 +689,12 @@ struct ResourceServerMetadata { /// decides what a document that is not metadata means there. #[derive(Debug, Clone, Copy)] enum ResourceMetadataUrlOrigin { - /// The server named this url in the `resource_metadata` parameter of a - /// `WWW-Authenticate` challenge. + /// The resource itself named this url in the `resource_metadata` parameter + /// of its `WWW-Authenticate` challenge. Advertised, - /// The url was derived from the base url, on the chance that the document is - /// published there. + /// The url was derived from the base url on the chance that the document is + /// published there, or named by a challenge on such a url. Either way the + /// run only guessed its way here. WellKnownGuess, } @@ -2570,7 +2571,9 @@ impl AuthorizationManager { } /// Walk the authorization servers a protected resource metadata document - /// names, keeping the first one that answers with usable metadata. + /// names, keeping the first one that answers with usable metadata. A document + /// that names servers and none of them answers is an error, not a reason to + /// go looking elsewhere; a document that names none returns `Ok(None)`. /// /// The document arrives here through `read_resource_metadata`, which is where /// it is decided to be this resource's metadata at all. @@ -2609,10 +2612,17 @@ impl AuthorizationManager { push_candidate(candidate); } - for candidate in candidates { - let candidate_url = match Url::parse(&candidate) { + if candidates.is_empty() { + debug!( + "protected resource metadata at {resource_metadata_url} names no authorization server" + ); + return Ok(None); + } + + for candidate in &candidates { + let candidate_url = match Url::parse(candidate) { Ok(url) => url, - Err(_) => match resource_metadata_url.join(&candidate) { + Err(_) => match resource_metadata_url.join(candidate) { Ok(url) => url, Err(e) => { debug!("Failed to resolve authorization server URL `{candidate}`: {e}"); @@ -2646,7 +2656,12 @@ impl AuthorizationManager { } } - Ok(None) + // Falling back to the base url would send the user somewhere the resource + // never named. + Err(AuthError::MetadataError(format!( + "protected resource metadata at {resource_metadata_url} names authorization servers {}, but none published usable metadata", + candidates.join(", ") + ))) } fn validate_resource_metadata_resource( @@ -2762,23 +2777,25 @@ impl AuthorizationManager { } } StatusCode::UNAUTHORIZED => { - let Some(advertised_url) = self + let Some(pointer_url) = self .extract_resource_metadata_url_from_www_authenticate(&response) .await else { continue; }; - if !requested.insert(advertised_url.clone()) { + if !requested.insert(pointer_url.clone()) { continue; } + // Found on a url this run guessed at, so a wrong answer only rules + // out the pointer. if let Some(metadata) = self .fetch_resource_metadata_from_url( - &advertised_url, - ResourceMetadataUrlOrigin::Advertised, + &pointer_url, + ResourceMetadataUrlOrigin::WellKnownGuess, ) .await? { - return Ok(Some((advertised_url, metadata))); + return Ok(Some((pointer_url, metadata))); } } status => debug!("resource metadata probe returned unexpected status: {status}"), @@ -5496,8 +5513,8 @@ mod tests { }), ), ]; - // the authorization server publishes no metadata, so every form of its - // discovery url is tried before the run settles on the legacy endpoints + // the authorization server publishes no metadata; more 404s than the walk + // can use, so that requesting it twice would show up in the sequence responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); let client = RecordingOAuthHttpClient::with_responses(responses); let recorder = client.clone(); @@ -5508,24 +5525,179 @@ mod tests { .await .unwrap(); + manager.resolve_metadata().await.unwrap_err(); + + let requests = recorder.requests(); + let requested: Vec<_> = requests + .iter() + .map(|request| request.uri.as_str()) + .collect(); + assert_eq!( + requested, + [ + "https://mcp.example.com/", + "https://mcp.example.com/.well-known/oauth-protected-resource", + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + ] + ); + } + + #[tokio::test] + async fn resolve_metadata_reports_named_authorization_servers_that_publish_no_metadata() { + let mut responses = vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the document names two authorization servers + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com", "https://sso.example.com"] + }), + ), + ]; + // neither publishes metadata; the surplus 404s would let the run reach the + // resource host's own well-known urls and the legacy endpoints if it kept going + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let error = manager.resolve_metadata().await.unwrap_err(); + + assert_eq!( + error.to_string(), + "Metadata error: protected resource metadata at https://mcp.example.com/.well-known/oauth-protected-resource names authorization servers https://auth.example.com, https://sso.example.com, but none published usable metadata" + ); + let requests = recorder.requests(); + let requested: Vec<_> = requests + .iter() + .map(|request| request.uri.as_str()) + .collect(); + assert_eq!( + requested, + [ + "https://mcp.example.com/", + "https://mcp.example.com/.well-known/oauth-protected-resource", + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + "https://sso.example.com/.well-known/oauth-authorization-server", + "https://sso.example.com/.well-known/openid-configuration", + ] + ); + } + + #[tokio::test] + async fn resolve_metadata_keeps_falling_back_when_the_document_names_no_authorization_server() { + let mut responses = vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the document says nothing about where to authorize + http_response( + 200, + serde_json::json!({"resource": "https://mcp.example.com/"}), + ), + ]; + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); + let client = RecordingOAuthHttpClient::with_responses(responses); + 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, AuthorizationMetadataSource::LegacyEndpointFallback ); - let discovery_requests = recorder - .requests() - .iter() - .filter(|request| { - request.uri == "https://auth.example.com/.well-known/oauth-authorization-server" - }) - .count(); + } + + #[tokio::test] + async fn resolve_metadata_tries_the_next_candidate_past_a_pointer_found_while_guessing() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/prm""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // auth sits in front of the first candidate and its challenge names a url + challenge, + // which serves something that is not the document + http_response(200, serde_json::json!({})), + // the second candidate carries the real document + 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 recorder = client.clone(); + 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!( - discovery_requests, - 1, - "the authorization server was walked once per field naming it: {:?}", - recorder.requests() + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + let requests = recorder.requests(); + let requested: Vec<_> = requests + .iter() + .map(|request| request.uri.as_str()) + .collect(); + assert_eq!( + requested, + [ + "https://mcp.example.com/mcp", + "https://mcp.example.com/.well-known/oauth-protected-resource/mcp", + "https://mcp.example.com/prm", + "https://mcp.example.com/mcp/.well-known/oauth-protected-resource", + "https://auth.example.com/.well-known/oauth-authorization-server", + ] ); } From d481cb0eec09a0302e553cb1517d5b8ba076999b Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:45:05 -0400 Subject: [PATCH 22/29] fix: run first pre-init request in service loop (#1263) --- crates/rmcp/src/service.rs | 18 ++++- crates/rmcp/src/service/client.rs | 9 ++- crates/rmcp/src/service/server.rs | 37 +++++---- .../tests/test_stateless_server_requests.rs | 80 ++++++++++++++++++- 4 files changed, 121 insertions(+), 23 deletions(-) diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index b6cc5e5..916e070 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -1298,7 +1298,14 @@ where { 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) + serve_inner( + service, + transport.into_transport(), + peer, + peer_rx, + VecDeque::new(), + ct, + ) } /// Spawn a task that may hold `!Send` state when the `local` feature is active. @@ -1323,12 +1330,19 @@ where tokio::task::spawn_local(future) } +/// Run the service loop over `transport`. +/// +/// `initial_messages` are messages the caller already read from `transport` +/// (e.g. the first request of an `initialize`-less session). They are +/// dispatched by the loop, in order, before anything else is read from the +/// transport, so that their handlers run with the loop draining `peer_rx`. #[instrument(skip_all)] fn serve_inner( service: S, transport: T, peer: Peer, mut peer_rx: tokio::sync::mpsc::Receiver>, + initial_messages: VecDeque>, ct: CancellationToken, ) -> RunningService where @@ -1361,7 +1375,7 @@ where let current_span = tracing::Span::current(); let handle = spawn_service_task(async move { let mut transport = transport.into_transport(); - let mut batch_messages = VecDeque::>::new(); + let mut batch_messages = initial_messages; let mut send_task_set = tokio::task::JoinSet::::new(); let mut response_send_tasks = tokio::task::JoinSet::<()>::new(); #[derive(Debug)] diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 7a35360..4148c5b 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -827,7 +827,14 @@ where } } } - Ok(serve_inner(service, transport, peer, peer_rx, ct)) + Ok(serve_inner( + service, + transport, + peer, + peer_rx, + VecDeque::new(), + ct, + )) } /// Modern-era JSON-RPC error codes a server can return from `server/discover` diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index cff97f5..3ff229f 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -594,7 +594,7 @@ where let initialize_request = match request { ClientRequest::InitializeRequest(request) => request, - mut request => { + request => { let missing_metadata = request .get_meta() .missing_required_keys(&ProtocolVersion::V_2026_07_28); @@ -617,21 +617,17 @@ where } 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)); + // Dispatch the request from inside the service loop rather than + // inline: its handler may send notifications through `peer`, which + // only complete once the loop drains `peer_rx`. + return Ok(serve_inner( + service, + transport, + peer, + peer_rx, + VecDeque::from([ClientJsonRpcMessage::request(request, id)]), + ct, + )); } }; let requested_protocol_version = initialize_request.params.protocol_version.clone(); @@ -681,7 +677,14 @@ where // 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)) + Ok(serve_inner( + service, + transport, + peer, + peer_rx, + VecDeque::new(), + ct, + )) } macro_rules! method { diff --git a/crates/rmcp/tests/test_stateless_server_requests.rs b/crates/rmcp/tests/test_stateless_server_requests.rs index ae418e3..9ab302b 100644 --- a/crates/rmcp/tests/test_stateless_server_requests.rs +++ b/crates/rmcp/tests/test_stateless_server_requests.rs @@ -1,14 +1,18 @@ #![cfg(all(feature = "server", not(feature = "local")))] -use std::sync::{Arc, Mutex}; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; use rmcp::{ ServerHandler, ServiceExt, model::{ ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest, DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest, - ListToolsResult, PaginatedRequestParams, ProtocolVersion, RequestId, RequestMetaObject, - ServerJsonRpcMessage, + ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotificationParam, + ProgressToken, ProtocolVersion, RequestId, RequestMetaObject, ServerJsonRpcMessage, + ServerNotification, }, service::{MaybeSendFuture, RequestContext, RoleServer, ServerInitializeError}, transport::{IntoTransport, Transport}, @@ -212,3 +216,73 @@ async fn stateless_server_rejects_malformed_metadata_opener_with_error_response( ServerInitializeError::ExpectedInitializeRequest(Some(_)) )); } + +#[derive(Clone)] +struct ProgressServer; + +impl ServerHandler for ProgressServer { + async fn list_tools( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + let progress_token = context + .meta + .get_progress_token() + .expect("progress token in request meta"); + context + .peer + .notify_progress(ProgressNotificationParam::new(progress_token, 1.0)) + .await + .expect("send progress notification"); + Ok(ListToolsResult::default()) + } +} + +/// Regression test for issue #1261: the first request of an `initialize`-less +/// session used to be handled outside the service loop, so a handler that sent +/// a notification before returning waited forever for the loop to flush it. +#[tokio::test] +async fn stateless_server_first_request_handler_can_send_notifications() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server_task = tokio::spawn(async move { + ProgressServer + .serve(server_transport) + .await + .expect("server should start") + }); + let mut client = IntoTransport::::into_transport(client_transport); + + let mut meta = complete_meta(); + meta.set_progress_token(ProgressToken(NumberOrString::Number(7))); + client + .send(list_tools_request(meta)) + .await + .expect("send first request"); + + let exchange = async { + let notification = match client.receive().await { + Some(ServerJsonRpcMessage::Notification(notification)) => notification.notification, + other => panic!("expected progress notification before the response, got {other:?}"), + }; + assert!( + matches!(notification, ServerNotification::ProgressNotification(_)), + "expected progress notification, got {notification:?}" + ); + let response = match client.receive().await { + Some(ServerJsonRpcMessage::Response(response)) => response, + other => panic!("expected list tools response, got {other:?}"), + }; + assert_eq!(response.id, RequestId::Number(1)); + }; + tokio::time::timeout(Duration::from_secs(5), exchange) + .await + .expect("first request must not deadlock the server"); + + server_task + .await + .expect("server task") + .cancel() + .await + .expect("cancel server"); +} From b037c0fa886158fd9b09b915df866458688967e4 Mon Sep 17 00:00:00 2001 From: Auro Date: Mon, 14 Sep 2026 14:22:57 -0600 Subject: [PATCH 23/29] fix(rmcp): route peer cancellation by lifecycle (#1262) * fix(rmcp): route peer cancellation by lifecycle Cancel inbound legacy requests by their exact request ID. Preserve modern subscription waiter cleanup using the peer lifecycle and outbound request kind. Suppress cancelled handler results and errors during EOF draining without dropping uncancelled responses during graceful shutdown. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(rmcp): parameterize reverse cancellation cases Use rstest to report each startup and request ID combination independently. Keep the existing per-case timeout and cancellation assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(rmcp): use canonical initialize result in cancellation test Replace the stale ServerInfo reference after merging the upstream alias migration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Hunter Sadler Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/rmcp/src/service.rs | 73 ++- crates/rmcp/src/service/client.rs | 240 ++++++++++ crates/rmcp/tests/test_cancelled_response.rs | 445 ++++++++++++++++++- 3 files changed, 726 insertions(+), 32 deletions(-) diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 916e070..63b38e4 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -136,6 +136,14 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { None } + #[doc(hidden)] + fn peer_cancels_subscriptions(_peer: &Peer) -> bool { + false + } + #[doc(hidden)] + fn is_subscription_request(_request: &Self::Req) -> bool { + false + } /// Invalidate any response cache affected by an inbound peer notification. /// /// The serve loop calls this for every notification *before* subscription @@ -522,6 +530,10 @@ impl ProgressNotificationToken for ServerNotification { } type Responder = tokio::sync::oneshot::Sender; +struct PendingResponse { + responder: Responder, + is_subscription: bool, +} type ProgressTimeoutWatchers = Arc>>>; type SubscriptionChannel = (mpsc::Sender, usize); type SubscriptionChannelMap = HashMap>; @@ -1362,7 +1374,7 @@ where } let mut local_responder_pool = - HashMap::>>::new(); + HashMap::>>::new(); let mut local_ct_pool = HashMap::::new(); let shared_service = Arc::new(service); // for return @@ -1459,7 +1471,7 @@ where Event::SendTaskResult(SendTaskResult::Request { id, result }) => { if let Err(e) = result && let Some(responder) = local_responder_pool.remove(&id) { - let _ = responder.send(Err(ServiceError::TransportSend(e))); + let _ = responder.responder.send(Err(ServiceError::TransportSend(e))); } } Event::SendTaskResult(SendTaskResult::Notification { @@ -1477,7 +1489,7 @@ where && 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 { + let _response_result = responder.responder.send(Err(ServiceError::Cancelled { reason: param.reason.clone(), })); } @@ -1514,7 +1526,10 @@ where id, responder, }) => { - local_responder_pool.insert(id.clone(), responder); + local_responder_pool.insert(id.clone(), PendingResponse { + responder, + is_subscription: R::is_subscription_request(&request), + }); let send = transport.send(JsonRpcMessage::request(request, id.clone())); { let id = id.clone(); @@ -1615,31 +1630,38 @@ where })) => { tracing::info!(?notification, "received notification"); R::invalidate_response_cache(&peer, ¬ification).await; - let cancellation_request_id = + let subscription_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) + // Modern servers cancel listen requests; legacy peers cancel + // requests they originated, even when both directions share an ID. + if R::peer_cancels_subscriptions(&peer) { + if let Some(request_id) = request_id.as_ref() { + if local_responder_pool.get(request_id) + .is_some_and(|pending| pending.is_subscription) + && let Some(pending) = local_responder_pool.remove(request_id) { - let _ = responder.send(Err(ServiceError::Cancelled { + let _ = pending.responder.send(Err(ServiceError::Cancelled { reason: cancelled.reason.clone(), })); + } else { + tracing::debug!(%request_id, "ignoring cancellation of unknown subscription"); + continue; } - } else if let Some(ct) = local_ct_pool.remove(request_id) { + } + request_id + } else { + if let Some(request_id) = request_id.as_ref() + && let Some(ct) = local_ct_pool.remove(request_id) + { tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled"); ct.cancel(); } + None } - request_id } else { - None + notification.get_meta().subscription_id() }; - 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) @@ -1656,7 +1678,7 @@ where && let Some(responder) = local_responder_pool.remove(&subscription_id) { - let _ = responder + let _ = responder.responder .send(Err(ServiceError::SubscriptionLagged { capacity })); } peer.unregister_subscription(&subscription_id); @@ -1708,7 +1730,7 @@ where if let Some(responder) = remove_pending_request(&mut local_responder_pool, &id) { - let response_result = responder.send(Ok(result)); + let response_result = responder.responder.send(Ok(result)); if let Err(_error) = response_result { tracing::warn!(%id, "Error sending response"); } @@ -1729,7 +1751,7 @@ where } else { ServiceError::McpError(error) }; - let _response_result = responder.send(Err(service_error)); + let _response_result = responder.responder.send(Err(service_error)); if let Err(_error) = _response_result { tracing::warn!(%id, "Error sending response"); } @@ -1763,6 +1785,17 @@ where // 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 Some(id) = match &m { + JsonRpcMessage::Response(response) => Some(&response.id), + JsonRpcMessage::Error(error) => error.id.as_ref(), + _ => None, + } { + let Some(ct) = local_ct_pool.remove(id) else { + tracing::debug!(%id, "dropping response for cancelled request"); + continue; + }; + ct.cancel(); + } if let Err(error) = transport.send(m).await { tracing::error!(%error, "failed to send pending response during drain"); break; diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 4148c5b..7d0d794 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -292,6 +292,20 @@ impl ServiceRole for RoleClient { } } + fn peer_cancels_subscriptions(peer: &Peer) -> bool { + // Discovery keeps modern lifecycle semantics even with an older application version. + !super::uses_legacy_lifecycle( + peer.peer_info() + .as_deref() + .map(|info| &info.protocol_version), + peer.client_request_metadata.get().is_some(), + ) + } + + fn is_subscription_request(request: &Self::Req) -> bool { + matches!(request, ClientRequest::SubscriptionsListenRequest(_)) + } + // 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. @@ -2200,6 +2214,232 @@ where mod tests { use super::*; + #[tokio::test] + async fn server_cancellation_retires_subscription_responder() { + tokio::task::LocalSet::new() + .run_until(async { + for (discover, version) in [ + (false, ProtocolVersion::V_2026_07_28), + (true, ProtocolVersion::V_2026_07_28), + (true, ProtocolVersion::V_2025_11_25), + ] { + tokio::time::timeout( + Duration::from_secs(5), + check_subscription_cancellation(discover, version), + ) + .await + .expect("subscription cancellation timed out"); + } + }) + .await; + } + + async fn check_subscription_cancellation(discover: bool, version: ProtocolVersion) { + use crate::model::{ + GetMeta, PingRequest, ServerInfo, SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, + }; + + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = + crate::transport::IntoTransport::::into_transport(server_transport); + let info = ServerInfo { + protocol_version: version.clone(), + ..Default::default() + }; + let client = if discover { + let (client, ()) = tokio::join!( + serve_client_with_lifecycle( + (), + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![version.clone()] + } + ), + async { + let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await + else { + panic!("expected discover request"); + }; + assert!(matches!(request.request, ClientRequest::DiscoverRequest(_))); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(DiscoverResult::new( + vec![version], + info.capabilities, + )), + request.id, + )) + .await + .unwrap(); + } + ); + client.unwrap() + } else { + super::super::serve_directly::( + (), + client_transport, + Some(info.into()), + ) + }; + let filter = SubscriptionFilter::default(); + let (subscription, ()) = tokio::join!(client.listen(filter.clone()), async { + let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await else { + panic!("expected listen request"); + }; + assert!(matches!( + request.request, + ClientRequest::SubscriptionsListenRequest(_) + )); + let mut ack: ServerNotification = SubscriptionsAcknowledgedNotification::new( + SubscriptionsAcknowledgedNotificationParams::new(filter), + ) + .into(); + ack.get_meta_mut().set_subscription_id(request.id); + server + .send(ServerJsonRpcMessage::notification(ack)) + .await + .unwrap(); + }); + let mut subscription = subscription.unwrap(); + let id = subscription.id().clone(); + let mut ordinary = client + .send_cancellable_request( + PingRequest { + method: Default::default(), + extensions: Default::default(), + } + .into(), + PeerRequestOptions::no_options(), + ) + .await + .unwrap(); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Request(_)) + )); + for unknown in [ + ordinary.id.clone(), + RequestId::String(id.to_string().into()), + RequestId::String("unknown".into()), + ] { + server + .send(ServerJsonRpcMessage::notification( + CancelledNotification::new(CancelledNotificationParam::new( + Some(unknown), + None, + )) + .into(), + )) + .await + .unwrap(); + } + server + .send(ServerJsonRpcMessage::request( + PingRequest { + method: Default::default(), + extensions: Default::default(), + } + .into(), + RequestId::String("before-cancellation".into()), + )) + .await + .unwrap(); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Response(_)) + )); + assert!(matches!( + subscription.request.as_mut().unwrap().rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert!(matches!( + ordinary.rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert!(matches!( + subscription.notifications.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + server + .send(ServerJsonRpcMessage::notification( + CancelledNotification::new(CancelledNotificationParam::new( + Some(id.clone()), + Some("subscription ended".to_owned()), + )) + .into(), + )) + .await + .unwrap(); + // Ordered input makes the ping response a barrier for cancellation handling. + server + .send(ServerJsonRpcMessage::request( + PingRequest { + method: Default::default(), + extensions: Default::default(), + } + .into(), + RequestId::String("after-cancellation".into()), + )) + .await + .unwrap(); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Response(_)) + )); + assert!( + matches!( + subscription.request.as_mut().unwrap().rx.try_recv(), + Ok(Err(ServiceError::Cancelled { .. })) + ), + "the outbound responder must retire without a final response or disconnect" + ); + assert!(subscription.next().await.unwrap().is_none()); + assert!(matches!( + subscription.end(), + Some(SubscriptionEnd::Cancelled) + )); + assert!(client.peer().subscription_sender(&id).is_none()); + server + .send(ServerJsonRpcMessage::response( + ServerResult::empty(()), + ordinary.id.clone(), + )) + .await + .unwrap(); + assert!(ordinary.await_response().await.is_ok()); + + // Raw listen requests also retire, even without a registered notification channel. + let raw = client + .send_cancellable_request( + ClientRequest::SubscriptionsListenRequest(SubscriptionsListenRequest::new( + SubscriptionsListenRequestParams::new(SubscriptionFilter::default()), + )), + PeerRequestOptions::no_options(), + ) + .await + .unwrap(); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Request(_)) + )); + server + .send(ServerJsonRpcMessage::notification( + CancelledNotification::new(CancelledNotificationParam::new( + Some(raw.id.clone()), + None, + )) + .into(), + )) + .await + .unwrap(); + assert!(matches!( + raw.await_response().await, + Err(ServiceError::Cancelled { .. }) + )); + client.cancel().await.unwrap(); + } + #[tokio::test] async fn auto_startup_falls_back_when_discover_is_ignored() { use crate::model::{InitializeResult, ServerCapabilities}; diff --git a/crates/rmcp/tests/test_cancelled_response.rs b/crates/rmcp/tests/test_cancelled_response.rs index 57109ab..b264735 100644 --- a/crates/rmcp/tests/test_cancelled_response.rs +++ b/crates/rmcp/tests/test_cancelled_response.rs @@ -3,8 +3,22 @@ //! 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. +#[cfg(all(feature = "client", not(feature = "local")))] +use std::sync::Arc; use std::{collections::BTreeSet, process::Stdio, time::Duration}; +#[cfg(all(feature = "client", not(feature = "local")))] +use rmcp::{ + ClientHandler, RoleClient, + model::{ + CancelledNotification, CancelledNotificationParam, ClientJsonRpcMessage, ClientRequest, + ClientResult, ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction, + ElicitationSchema, PingRequest, RequestId, ServerJsonRpcMessage, ServerNotification, + ServerRequest, ServerResult, + }, + service::{PeerRequestOptions, QuitReason, serve_directly}, + transport::{IntoTransport, Transport}, +}; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, model::{ @@ -21,6 +35,7 @@ use tokio::{ const HELPER_ENV: &str = "RMCP_CANCELLED_RESPONSE_HELPER"; const READ_TIMEOUT: Duration = Duration::from_secs(10); +const STRING_REQUEST_ID: &str = "tool-request-2"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { @@ -43,7 +58,7 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { }), ) .await?; - collect_ids_until(&mut reader, 1, READ_TIMEOUT).await?; + collect_ids_until(&mut reader, "1", READ_TIMEOUT).await?; send_json( &mut writer, &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), @@ -56,7 +71,7 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { &mut writer, &json!({ "jsonrpc": "2.0", - "id": 2, + "id": STRING_REQUEST_ID, "method": "tools/call", "params": { "name": "wait-for-cancel", "arguments": {} } }), @@ -67,21 +82,59 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { &json!({ "jsonrpc": "2.0", "method": "notifications/cancelled", - "params": { "requestId": 2 } + "params": { "requestId": "unrelated-request" } + }), + ) + .await?; + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { "requestId": STRING_REQUEST_ID } }), ) .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. + // a cancelled 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)); + let seen = collect_ids_until(&mut reader, "3", READ_TIMEOUT).await?; + assert!(seen.contains("3")); + assert!(!seen.contains(STRING_REQUEST_ID)); + + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { "name": "complete-unless-cancelled", "arguments": {} } + }), + ) + .await?; + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { "requestId": "4" } + }), + ) + .await?; + send_json( + &mut writer, + &json!({ "jsonrpc": "2.0", "id": 5, "method": "ping" }), + ) + .await?; + + let seen = collect_ids_until(&mut reader, "5", READ_TIMEOUT).await?; + assert!(seen.contains("4"), "string and numeric IDs must not alias"); + assert!(seen.contains("5")); drop(writer); wait_for_child(&mut child).await; @@ -97,14 +150,377 @@ impl ServerHandler for WaitForCancelServer { async fn call_tool( &self, - _request: CallToolRequestParams, + request: CallToolRequestParams, context: RequestContext, ) -> Result { + if request.name == "complete-unless-cancelled" { + tokio::select! { + _ = context.ct.cancelled() => {} + _ = tokio::time::sleep(Duration::from_millis(100)) => {} + } + return Ok(CallToolResult::success(vec![ContentBlock::text("completed")]).into()); + } context.ct.cancelled().await; Ok(CallToolResult::success(vec![ContentBlock::text("late response")]).into()) } } +#[cfg(all(feature = "client", not(feature = "local")))] +#[derive(Clone)] +struct WaitForReverseCancelClient { + events: tokio::sync::mpsc::UnboundedSender<&'static str>, + finish: Option>, + return_error: bool, +} + +#[cfg(all(feature = "client", not(feature = "local")))] +impl ClientHandler for WaitForReverseCancelClient { + async fn create_elicitation( + &self, + _request: ElicitRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + self.events.send("started").expect("test is listening"); + context.ct.cancelled().await; + self.events.send("cancelled").expect("test is listening"); + if let Some(finish) = &self.finish { + finish.notified().await; + } + if self.return_error { + return Err(McpError::internal_error("late handler error", None)); + } + Ok(ElicitResult::new(ElicitationAction::Decline)) + } +} + +#[cfg(all(feature = "client", not(feature = "local")))] +#[rstest::rstest] +#[case::initialize("initialize")] +#[case::direct_legacy("direct-legacy")] +#[case::direct_unknown("direct-unknown")] +#[tokio::test] +async fn peer_cancels_reverse_request_without_cancelling_outbound_request( + #[case] startup: &str, + #[values(false, true)] equal_ids: bool, +) -> anyhow::Result<()> { + tokio::time::timeout(READ_TIMEOUT, reverse_cancellation(startup, equal_ids)).await??; + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +async fn reverse_cancellation(startup: &str, equal_ids: bool) -> anyhow::Result<()> { + use rmcp::model::ProtocolVersion; + let (client_transport, server_transport) = tokio::io::duplex(4096); + let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel(); + let handler = WaitForReverseCancelClient { + events: events_tx, + finish: None, + return_error: false, + }; + let mut server = IntoTransport::::into_transport(server_transport); + let mut info = InitializeResult::default(); + info.protocol_version = ProtocolVersion::V_2025_11_25; + let client = if startup == "initialize" { + let (client, handshake) = tokio::join!(handler.serve(client_transport), async { + let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await else { + panic!("expected initialize request"); + }; + assert!(matches!( + request.request, + ClientRequest::InitializeRequest(_) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult(info.clone()), + request.id, + )) + .await?; + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + anyhow::Ok(()) + }); + handshake?; + client? + } else { + let peer_info = (startup == "direct-legacy").then(|| info.into()); + serve_directly::(handler, client_transport, peer_info) + }; + let mut outbound = client + .send_cancellable_request( + ClientRequest::PingRequest(PingRequest { + method: Default::default(), + extensions: Default::default(), + }), + PeerRequestOptions::no_options(), + ) + .await?; + let Some(ClientJsonRpcMessage::Request(outbound_request)) = server.receive().await else { + panic!("expected outbound ping request"); + }; + let id = if equal_ids { + outbound_request.id.clone() + } else { + RequestId::String("elicitation-1".into()) + }; + let mut request = reverse_elicitation("elicitation-1"); + if let ServerJsonRpcMessage::Request(request) = &mut request { + request.id = id.clone(); + } + server.send(request).await?; + assert_eq!(events_rx.recv().await, Some("started")); + let unknown = if equal_ids { + RequestId::String(id.to_string().into()) + } else { + outbound_request.id.clone() + }; + for cancel_id in [unknown, RequestId::String("unknown".into())] { + server.send(cancel_notification(cancel_id)).await?; + } + exchange_ping(&mut server, "before-cancel").await?; + assert!(matches!( + events_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + assert!(matches!( + outbound.rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + + // A cancellation can arrive after its response was already delivered. + server + .send(cancel_notification(RequestId::String( + "before-cancel".into(), + ))) + .await?; + server.send(cancel_notification(id)).await?; + assert_eq!(events_rx.recv().await, Some("cancelled")); + exchange_ping(&mut server, "after-cancel").await?; + assert!(matches!( + outbound.rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::empty(()), + outbound_request.id, + )) + .await?; + assert!(matches!( + outbound.await_response().await?, + ServerResult::EmptyResult(_) + )); + + client.cancel().await?; + assert!( + server.receive().await.is_none(), + "no late response after handler drain" + ); + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +fn cancel_notification(id: RequestId) -> ServerJsonRpcMessage { + ServerJsonRpcMessage::notification( + CancelledNotification::new(CancelledNotificationParam::new( + Some(id), + Some("request cancelled".to_owned()), + )) + .into(), + ) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +async fn exchange_ping(server: &mut impl Transport, id: &str) -> anyhow::Result<()> { + server + .send(ServerJsonRpcMessage::request( + PingRequest { + method: Default::default(), + extensions: Default::default(), + } + .into(), + RequestId::String(id.into()), + )) + .await?; + let Some(ClientJsonRpcMessage::Response(response)) = server.receive().await else { + panic!("expected ping response"); + }; + assert_eq!(response.id, RequestId::String(id.into())); + assert!(matches!(response.result, ClientResult::EmptyResult(_))); + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +struct ChannelClientTransport { + incoming: tokio::sync::mpsc::UnboundedReceiver, + outgoing: tokio::sync::mpsc::UnboundedSender, + eof_observed: Arc, +} + +#[cfg(all(feature = "client", not(feature = "local")))] +impl Transport for ChannelClientTransport { + type Error = std::io::Error; + + fn send( + &mut self, + item: ClientJsonRpcMessage, + ) -> impl Future> + Send + 'static { + let outgoing = self.outgoing.clone(); + async move { + outgoing.send(item).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test receiver closed") + }) + } + } + + async fn receive(&mut self) -> Option { + let message = self.incoming.recv().await; + if message.is_none() { + self.eof_observed.notify_one(); + } + message + } + + async fn close(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[cfg(all(feature = "client", not(feature = "local")))] +fn reverse_elicitation(id: &str) -> ServerJsonRpcMessage { + ServerJsonRpcMessage::request( + ServerRequest::ElicitRequest(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Continue the operation?".to_owned(), + requested_schema: ElicitationSchema::builder() + .build() + .expect("empty elicitation schema is valid"), + }, + )), + RequestId::String(id.into()), + ) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +#[tokio::test] +async fn graceful_close_drains_uncancelled_reverse_response() -> anyhow::Result<()> { + const REVERSE_REQUEST_ID: &str = "elicitation-close-drain"; + + let (to_client, incoming) = tokio::sync::mpsc::unbounded_channel(); + let (outgoing, mut from_client) = tokio::sync::mpsc::unbounded_channel(); + let eof_observed = Arc::new(tokio::sync::Notify::new()); + let finish = Arc::new(tokio::sync::Notify::new()); + let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut client = serve_directly::( + WaitForReverseCancelClient { + events: events_tx, + finish: Some(finish.clone()), + return_error: false, + }, + ChannelClientTransport { + incoming, + outgoing, + eof_observed, + }, + None, + ); + + to_client.send(reverse_elicitation(REVERSE_REQUEST_ID))?; + assert_eq!(events_rx.recv().await, Some("started")); + + let (close_result, cancellation_event) = tokio::join!(client.close(), async { + let event = events_rx.recv().await; + finish.notify_one(); + event + }); + assert!(matches!(close_result?, QuitReason::Cancelled)); + assert_eq!(cancellation_event, Some("cancelled")); + + let Some(ClientJsonRpcMessage::Response(response)) = + tokio::time::timeout(READ_TIMEOUT, from_client.recv()).await? + else { + panic!("expected in-flight response during graceful close"); + }; + assert_eq!(response.id, RequestId::String(REVERSE_REQUEST_ID.into())); + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +#[tokio::test] +async fn cancelled_reverse_request_stays_suppressed_during_eof_drain() -> anyhow::Result<()> { + for return_error in [false, true] { + tokio::time::timeout( + READ_TIMEOUT, + cancelled_reverse_response_at_eof(return_error), + ) + .await??; + } + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +async fn cancelled_reverse_response_at_eof(return_error: bool) -> anyhow::Result<()> { + const REVERSE_REQUEST_ID: &str = "elicitation-before-eof"; + + let (to_client, incoming) = tokio::sync::mpsc::unbounded_channel(); + let (outgoing, mut from_client) = tokio::sync::mpsc::unbounded_channel(); + let eof_observed = Arc::new(tokio::sync::Notify::new()); + let finish = Arc::new(tokio::sync::Notify::new()); + let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel(); + let client = serve_directly::( + WaitForReverseCancelClient { + events: events_tx, + finish: Some(finish.clone()), + return_error, + }, + ChannelClientTransport { + incoming, + outgoing, + eof_observed: eof_observed.clone(), + }, + None, + ); + + to_client.send(reverse_elicitation(REVERSE_REQUEST_ID))?; + assert_eq!( + tokio::time::timeout(READ_TIMEOUT, events_rx.recv()).await?, + Some("started") + ); + + to_client.send(ServerJsonRpcMessage::notification( + ServerNotification::CancelledNotification(CancelledNotification::new( + CancelledNotificationParam::new( + Some(RequestId::String(REVERSE_REQUEST_ID.into())), + Some("user cancelled".to_owned()), + ), + )), + ))?; + assert_eq!( + tokio::time::timeout(READ_TIMEOUT, events_rx.recv()).await?, + Some("cancelled") + ); + + let eof = eof_observed.notified(); + drop(to_client); + tokio::time::timeout(READ_TIMEOUT, eof).await?; + finish.notify_one(); + + assert!( + matches!( + tokio::time::timeout(READ_TIMEOUT, client.waiting()).await??, + QuitReason::Closed + ), + "client must close after input EOF" + ); + assert!( + from_client.recv().await.is_none(), + "cancelled reverse request must not be sent during EOF drain (return_error={return_error})" + ); + Ok(()) +} + #[tokio::test] async fn cancelled_response_helper() -> anyhow::Result<()> { if std::env::var(HELPER_ENV).as_deref() != Ok("1") { @@ -172,9 +588,9 @@ where /// (then a short grace read to catch any straggler) or the timeout elapses. async fn collect_ids_until( reader: &mut BufReader, - stop_id: u64, + stop_id: &str, timeout: Duration, -) -> anyhow::Result> +) -> anyhow::Result> where R: tokio::io::AsyncRead + Unpin, { @@ -200,8 +616,13 @@ where 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); + let id = match value.get("id") { + Some(Value::String(id)) => Some(id.clone()), + Some(Value::Number(id)) => Some(id.to_string()), + _ => None, + }; + if let Some(id) = id { + seen.insert(id.clone()); if id == stop_id { // Give any late (incorrectly-sent) response a brief window to arrive. deadline = tokio::time::Instant::now() + Duration::from_millis(300); From 0550e13c1bbb704790f2c52482edb5752a8f1758 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:18:33 -0400 Subject: [PATCH 24/29] fix(rmcp): use ServerConfig in cancellation test (#1273) --- crates/rmcp/tests/test_cancelled_response.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/tests/test_cancelled_response.rs b/crates/rmcp/tests/test_cancelled_response.rs index b264735..864932a 100644 --- a/crates/rmcp/tests/test_cancelled_response.rs +++ b/crates/rmcp/tests/test_cancelled_response.rs @@ -218,7 +218,7 @@ async fn reverse_cancellation(startup: &str, equal_ids: bool) -> anyhow::Result< return_error: false, }; let mut server = IntoTransport::::into_transport(server_transport); - let mut info = InitializeResult::default(); + let mut info = ServerConfig::default(); info.protocol_version = ProtocolVersion::V_2025_11_25; let client = if startup == "initialize" { let (client, handshake) = tokio::join!(handler.serve(client_transport), async { From fd7811fdaa9fefa1c8034534b4d7a31c97204f89 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:37:39 -0400 Subject: [PATCH 25/29] chore: release v3.4.0 (#1258) 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 | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 261b77e..0629e82 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.3.0", path = "./crates/rmcp" } -rmcp-macros = { version = "3.3.0", path = "./crates/rmcp-macros" } +rmcp = { version = "3.4.0", path = "./crates/rmcp" } +rmcp-macros = { version = "3.4.0", path = "./crates/rmcp-macros" } [workspace.package] edition = "2024" rust-version = "1.88" -version = "3.3.0" +version = "3.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 67154ba..6e35cf3 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.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.3.0...rmcp-macros-v3.4.0) - 2026-09-15 + +### Added + +- *(model)* add ServerConfig and ClientConfig ([#1266](https://github.com/modelcontextprotocol/rust-sdk/pull/1266)) + +### Fixed + +- *(model)* deprecate ServerInfo and ClientInfo aliases ([#1156](https://github.com/modelcontextprotocol/rust-sdk/pull/1156)) + ## [3.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.2.0...rmcp-macros-v3.3.0) - 2026-09-10 ### Added diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 5465cf2..a3e4ab7 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.3.0...rmcp-v3.4.0) - 2026-09-15 + +### Added + +- *(model)* add ServerConfig and ClientConfig ([#1266](https://github.com/modelcontextprotocol/rust-sdk/pull/1266)) + +### Fixed + +- *(rmcp)* use ServerConfig in cancellation test ([#1273](https://github.com/modelcontextprotocol/rust-sdk/pull/1273)) +- *(rmcp)* route peer cancellation by lifecycle ([#1262](https://github.com/modelcontextprotocol/rust-sdk/pull/1262)) +- run first pre-init request in service loop ([#1263](https://github.com/modelcontextprotocol/rust-sdk/pull/1263)) +- *(auth)* let url origin decide prm discovery ([#1264](https://github.com/modelcontextprotocol/rust-sdk/pull/1264)) +- *(model)* deprecate ServerInfo and ClientInfo aliases ([#1156](https://github.com/modelcontextprotocol/rust-sdk/pull/1156)) +- *(auth)* ignore non-metadata JSON when probing for protected resource metadata ([#1204](https://github.com/modelcontextprotocol/rust-sdk/pull/1204)) +- do not treat malformed JSON 200 as Accepted for requests ([#1208](https://github.com/modelcontextprotocol/rust-sdk/pull/1208)) +- *(streamable-http-server)* map handler-generated HeaderMismatch to HTTP 400 ([#1259](https://github.com/modelcontextprotocol/rust-sdk/pull/1259)) +- *(http)* enforce Origin validation semantics ([#1192](https://github.com/modelcontextprotocol/rust-sdk/pull/1192)) + ## [3.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.2.0...rmcp-v3.3.0) - 2026-09-10 ### Added From 0cfbe7f453363aa07ae6597bdf473eeeb89dc4cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:46:37 -0400 Subject: [PATCH 26/29] chore(deps): bump taiki-e/install-action from 2.87.6 to 2.87.12 (#1279) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.87.6 to 2.87.12. - [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/7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c...3f74d7c16a4242f1c95561e98edc25d36adb4375) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.12 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 1edfe69..c0c03b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Install cargo-semver-checks - uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6 + uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 with: tool: cargo-semver-checks @@ -139,7 +139,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Install cargo-public-api - uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6 + uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 with: tool: cargo-public-api From 011f281cedf2502e3b3a456bd4281a02d22c64b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:46:56 -0400 Subject: [PATCH 27/29] chore(deps): bump astral-sh/setup-uv from 10.0.1 to 10.1.0 (#1278) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 10.0.1 to 10.1.0. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/20cfd1bf945f4377ade1205e4dbc17946fc9a30d...bec219d24cd3e171d82865faccec33120bb574f4) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.1.0 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 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0c03b8..7ffafe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,7 +251,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @@ -280,7 +280,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @@ -316,7 +316,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @@ -351,7 +351,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable From 9f7ace357a138d9ee69cb6ff6e0bbf32e46e3eae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:47:13 -0400 Subject: [PATCH 28/29] chore(deps): bump the codeql-action group with 4 updates (#1277) Bumps the codeql-action group with 4 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/autobuild](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.9 to 4.38.0 - [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/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63) Updates `github/codeql-action/autobuild` from 4.37.9 to 4.38.0 - [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/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63) Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0 - [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/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63) Updates `github/codeql-action/upload-sarif` from 4.37.3 to 4.38.0 - [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/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...b96794f015dfd88f77b49b1c93e0fa7110f94c63) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: codeql-action - dependency-name: github/codeql-action/autobuild dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: codeql-action - dependency-name: github/codeql-action/analyze dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: codeql-action - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: codeql-action ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/scorecard.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 83e1ce4..aa77b0a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,13 +27,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 1369d73..453c0c5 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -43,6 +43,6 @@ jobs: retention-days: 5 - name: Upload to code scanning - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: sarif_file: results.sarif From dbd238275534c3a8da4d91b7220655e878216988 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:47:35 -0400 Subject: [PATCH 29/29] chore(deps): update rstest requirement from 0.26.1 to 0.27.0 (#1276) Updates the requirements on [rstest](https://github.com/la10736/rstest) to permit the latest version. - [Release notes](https://github.com/la10736/rstest/releases) - [Changelog](https://github.com/la10736/rstest/blob/master/CHANGELOG.md) - [Commits](https://github.com/la10736/rstest/compare/v0.26.1...v0.27.0) --- updated-dependencies: - dependency-name: rstest dependency-version: 0.27.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 0d00d14..ac698f9 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -215,7 +215,7 @@ tracing-subscriber = { version = "0.3", features = [ "fmt", ] } async-trait = "0.1" -rstest = "0.26.1" +rstest = "0.27.0" [[test]] name = "test_tool_macros" required-features = ["server", "client"]