diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ffafe1..1edfe69 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@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 + uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6 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@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 + uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6 with: tool: cargo-public-api @@ -251,7 +251,7 @@ jobs: node-version: '22' - name: Install uv - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aa77b0a..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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + 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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml deleted file mode 100644 index 453c0c5..0000000 --- a/.github/workflows/scorecard.yml +++ /dev/null @@ -1,48 +0,0 @@ -# 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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 - with: - sarif_file: results.sarif diff --git a/Cargo.toml b/Cargo.toml index 0629e82..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.4.0", path = "./crates/rmcp" } -rmcp-macros = { version = "3.4.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.4.0" +version = "3.3.0" authors = ["4t145 "] license = "Apache-2.0" repository = "https://github.com/modelcontextprotocol/rust-sdk/" diff --git a/README.md b/README.md index 7b24d6a..390f96c 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, ProtocolVersion, model::ClientConfig}; +use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion}; // Start directly with server/discover and include client metadata on every request. -let client = ClientConfig::default() +let client = ClientInfo::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -113,7 +113,7 @@ let client = ClientConfig::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 = ClientConfig::default() +let client = ClientInfo::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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_resources() .build(), @@ -582,8 +582,8 @@ impl MyServer { #[prompt_handler] impl ServerHandler for MyServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_prompts().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_completions() .enable_prompts() @@ -1253,8 +1253,8 @@ use rmcp::{ }; impl ServerHandler for MyServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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 = ClientConfig::default().serve(transport).await?; +let client = ClientInfo::default().serve(transport).await?; ``` **Example:** [`examples/servers/src/counter_streamhttp.rs`](examples/servers/src/counter_streamhttp.rs) (server), [`examples/clients/src/streamable_http.rs`](examples/clients/src/streamable_http.rs) (client) @@ -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 = ClientConfig::default().serve(transport).await?; +let client = ClientInfo::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, ServerConfig}; +use rmcp::model::{ServerCapabilities, ServerInfo}; -fn get_info(&self) -> ServerConfig { - ServerConfig::new( +fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_prompts() diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index aef2c27..92e4a94 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) -> ClientConfig { - let mut info = ClientConfig::default(); + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::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) -> ClientConfig { - let mut info = ClientConfig::default(); + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::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 5c4b5c8..0d2baf4 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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_prompts() .enable_prompts_list_changed() @@ -1744,9 +1744,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting conformance server on {}", bind_addr); let server = ConformanceServer::new(); - let config = StreamableHttpServerConfig::default() - .with_allowed_origins([format!("http://{bind_addr}")]) - .enforce_origin_validation(); + let config = StreamableHttpServerConfig::default(); let service = StreamableHttpService::new( move || Ok(server.clone()), LocalSessionManager::default().into(), diff --git a/crates/rmcp-macros/CHANGELOG.md b/crates/rmcp-macros/CHANGELOG.md index 6e35cf3..67154ba 100644 --- a/crates/rmcp-macros/CHANGELOG.md +++ b/crates/rmcp-macros/CHANGELOG.md @@ -7,16 +7,6 @@ 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-macros/src/lib.rs b/crates/rmcp-macros/src/lib.rs index 30433a8..176ead7 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) -> ServerConfig { -/// ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) +/// fn get_info(&self) -> ServerInfo { +/// ServerInfo::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 c9f4a25..e274c10 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 `ServerConfig` with the appropriate capabilities. +/// Build a `get_info()` method that returns `ServerInfo` with the appropriate capabilities. /// /// The caller declares its own capability via `caller`. Sibling handler attributes /// (`prompt_handler`, `tool_handler`) are detected automatically @@ -157,8 +157,8 @@ pub(crate) fn build_get_info( } syn::parse2::(quote! { - fn get_info(&self) -> rmcp::model::ServerConfig { - rmcp::model::ServerConfig::new( + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo::new( rmcp::model::ServerCapabilities::builder() #(#capability_calls)* .build() diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index a3e4ab7..5465cf2 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,24 +7,6 @@ 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 diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index ac698f9..ac51ebf 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.27.0" +rstest = "0.26.1" [[test]] name = "test_tool_macros" required-features = ["server", "client"] @@ -348,15 +348,6 @@ 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/handler/client.rs b/crates/rmcp/src/handler/client.rs index 486fa3b..4143870 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) -> ClientConfig { - ClientConfig::default() + fn get_info(&self) -> ClientInfo { + ClientInfo::default() } }; } @@ -292,12 +292,12 @@ pub trait ClientHandler: Sized + 'static { client_handler_methods!(); } -/// Do nothing, with the default client config. +/// Do nothing, with default client info. impl ClientHandler for () {} -/// Do nothing, with a specific client config. -impl ClientHandler for ClientConfig { - fn get_info(&self) -> ClientConfig { +/// Do nothing, with a specific client info. +impl ClientHandler for ClientInfo { + fn get_info(&self) -> ClientInfo { self.clone() } } @@ -422,7 +422,7 @@ macro_rules! impl_client_handler_for_wrapper { (**self).on_custom_notification(notification, context) } - fn get_info(&self) -> ClientConfig { + fn get_info(&self) -> ClientInfo { (**self).get_info() } } diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index f846724..7098552 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, ServerConfig}, + /// model::{InitializeRequestParams, InitializeResult, ServerInfo}, /// service::RequestContext, /// }; /// /// struct MyServer; /// /// impl ServerHandler for MyServer { - /// fn get_info(&self) -> ServerConfig { - /// ServerConfig::default() + /// fn get_info(&self) -> ServerInfo { + /// ServerInfo::default() /// } /// /// async fn initialize( @@ -600,8 +600,8 @@ macro_rules! server_handler_methods { std::future::ready(()) } - fn get_info(&self) -> ServerConfig { - ServerConfig::default() + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ServerConfig { + fn get_info(&self) -> ServerInfo { (**self).get_info() } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index ec7b93b..be911f7 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1119,58 +1119,7 @@ impl InitializeResult { } } -/// A server's own configuration: the protocol version, capabilities, -/// implementation identity, and instructions it advertises to clients. -/// -/// 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; - -/// Deprecated alias for [`ClientConfig`]. -/// -/// 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. @@ -1330,18 +1279,18 @@ impl DiscoverResult { self } - /// Create a discovery result from the server's configuration. + /// Create a discovery result from the server's initialization information. pub fn from_server_info( supported_versions: Vec, - server_config: ServerConfig, + server_info: ServerInfo, ) -> Self { - let ServerConfig { + let ServerInfo { capabilities, server_info, instructions, meta, .. - } = server_config; + } = server_info; let mut result = Self { result_type: ResultType::COMPLETE, supported_versions, @@ -1383,9 +1332,9 @@ impl ServerPeerInfo { } #[allow(clippy::derivable_impls)] -impl Default for InitializeResult { +impl Default for ServerInfo { fn default() -> Self { - InitializeResult { + ServerInfo { protocol_version: ProtocolVersion::default(), capabilities: ServerCapabilities::default(), server_info: Implementation::from_build_env(), @@ -1396,9 +1345,9 @@ impl Default for InitializeResult { } #[allow(clippy::derivable_impls)] -impl Default for InitializeRequestParams { +impl Default for ClientInfo { fn default() -> Self { - InitializeRequestParams { + ClientInfo { meta: None, protocol_version: ProtocolVersion::default(), capabilities: ClientCapabilities::default(), diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index 63b38e4..b6cc5e5 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -136,14 +136,6 @@ 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 @@ -530,10 +522,6 @@ 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>; @@ -1310,14 +1298,7 @@ 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, - VecDeque::new(), - ct, - ) + serve_inner(service, transport.into_transport(), peer, peer_rx, ct) } /// Spawn a task that may hold `!Send` state when the `local` feature is active. @@ -1342,19 +1323,12 @@ 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 @@ -1374,7 +1348,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 @@ -1387,7 +1361,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 = initial_messages; + let mut batch_messages = VecDeque::>::new(); let mut send_task_set = tokio::task::JoinSet::::new(); let mut response_send_tasks = tokio::task::JoinSet::<()>::new(); #[derive(Debug)] @@ -1471,7 +1445,7 @@ where Event::SendTaskResult(SendTaskResult::Request { id, result }) => { if let Err(e) = result && let Some(responder) = local_responder_pool.remove(&id) { - let _ = responder.responder.send(Err(ServiceError::TransportSend(e))); + let _ = responder.send(Err(ServiceError::TransportSend(e))); } } Event::SendTaskResult(SendTaskResult::Notification { @@ -1489,7 +1463,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.responder.send(Err(ServiceError::Cancelled { + let _response_result = responder.send(Err(ServiceError::Cancelled { reason: param.reason.clone(), })); } @@ -1526,10 +1500,7 @@ where id, responder, }) => { - local_responder_pool.insert(id.clone(), PendingResponse { - responder, - is_subscription: R::is_subscription_request(&request), - }); + local_responder_pool.insert(id.clone(), responder); let send = transport.send(JsonRpcMessage::request(request, id.clone())); { let id = id.clone(); @@ -1630,38 +1601,31 @@ where })) => { tracing::info!(?notification, "received notification"); R::invalidate_response_cache(&peer, ¬ification).await; - let subscription_id = + let cancellation_request_id = if let Some(cancelled) = R::peer_cancelled_params(¬ification) { let request_id = cancelled.request_id.clone(); - // 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) + if let Some(request_id) = request_id.as_ref() { + if R::IS_CLIENT { + if let Some(responder) = + local_responder_pool.remove(request_id) { - let _ = pending.responder.send(Err(ServiceError::Cancelled { + let _ = responder.send(Err(ServiceError::Cancelled { reason: cancelled.reason.clone(), })); - } else { - tracing::debug!(%request_id, "ignoring cancellation of unknown subscription"); - continue; } - } - request_id - } else { - if let Some(request_id) = request_id.as_ref() - && let Some(ct) = local_ct_pool.remove(request_id) - { + } else if let Some(ct) = local_ct_pool.remove(request_id) { tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled"); ct.cancel(); } - None } + request_id } else { - notification.get_meta().subscription_id() + None }; + let subscription_id = notification + .get_meta() + .subscription_id() + .or(cancellation_request_id); if let Some(subscription_id) = subscription_id && let Some((sender, capacity)) = peer.subscription_sender(&subscription_id) @@ -1678,7 +1642,7 @@ where && let Some(responder) = local_responder_pool.remove(&subscription_id) { - let _ = responder.responder + let _ = responder .send(Err(ServiceError::SubscriptionLagged { capacity })); } peer.unregister_subscription(&subscription_id); @@ -1730,7 +1694,7 @@ where if let Some(responder) = remove_pending_request(&mut local_responder_pool, &id) { - let response_result = responder.responder.send(Ok(result)); + let response_result = responder.send(Ok(result)); if let Err(_error) = response_result { tracing::warn!(%id, "Error sending response"); } @@ -1751,7 +1715,7 @@ where } else { ServiceError::McpError(error) }; - let _response_result = responder.responder.send(Err(service_error)); + let _response_result = responder.send(Err(service_error)); if let Err(_error) = _response_result { tracing::warn!(%id, "Error sending response"); } @@ -1785,17 +1749,6 @@ 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 7d0d794..520410f 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, ClientConfig, ClientJsonRpcMessage, ClientNotification, + CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CompleteRequest, CompleteRequestParams, CompleteResult, CompletionContext, CompletionInfo, DEFAULT_MRTR_MAX_ROUNDS, DiscoverRequest, DiscoverRequestParams, DiscoverResult, ErrorData, GetExtensions, GetMeta, GetPromptRequest, GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetTaskParams, GetTaskRequest, - GetTaskResult, InitializeRequest, InitializeRequestParams, InitializedNotification, - InputRequest, InputRequiredResult, InputResponses, JsonRpcResponse, ListPromptsRequest, + GetTaskResult, InitializeRequest, 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 = ClientConfig; + type Info = ClientInfo; type PeerInfo = ServerPeerInfo; type InitializeError = ClientInitializeError; const IS_CLIENT: bool = true; @@ -292,20 +292,6 @@ 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. @@ -841,14 +827,7 @@ where } } } - Ok(serve_inner( - service, - transport, - peer, - peer_rx, - VecDeque::new(), - ct, - )) + Ok(serve_inner(service, transport, peer, peer_rx, ct)) } /// Modern-era JSON-RPC error codes a server can return from `server/discover` @@ -891,7 +870,7 @@ async fn legacy_startup( transport: &mut T, id_provider: &Arc, peer: &Peer, - client_info: InitializeRequestParams, + client_info: ClientInfo, ) -> Result<(), ClientInitializeError> where S: Service, @@ -940,7 +919,7 @@ async fn discover_startup( transport: &mut T, id_provider: &Arc, peer: &Peer, - client_info: &InitializeRequestParams, + client_info: &ClientInfo, preferred_versions: Vec, ) -> Result where @@ -2214,232 +2193,6 @@ 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/src/service/server.rs b/crates/rmcp/src/service/server.rs index 3ff229f..29e4690 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -13,16 +13,15 @@ use super::*; use crate::model::{ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction}; use crate::{ model::{ - CancelledNotification, CancelledNotificationParam, ClientJsonRpcMessage, + CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification, ClientRequest, ClientResult, CreateMessageRequest, - CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, - InitializeRequestParams, ListRootsRequest, ListRootsResult, LoggingMessageNotification, - LoggingMessageNotificationParam, ProgressNotification, ProgressNotificationParam, - PromptListChangedNotification, ProtocolVersion, ResourceListChangedNotification, - ResourceUpdatedNotification, ResourceUpdatedNotificationParam, ServerConfig, - ServerNotification, ServerRequest, ServerResult, SubscriptionFilter, - SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, - ToolListChangedNotification, + CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, ListRootsRequest, + ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam, + ProgressNotification, ProgressNotificationParam, PromptListChangedNotification, + ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification, + ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest, + ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification, }, transport::DynamicTransportError, }; @@ -38,8 +37,8 @@ impl ServiceRole for RoleServer { type PeerReq = ClientRequest; type PeerResp = ClientResult; type PeerNot = ClientNotification; - type Info = ServerConfig; - type PeerInfo = InitializeRequestParams; + type Info = ServerInfo; + type PeerInfo = ClientInfo; type InitializeError = ServerInitializeError; const IS_CLIENT: bool = false; @@ -594,7 +593,7 @@ where let initialize_request = match request { ClientRequest::InitializeRequest(request) => request, - request => { + mut request => { let missing_metadata = request .get_meta() .missing_required_keys(&ProtocolVersion::V_2026_07_28); @@ -617,17 +616,21 @@ where } let (peer, peer_rx) = Peer::new(id_provider, None); peer.require_request_metadata(); - // 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 context = RequestContext { + ct: ct.child_token(), + id: id.clone(), + meta: std::mem::take(request.get_meta_mut()), + extensions: std::mem::take(request.extensions_mut()), + peer: peer.clone(), + }; + let response = match service.handle_request(request, context).await { + Ok(result) => ServerJsonRpcMessage::response(result, id), + Err(error) => ServerJsonRpcMessage::error(error, Some(id)), + }; + transport.send(response).await.map_err(|error| { + ServerInitializeError::transport::(error, "sending negotiated request response") + })?; + return Ok(serve_inner(service, transport, peer, peer_rx, ct)); } }; let requested_protocol_version = initialize_request.params.protocol_version.clone(); @@ -677,14 +680,7 @@ 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, - VecDeque::new(), - ct, - )) + Ok(serve_inner(service, transport, peer, peer_rx, ct)) } macro_rules! method { diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 0727368..759044b 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, future::Future, net::{IpAddr, Ipv4Addr, Ipv6Addr}, pin::Pin, @@ -685,19 +685,6 @@ 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 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, or named by a challenge on such a url. Either way the - /// run only guessed its way here. - WellKnownGuess, -} - /// Parameters extracted from WWW-Authenticate header #[derive(Debug, Clone, Default)] #[non_exhaustive] @@ -2538,50 +2525,26 @@ impl AuthorizationManager { async fn discover_oauth_server_via_resource_metadata( &self, ) -> Result, AuthError> { - let Some((resource_metadata_url, resource_metadata)) = - self.discover_resource_metadata().await? - else { + let Some(resource_metadata_url) = self.discover_resource_metadata_url().await? else { return Ok(None); }; - self.authorization_metadata_from_resource_metadata( - &resource_metadata_url, - resource_metadata, - ) - .await + self.discover_oauth_server_from_resource_metadata_url(&resource_metadata_url) + .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, - ResourceMetadataUrlOrigin::Advertised, - ) + .fetch_resource_metadata_from_url(resource_metadata_url) .await? else { return Ok(None); }; - self.authorization_metadata_from_resource_metadata(resource_metadata_url, resource_metadata) - .await - } + self.validate_resource_metadata_resource(&resource_metadata)?; - /// Walk the authorization servers a protected resource metadata document - /// 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. - async fn authorization_metadata_from_resource_metadata( - &self, - resource_metadata_url: &Url, - resource_metadata: ResourceServerMetadata, - ) -> Result, AuthError> { self.discovered_resource .write() .await @@ -2594,32 +2557,21 @@ impl AuthorizationManager { *self.resource_scopes.write().await = scopes; } - // 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()); - } - }; + let mut candidates = Vec::new(); if let Some(single) = resource_metadata.authorization_server { - push_candidate(single); + candidates.push(single); } - for candidate in resource_metadata.authorization_servers.unwrap_or_default() { - push_candidate(candidate); + if let Some(list) = resource_metadata.authorization_servers { + candidates.extend(list); } - 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 = candidate.trim(); + if candidate.is_empty() { + continue; + } - for candidate in &candidates { let candidate_url = match Url::parse(candidate) { Ok(url) => url, Err(_) => match resource_metadata_url.join(candidate) { @@ -2656,12 +2608,7 @@ impl AuthorizationManager { } } - // 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(", ") - ))) + Ok(None) } fn validate_resource_metadata_resource( @@ -2721,115 +2668,52 @@ impl AuthorizationManager { || expected_path.as_bytes().get(actual_path.len()) == Some(&b'/')) } - /// 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. + 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)); } - // The other place the document can be is the well-known location. + // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. // 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 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(pointer_url) = self - .extract_resource_metadata_url_from_www_authenticate(&response) - .await - else { - continue; - }; - 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( - &pointer_url, - ResourceMetadataUrlOrigin::WellKnownGuess, - ) - .await? - { - return Ok(Some((pointer_url, metadata))); - } - } - status => debug!("resource metadata probe returned unexpected status: {status}"), + 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)); } } Ok(None) } - /// 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. + /// Probe `url` with a GET, extracting the resource metadata url from a + /// 200 (the url itself is the metadata document) or from a 401's + /// WWW-Authenticate header value. /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for - async fn probe_resource_endpoint_for_challenge(&self) -> Result, AuthError> { + async fn probe_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { let response = self - .discovery_get(&self.base_url) + .discovery_get(url) .await - .map_err(|error| Self::discovery_failed(&self.base_url, error))?; + .map_err(|error| Self::discovery_failed(url, error))?; - if response.status() == StatusCode::UNAUTHORIZED { - return Ok(self + match response.status() { + StatusCode::OK => Ok(Some(url.clone())), + StatusCode::UNAUTHORIZED => Ok(self .extract_resource_metadata_url_from_www_authenticate(&response) - .await); + .await), + status => { + debug!("resource metadata probe returned unexpected status: {status}"); + Ok(None) + } } - - debug!( - "resource endpoint probe returned {}, no WWW-Authenticate pointer to follow", - response.status() - ); - Ok(None) } async fn extract_resource_metadata_url_from_www_authenticate( @@ -2855,7 +2739,6 @@ impl AuthorizationManager { async fn fetch_resource_metadata_from_url( &self, resource_metadata_url: &Url, - origin: ResourceMetadataUrlOrigin, ) -> Result, AuthError> { debug!( "resource metadata discovery url: {:?}", @@ -2874,69 +2757,13 @@ impl AuthorizationManager { return Ok(None); } - 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) { + let metadata = match serde_json::from_slice::(response.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)) } @@ -4614,6 +4441,13 @@ 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!({ @@ -4648,6 +4482,7 @@ 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", ], ) @@ -4665,6 +4500,13 @@ 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!({ @@ -5128,579 +4970,6 @@ 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; 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(); - let manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/", - Arc::new(client), - ) - .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 - ); - } - - #[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!( - ( - 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", - ] - ); - } - #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata, 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 1b47560..b0b5090 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -308,29 +308,17 @@ 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()) => { - // 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(_) - ) => - { + // Try to parse as a valid JSON-RPC message. If the body is + // malformed (e.g. a 200 response to a notification that lacks + // an `id` field), treat it as accepted rather than failing. + match response.json::().await { + Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), + Err(e) => { tracing::warn!( "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" ); Ok(StreamableHttpPostResponse::Accepted) } - 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 688c9ad..e3b3489 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -322,23 +322,13 @@ impl StreamableHttpClient for UnixSocketHttpClient { .map_err(|e| StreamableHttpError::Client(UnixSocketError::Hyper(e)))? .to_bytes(); 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(_) - ) => - { + Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), + Err(e) => { tracing::warn!( "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" ); Ok(StreamableHttpPostResponse::Accepted) } - Err(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 8af045f..6f27abf 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -222,35 +222,6 @@ 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 @@ -2184,32 +2155,6 @@ 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/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 1b53820..f03014e 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -28,7 +28,7 @@ use crate::{ ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorCode, ErrorData, GetExtensions, GetMeta, Implementation, InitializeRequest, InitializeRequestParams, InitializedNotification, JsonObject, JsonRpcError, - ProtocolVersion, RequestId, ServerConfig, ServerJsonRpcMessage, ServerResult, + ProtocolVersion, RequestId, ServerInfo, ServerJsonRpcMessage, ServerResult, }, serve_server, service::{ @@ -110,18 +110,13 @@ pub struct StreamableHttpServerConfig { pub allowed_hosts: Vec, /// Allowed browser origins for inbound `Origin` validation. /// - /// 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. + /// Defaults to an empty list, which disables Origin validation. When + /// non-empty, requests carrying an `Origin` header must match per RFC 6454 + /// `(scheme, host, port)`; missing-`Origin` requests still pass. Entries + /// must include a scheme; `"null"` matches the browser's `Origin: null`. /// examples: /// allowed_origins = ["https://app.example.com", "http://localhost:8080"] pub allowed_origins: Vec, - validate_empty_origin_allowlist: bool, /// Optional external session store for cross-instance recovery. /// /// When set, [`SessionState`] (the client's `initialize` parameters) is @@ -194,7 +189,6 @@ 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, @@ -222,15 +216,9 @@ impl StreamableHttpServerConfig { self.allowed_origins = allowed_origins.into_iter().map(Into::into).collect(); self } - /// 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. + /// Disable Origin validation, reverting to the default ignore-Origin behavior. 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 { @@ -367,7 +355,7 @@ impl> Service for NegotiatingStatelessHttpSer self.0.handle_notification(notification, context).await } - fn get_info(&self) -> ServerConfig { + fn get_info(&self) -> ServerInfo { self.0.get_info() } @@ -644,52 +632,12 @@ fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { match error.error.code { ErrorCode::UNSUPPORTED_PROTOCOL_VERSION | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY - | ErrorCode::INVALID_PARAMS - | ErrorCode::HEADER_MISMATCH => http::StatusCode::BAD_REQUEST, + | ErrorCode::INVALID_PARAMS => 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, @@ -854,6 +802,9 @@ 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)) @@ -925,15 +876,12 @@ fn validate_dns_rebinding_headers( ); return Err(forbidden_response("Forbidden: Host header is not allowed").into()); } - validate_origin_header(headers, config)?; + validate_origin_header(headers, &config.allowed_origins)?; Ok(()) } -fn validate_origin_header( - headers: &HeaderMap, - config: &StreamableHttpServerConfig, -) -> HttpResult<()> { - if !config.validate_empty_origin_allowlist && config.allowed_origins.is_empty() { +fn validate_origin_header(headers: &HeaderMap, allowed_origins: &[String]) -> HttpResult<()> { + if allowed_origins.is_empty() { return Ok(()); } let Some(origin_header) = headers.get(http::header::ORIGIN) else { @@ -944,15 +892,15 @@ fn validate_origin_header( .inspect_err(|_| { tracing::warn!(origin = ?origin_header, "rejected request with non-UTF-8 Origin header"); }) - .map_err(|_| forbidden_response("Forbidden: Invalid Origin header encoding"))?; + .map_err(|_| bad_request_response("Bad Request: 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", ); - forbidden_response("Forbidden: Invalid Origin header") + bad_request_response("Bad Request: Invalid Origin header") })?; - if !origin_is_allowed(&origin, &config.allowed_origins) { + if !origin_is_allowed(&origin, allowed_origins) { tracing::warn!( origin = ?origin, "rejected request with disallowed Origin header (possible cross-origin attack)", @@ -2111,7 +2059,7 @@ where Ok(accepted_response()) } - /// Build a `InitializeRequestParams` (peer_info) for a stateless request so that + /// Build a `ClientInfo` (peer_info) for a stateless request so that /// `context.protocol_version()` returns the correct value inside handlers. /// /// `serve_directly` skips the MCP handshake and accepts `peer_info = None`, diff --git a/crates/rmcp/tests/common/calculator.rs b/crates/rmcp/tests/common/calculator.rs index 961d977..22c6d38 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, ServerConfig}, + model::{ServerCapabilities, ServerInfo}, schemars, tool, tool_router, }; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -52,8 +52,8 @@ impl Calculator { } impl ServerHandler for Calculator { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_instructions("A simple calculator") } } diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index 7e68621..7928fa6 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_logging().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 864932a..5961a7b 100644 --- a/crates/rmcp/tests/test_cancelled_response.rs +++ b/crates/rmcp/tests/test_cancelled_response.rs @@ -3,27 +3,13 @@ //! 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::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerConfig, + ServerInfo, }, service::RequestContext, }; @@ -35,7 +21,6 @@ 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<()> { @@ -58,7 +43,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" }), @@ -71,7 +56,7 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { &mut writer, &json!({ "jsonrpc": "2.0", - "id": STRING_REQUEST_ID, + "id": 2, "method": "tools/call", "params": { "name": "wait-for-cancel", "arguments": {} } }), @@ -82,59 +67,21 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { &json!({ "jsonrpc": "2.0", "method": "notifications/cancelled", - "params": { "requestId": "unrelated-request" } - }), - ) - .await?; - send_json( - &mut writer, - &json!({ - "jsonrpc": "2.0", - "method": "notifications/cancelled", - "params": { "requestId": STRING_REQUEST_ID } + "params": { "requestId": 2 } }), ) .await?; // A ping proves the server is alive past the cancellation, so the absence of - // a cancelled response is genuine suppression rather than a dead connection. + // an id=2 response is genuine suppression rather than a dead connection. send_json( &mut writer, &json!({ "jsonrpc": "2.0", "id": 3, "method": "ping" }), ) .await?; - let seen = collect_ids_until(&mut reader, "3", READ_TIMEOUT).await?; - assert!(seen.contains("3")); - assert!(!seen.contains(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")); + let seen = collect_ids_until(&mut reader, 3, READ_TIMEOUT).await?; + assert!(seen.contains(&3)); + assert!(!seen.contains(&2)); drop(writer); wait_for_child(&mut child).await; @@ -144,383 +91,20 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { struct WaitForCancelServer; impl ServerHandler for WaitForCancelServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } 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 = 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 { - 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") { @@ -588,9 +172,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: &str, + stop_id: u64, timeout: Duration, -) -> anyhow::Result> +) -> anyhow::Result> where R: tokio::io::AsyncRead + Unpin, { @@ -616,13 +200,8 @@ where let Ok(value) = serde_json::from_str::(trimmed) else { continue; }; - 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 let Some(id) = value.get("id").and_then(Value::as_u64) { + seen.insert(id); if id == stop_id { // Give any late (incorrectly-sent) response a brief window to arrive. deadline = tokio::time::Instant::now() + Duration::from_millis(300); diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index b01223a..cb10182 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, ServerConfig}, + model::{ServerCapabilities, ServerInfo}, 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) } } @@ -880,11 +880,10 @@ fn test_protocol_version_utilities() { assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28)); } -/// Integration test: Verify Host validation remains enabled when Origin validation is -/// disabled by default +/// Integration test: Verify server validates only the Host header for DNS rebinding protection #[tokio::test] #[cfg(all(feature = "transport-streamable-http-server", feature = "server",))] -async fn test_server_validates_host_when_origin_validation_is_disabled_by_default() { +async fn test_server_validates_host_header_for_dns_rebinding_protection() { use std::sync::Arc; use bytes::Bytes; @@ -892,7 +891,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, ServerConfig}, + model::{ServerCapabilities, ServerInfo}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -903,8 +902,8 @@ async fn test_server_validates_host_when_origin_validation_is_disabled_by_defaul struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) } } @@ -975,7 +974,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, ServerConfig}, + model::{ServerCapabilities, ServerInfo}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -986,8 +985,8 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) } } @@ -1046,7 +1045,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, ServerConfig}, + model::{ServerCapabilities, ServerInfo}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -1057,8 +1056,8 @@ async fn test_server_falls_back_to_uri_authority_when_host_header_missing() { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) } } @@ -1128,11 +1127,11 @@ mod origin_validation { use std::sync::Arc; use bytes::Bytes; - use http::{HeaderValue, Method, Request, header::CONTENT_TYPE}; + use http::{Method, Request, header::CONTENT_TYPE}; use http_body_util::Full; use rmcp::{ handler::server::ServerHandler, - model::{ServerCapabilities, ServerConfig}, + model::{ServerCapabilities, ServerInfo}, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, @@ -1143,25 +1142,17 @@ mod origin_validation { struct TestHandler; impl ServerHandler for TestHandler { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().build()) } } - 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( + StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig::default().with_allowed_origins(origins.iter().copied()), ) } @@ -1208,80 +1199,6 @@ 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"]); diff --git a/crates/rmcp/tests/test_discover_http_client_startup.rs b/crates/rmcp/tests/test_discover_http_client_startup.rs index 2d92df5..c2825f8 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::{ClientConfig, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion}, + model::{ClientInfo, 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 = ClientConfig::default() + let client = ClientInfo::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 = ClientConfig::default() + let client = ClientInfo::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 = ClientConfig::default() + let client = ClientInfo::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 ed3cf67..7f44e82 100644 --- a/crates/rmcp/tests/test_handler_cache_hints.rs +++ b/crates/rmcp/tests/test_handler_cache_hints.rs @@ -5,8 +5,7 @@ use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, handler::server::router::{prompt::PromptRouter, tool::ToolRouter}, model::{ - CacheScope, ClientConfig, InitializeResult, ListPromptsResult, ListToolsResult, - ProtocolVersion, + CacheScope, ClientInfo, ListPromptsResult, ListToolsResult, ProtocolVersion, ServerInfo, }, prompt_handler, service::serve_directly, @@ -38,8 +37,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientConfig { - let mut info = ClientConfig::default(); + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); info.protocol_version = self.protocol_version.clone(); info } @@ -54,7 +53,7 @@ async fn list_results(protocol_version: ProtocolVersion) -> (ListToolsResult, Li let client_handler = VersionedClient { protocol_version: protocol_version.clone(), }; - let mut server_peer_info = InitializeResult::default(); + let mut server_peer_info = ServerInfo::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 fc5908f..c75e17c 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, ClientConfig, ServerCapabilities, ServerConfig}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, service::QuitReason, tool, tool_handler, tool_router, }; @@ -55,8 +55,8 @@ impl SlowToolServer { #[tool_handler] impl ServerHandler for SlowToolServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ClientConfig { - ClientConfig::default() + fn get_info(&self) -> ClientInfo { + ClientInfo::default() } } diff --git a/crates/rmcp/tests/test_mrtr_behavior.rs b/crates/rmcp/tests/test_mrtr_behavior.rs index d9df5af..1cbbc01 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) -> ServerConfig { - let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::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) -> ServerConfig { - let mut info = ServerConfig::new( + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_prompts() @@ -328,16 +328,16 @@ impl ClientHandler for MrtrClient { // Harness // ============================================================================= -fn client_info(protocol_version: ProtocolVersion) -> InitializeRequestParams { - InitializeRequestParams::new( +fn client_info(protocol_version: ProtocolVersion) -> ClientInfo { + ClientInfo::new( ClientCapabilities::builder().enable_elicitation().build(), Implementation::new("mrtr-test-client", "0.0.0"), ) .with_protocol_version(protocol_version) } -fn server_info(protocol_version: ProtocolVersion) -> InitializeResult { - let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); +fn server_info(protocol_version: ProtocolVersion) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); info.protocol_version = protocol_version; info } @@ -609,8 +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) -> ServerConfig { - let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::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 dafb0df..9aafd3c 100644 --- a/crates/rmcp/tests/test_notification.rs +++ b/crates/rmcp/tests/test_notification.rs @@ -6,7 +6,7 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ ClientNotification, CustomNotification, ResourceUpdatedNotificationParam, - ServerCapabilities, ServerConfig, ServerNotification, SubscribeRequestParams, + ServerCapabilities, ServerInfo, 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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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 e6d65b6..642ae88 100644 --- a/crates/rmcp/tests/test_prompt_macros.rs +++ b/crates/rmcp/tests/test_prompt_macros.rs @@ -7,7 +7,7 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceExt, handler::server::{router::prompt::PromptRouter, wrapper::Parameters}, model::{ - ClientConfig, ContentBlock, GetPromptRequestParams, GetPromptResult, PromptMessage, Role, + ClientInfo, ContentBlock, GetPromptRequestParams, GetPromptResult, PromptMessage, Role, }, prompt, prompt_handler, prompt_router, }; @@ -297,8 +297,8 @@ fn test_optional_field_schema_generation_via_macro() { struct DummyClientHandler {} impl ClientHandler for DummyClientHandler { - fn get_info(&self) -> ClientConfig { - ClientConfig::default() + fn get_info(&self) -> ClientInfo { + ClientInfo::default() } } diff --git a/crates/rmcp/tests/test_protocol_version_negotiation.rs b/crates/rmcp/tests/test_protocol_version_negotiation.rs index 1453e0f..aa4607a 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, ClientConfig, ErrorCode, Implementation, InitializeRequestParams, - InitializeResult, ProtocolVersion, ServerConfig, + ClientCapabilities, ClientInfo, ErrorCode, Implementation, InitializeRequestParams, + InitializeResult, ProtocolVersion, ServerInfo, }, service::{ClientInitializeError, RequestContext}, }; @@ -26,8 +26,8 @@ use rmcp::{ struct EchoServer; impl ServerHandler for EchoServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::default() + fn get_info(&self) -> ServerInfo { + ServerInfo::default() } } @@ -45,8 +45,8 @@ const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[ struct NarrowedServer; impl ServerHandler for NarrowedServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::default() + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ServerConfig { - let mut info = ServerConfig::default(); + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::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) -> ServerConfig { - ServerConfig::default() + fn get_info(&self) -> ServerInfo { + ServerInfo::default() } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { @@ -102,8 +102,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientConfig { - let mut info = ClientConfig::default(); + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); info.protocol_version = self.protocol_version.clone(); info } @@ -247,8 +247,8 @@ struct DelegatingServer { } impl ServerHandler for DelegatingServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::default() + fn get_info(&self) -> ServerInfo { + ServerInfo::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 efae45f..1d86ca8 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::{ - ClientConfig, ErrorCode, ErrorData, InitializeResult, ProtocolVersion, - ReadResourceRequestParams, ReadResourceResponse, + ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams, + ReadResourceResponse, ServerInfo, }, service::{RequestContext, serve_directly}, }; @@ -33,8 +33,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientConfig { - let mut info = ClientConfig::default(); + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::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 = InitializeResult::default(); + let mut server_peer_info = ServerInfo::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 61240ed..ab779d2 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, ClientConfig, ContentBlock, - ErrorData, InitializeResult, ProtocolVersion, ResultType, + CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, + ErrorData, ProtocolVersion, ResultType, ServerInfo, }, service::{RequestContext, serve_directly}, }; @@ -33,8 +33,8 @@ struct VersionedClient { } impl ClientHandler for VersionedClient { - fn get_info(&self) -> ClientConfig { - let mut info = ClientConfig::default(); + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::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 787d634..b02cff0 100644 --- a/crates/rmcp/tests/test_sep_2260_request_association.rs +++ b/crates/rmcp/tests/test_sep_2260_request_association.rs @@ -9,9 +9,9 @@ use std::sync::{Arc, Mutex}; use rmcp::{ ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError, ServiceExt, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ClientConfig, ContentBlock, + CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, ProtocolVersion, - SamplingMessage, ServerCapabilities, ServerConfig, ServerRequest, + SamplingMessage, ServerCapabilities, ServerInfo, ServerRequest, }, service::{RequestContext, RunningService, serve_directly}, }; @@ -29,8 +29,8 @@ struct SamplingServer { } impl ServerHandler for SamplingServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( @@ -93,8 +93,8 @@ impl ClientHandler for SamplingClient { .with_stop_reason(CreateMessageResult::STOP_REASON_END_TURN)) } - fn get_info(&self) -> ClientConfig { - let mut info = ClientConfig::default(); + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::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 72a0832..36595e0 100644 --- a/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs +++ b/crates/rmcp/tests/test_sep_2260_stream_enforcement.rs @@ -26,8 +26,8 @@ use http::{HeaderName, HeaderValue}; use rmcp::{ ClientHandler, model::{ - ClientConfig, ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, - ProtocolVersion, SamplingMessage, ServerCapabilities, ServerConfig, ServerJsonRpcMessage, + ClientInfo, ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, + ProtocolVersion, SamplingMessage, ServerCapabilities, ServerInfo, ServerJsonRpcMessage, }, service::{ClientLifecycleMode, RequestContext, RoleClient, serve_client_with_lifecycle}, transport::streamable_http_client::{ @@ -80,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 = ServerConfig::new(ServerCapabilities::default()); + let mut info = ServerInfo::new(ServerCapabilities::default()); info.protocol_version = ProtocolVersion::V_2026_07_28; let response = ServerJsonRpcMessage::response( rmcp::model::ServerResult::InitializeResult(info), @@ -152,8 +152,8 @@ impl ClientHandler for SamplingClient { )) } - fn get_info(&self) -> ClientConfig { - ClientConfig::default() + fn get_info(&self) -> ClientInfo { + ClientInfo::default() } } diff --git a/crates/rmcp/tests/test_sep_2260_stream_routing.rs b/crates/rmcp/tests/test_sep_2260_stream_routing.rs index e883ccd..824ef0c 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, ServerConfig, + ElicitationSchema, ServerCapabilities, ServerInfo, }, 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 9046f6b..3b309dd 100644 --- a/crates/rmcp/tests/test_server_discover_client.rs +++ b/crates/rmcp/tests/test_server_discover_client.rs @@ -4,7 +4,7 @@ use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ ClientCapabilities, Implementation, ProtocolVersion, RequestMetaObject, ServerCapabilities, - ServerConfig, + ServerInfo, }, select_protocol_version, }; @@ -13,8 +13,8 @@ use rmcp::{ struct DiscoveryServer; impl ServerHandler for DiscoveryServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 7762c97..7a42f06 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, ServerConfig}, + model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}, 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("discovery-server", "1.0.0")) .with_instructions("Use the tools carefully") } diff --git a/crates/rmcp/tests/test_server_initialization.rs b/crates/rmcp/tests/test_server_initialization.rs index 5be178e..6542e29 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, ServerConfig, + ClientJsonRpcMessage, ProtocolVersion, ServerCapabilities, ServerInfo, 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 23ab8d0..e1e8852 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, ServerConfig, ToolsCapability}, + model::{Implementation, ServerCapabilities, ServerInfo, 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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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 cc2a4b5..dbfcaf2 100644 --- a/crates/rmcp/tests/test_stateless_protocol_version.rs +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -11,8 +11,7 @@ use std::borrow::Cow; use rmcp::{ ErrorData, RoleServer, ServerHandler, model::{ - InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities, - ServerConfig, + InitializeRequestParams, InitializeResult, ProtocolVersion, ServerCapabilities, ServerInfo, }, service::RequestContext, transport::streamable_http_server::{ @@ -25,8 +24,8 @@ use tokio_util::sync::CancellationToken; struct OverridingInitialize; impl ServerHandler for OverridingInitialize { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::default()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::default()) } async fn initialize( @@ -54,8 +53,8 @@ const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[ struct NarrowedOverridingInitialize; impl ServerHandler for NarrowedOverridingInitialize { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::default()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::default()) } fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { diff --git a/crates/rmcp/tests/test_stateless_server_requests.rs b/crates/rmcp/tests/test_stateless_server_requests.rs index 9ab302b..ae418e3 100644 --- a/crates/rmcp/tests/test_stateless_server_requests.rs +++ b/crates/rmcp/tests/test_stateless_server_requests.rs @@ -1,18 +1,14 @@ #![cfg(all(feature = "server", not(feature = "local")))] -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; +use std::sync::{Arc, Mutex}; use rmcp::{ ServerHandler, ServiceExt, model::{ ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest, DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest, - ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotificationParam, - ProgressToken, ProtocolVersion, RequestId, RequestMetaObject, ServerJsonRpcMessage, - ServerNotification, + ListToolsResult, PaginatedRequestParams, ProtocolVersion, RequestId, RequestMetaObject, + ServerJsonRpcMessage, }, service::{MaybeSendFuture, RequestContext, RoleServer, ServerInitializeError}, transport::{IntoTransport, Transport}, @@ -216,73 +212,3 @@ 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"); -} diff --git a/crates/rmcp/tests/test_stdio_response_concurrency.rs b/crates/rmcp/tests/test_stdio_response_concurrency.rs index 64be3fc..2e1e40a 100644 --- a/crates/rmcp/tests/test_stdio_response_concurrency.rs +++ b/crates/rmcp/tests/test_stdio_response_concurrency.rs @@ -6,7 +6,7 @@ use rmcp::{ ErrorData as McpError, ServerHandler, ServiceExt, model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerConfig, + ServerInfo, }, }; use serde_json::{Value, json}; @@ -82,8 +82,8 @@ struct LargeResponseServer; impl ServerHandler for LargeResponseServer { #[allow(deprecated)] - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 2e5be84..20b7258 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, ClientConfig, ClientJsonRpcMessage, - ClientRequest, DiscoverResult, InitializeRequestParams, ProtocolVersion, Request, - RequestId, RequestMetaObject, ServerJsonRpcMessage, + CallToolRequestParams, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, + ClientRequest, DiscoverResult, 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,7 @@ impl Harness { config, ); let client = - serve_client_with_lifecycle(ClientConfig::default(), transport, lifecycle).await?; + serve_client_with_lifecycle(ClientInfo::default(), transport, lifecycle).await?; Ok(Self { client, started: requests, @@ -601,12 +601,7 @@ 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": InitializeRequestParams::default() - }), + json!({ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": ClientInfo::default() }), json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), ] { timeout( @@ -825,12 +820,7 @@ 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": InitializeRequestParams::default() - }), + json!({ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": ClientInfo::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 aa4ae63..553448e 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, ClientConfig, ServerCapabilities, ServerConfig}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 = ClientConfig::default().serve(transport).await?; + let client = ClientInfo::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 05efe21..31e7413 100644 --- a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs +++ b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs @@ -19,7 +19,7 @@ use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, - ServerConfig, + ServerInfo, }, service::RequestContext, transport::streamable_http_server::{ @@ -37,8 +37,8 @@ struct CancelProbe { impl ServerHandler for CancelProbe { #[allow(deprecated)] - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 979ce7e..cc88f10 100644 --- a/crates/rmcp/tests/test_streamable_http_event_store.rs +++ b/crates/rmcp/tests/test_streamable_http_event_store.rs @@ -19,7 +19,7 @@ use rmcp::{ ErrorData, ServerHandler, model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - ProgressNotificationParam, ServerCapabilities, ServerConfig, + ProgressNotificationParam, ServerCapabilities, ServerInfo, }, service::RequestContext, transport::streamable_http_server::{ @@ -109,8 +109,8 @@ impl EventStore for InMemoryEventStore { struct ProgressServer; impl ServerHandler for ProgressServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 62daa7a..b1c09f5 100644 --- a/crates/rmcp/tests/test_streamable_http_json_response.rs +++ b/crates/rmcp/tests/test_streamable_http_json_response.rs @@ -3,7 +3,7 @@ use rmcp::{ ErrorData, ServerHandler, model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - ProgressNotificationParam, ServerCapabilities, ServerConfig, + ProgressNotificationParam, ServerCapabilities, ServerInfo, }, 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } async fn call_tool( diff --git a/crates/rmcp/tests/test_streamable_http_malformed_json_request.rs b/crates/rmcp/tests/test_streamable_http_malformed_json_request.rs deleted file mode 100644 index c34e96e..0000000 --- a/crates/rmcp/tests/test_streamable_http_malformed_json_request.rs +++ /dev/null @@ -1,195 +0,0 @@ -#![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_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index 76005a7..373549a 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::ServerConfig { - rmcp::model::ServerConfig::new( + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo::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 c9ea214..be1ac02 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -15,7 +15,7 @@ use http::{HeaderName, HeaderValue}; use rmcp::{ ServiceError, ServiceExt, model::{ - CallToolRequestParams, ClientConfig, ClientJsonRpcMessage, ClientRequest, ErrorCode, + CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode, ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities, ServerJsonRpcMessage, ServerResult, }, @@ -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 = ClientConfig::default().serve(transport).await?; + let mut client = ClientInfo::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 9e70805..510433e 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, ServerConfig, Tool}, + model::{ServerCapabilities, ServerInfo, 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 7359530..8ceef93 100644 --- a/crates/rmcp/tests/test_subscriptions.rs +++ b/crates/rmcp/tests/test_subscriptions.rs @@ -19,7 +19,7 @@ use rmcp::{ model::{ ClientNotification, ClientRequest, DiscoverResult, GetMeta, Implementation, NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, ServerCapabilities, - ServerConfig, ServerNotification, ServerResult, SubscriptionFilter, + ServerInfo, ServerNotification, ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenResult, }, @@ -44,8 +44,8 @@ impl ClientHandler for CountingClient { } impl ServerHandler for ToolsOnlyServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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::ServerConfig { - ServerConfig::default() + fn get_info(&self) -> rmcp::model::ServerInfo { + ServerInfo::default() } } impl ServerHandler for ClosedSinkServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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 70f102f..bfb9313 100644 --- a/crates/rmcp/tests/test_subscriptions_streamable_http.rs +++ b/crates/rmcp/tests/test_subscriptions_streamable_http.rs @@ -18,9 +18,8 @@ use std::{ use rmcp::{ ClientLifecycleMode, ClientServiceExt, ServerHandler, model::{ - ClientConfig, ClientRequest, Implementation, ListToolsRequest, ProtocolVersion, - RequestMetaObject, ServerCapabilities, ServerConfig, ServerNotification, - SubscriptionFilter, + ClientInfo, ClientRequest, Implementation, ListToolsRequest, ProtocolVersion, + RequestMetaObject, ServerCapabilities, ServerInfo, ServerNotification, SubscriptionFilter, }, service::{PeerRequestOptions, SubscriptionContext, SubscriptionEnd}, transport::{ @@ -53,8 +52,8 @@ impl ServerHandler for HttpSubscriptionServer { Cow::Borrowed(&[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25]) } - fn get_info(&self) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() @@ -160,7 +159,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 = ClientConfig::default() + let client = ClientInfo::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -205,7 +204,7 @@ async fn modern_http_graceful_close_returns_final_listen_result() -> anyhow::Res let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = ClientConfig::default() + let client = ClientInfo::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -241,7 +240,7 @@ async fn modern_http_stream_close_without_result_is_abrupt() -> anyhow::Result<( let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = ClientConfig::default() + let client = ClientInfo::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { @@ -274,7 +273,7 @@ async fn modern_http_lifecycle_stays_sessionless_for_older_application_version() let transport = StreamableHttpClientTransport::from_config( StreamableHttpClientTransportConfig::with_uri(url), ); - let client = ClientConfig::default() + let client = ClientInfo::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { diff --git a/crates/rmcp/tests/test_task.rs b/crates/rmcp/tests/test_task.rs index 0178bab..55ba4cc 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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() @@ -117,8 +117,8 @@ impl ServerHandler for TaskServer { } } -fn tasks_client_info() -> ClientConfig { - ClientConfig::new( +fn tasks_client_info() -> ClientInfo { + ClientInfo::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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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 14a8757..cd87805 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, ServerConfig, Tool}, + model::{CallToolResponse, CallToolResult, ServerCapabilities, ServerInfo, Tool}, service::{MaybeSendFuture, NotificationContext}, }; use tokio::sync::{Notify, RwLock}; @@ -41,8 +41,8 @@ impl TestToolServer { } impl ServerHandler for TestToolServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 8642496..4975109 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, ClientConfig, ServerCapabilities, ServerConfig}, + model::{CallToolRequestParams, ClientInfo, ServerCapabilities, ServerInfo}, 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) -> ClientConfig { - ClientConfig::default() + fn get_info(&self) -> ClientInfo { + ClientInfo::default() } } @@ -549,8 +549,8 @@ impl ManualInfoServer { #[tool_handler] impl ServerHandler for ManualInfoServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_resources() diff --git a/crates/rmcp/tests/test_unix_socket_transport.rs b/crates/rmcp/tests/test_unix_socket_transport.rs index 134bb1e..d0e5397 100644 --- a/crates/rmcp/tests/test_unix_socket_transport.rs +++ b/crates/rmcp/tests/test_unix_socket_transport.rs @@ -13,12 +13,9 @@ use http::{HeaderName, HeaderValue}; use hyper_util::rt::TokioIo; use rmcp::{ ServiceExt, - model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId}, transport::{ StreamableHttpClientTransport, UnixSocketHttpClient, - streamable_http_client::{ - StreamableHttpClient, StreamableHttpClientTransportConfig, StreamableHttpError, - }, + streamable_http_client::StreamableHttpClientTransportConfig, }, }; use serde_json::json; @@ -299,56 +296,3 @@ 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(()) -} diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index ffd3bf2..5d32be3 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 = ClientConfig::default(); +let client_service = ClientInfo::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::ClientConfig, + model::ClientInfo, transport::{ StreamableHttpClientTransport, auth::{ @@ -359,7 +359,7 @@ async fn connect( StreamableHttpClientTransportConfig::with_uri(resource) .auth_header(token.access_token.secret()), ); - let client = ClientConfig::default().serve(transport).await?; + let client = ClientInfo::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 e277b9e..55aa615 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::ClientConfig, + model::ClientInfo, transport::{ StreamableHttpClientTransport, auth::{AuthClient, ClientCredentialsConfig, OAuthState}, @@ -73,7 +73,7 @@ async fn main() -> Result<()> { ); // Connect to MCP server and list tools - let client_service = ClientConfig::default(); + let client_service = ClientInfo::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 8fa18df..1ab9313 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::ClientConfig, + model::ClientInfo, 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(ClientConfig::default().serve(transport).await?) + Ok(ClientInfo::default().serve(transport).await?) } #[tokio::main] diff --git a/examples/clients/src/progress_client.rs b/examples/clients/src/progress_client.rs index 3990006..ad00dea 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, ClientConfig, Implementation, + CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, ProgressNotificationParam, }, service::{NotificationContext, RoleClient}, @@ -121,8 +121,8 @@ impl ClientHandler for ProgressAwareClient { } } - fn get_info(&self) -> ClientConfig { - ClientConfig::new( + fn get_info(&self) -> ClientInfo { + ClientInfo::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 f841a87..d2c88dc 100644 --- a/examples/clients/src/streamable_http.rs +++ b/examples/clients/src/streamable_http.rs @@ -2,7 +2,7 @@ use anyhow::Result; use rmcp::{ ClientLifecycleMode, ClientServiceExt, model::{ - CallToolRequestParams, ClientCapabilities, ClientConfig, Implementation, ProtocolVersion, + CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, ProtocolVersion, }, transport::StreamableHttpClientTransport, }; @@ -18,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 = ClientConfig::new( + let client_info = ClientInfo::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 9c04531..7ab612b 100644 --- a/examples/clients/src/subscriptions_streamhttp.rs +++ b/examples/clients/src/subscriptions_streamhttp.rs @@ -1,6 +1,6 @@ use rmcp::{ ClientLifecycleMode, ClientServiceExt, - model::{ClientConfig, ProtocolVersion, SubscriptionFilter}, + model::{ClientInfo, 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 = ClientConfig::default() + let client = ClientInfo::default() .serve_with_lifecycle( transport, ClientLifecycleMode::Discover { diff --git a/examples/clients/src/task_stdio.rs b/examples/clients/src/task_stdio.rs index a946f75..465cb70 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::ClientConfig::new( + let client_info = rmcp::model::ClientInfo::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 479fcfa..3bd1617 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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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 68a32ec..91f0947 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::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 30aed17..4d8d53a 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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() diff --git a/examples/servers/src/completion_stdio.rs b/examples/servers/src/completion_stdio.rs index c7894ea..ffaeee5 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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::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 e565892..50d2844 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::from_build_env()) .with_instructions( "Simple server demonstrating elicitation for enum selection".to_string(), diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 5d7f280..16b4773 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::from_build_env()) .with_instructions( "Simple server demonstrating elicitation for user name collection".to_string(), diff --git a/examples/servers/src/mrtr.rs b/examples/servers/src/mrtr.rs index b6c87b6..3165c8d 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) -> ServerConfig { - let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()); // MRTR requires 2026-07-28 or newer. info.protocol_version = ProtocolVersion::V_2026_07_28; info @@ -173,8 +173,8 @@ impl ServerHandler for WeatherServer { struct InteractiveClient; impl ClientHandler for InteractiveClient { - fn get_info(&self) -> ClientConfig { - ClientConfig::new( + fn get_info(&self) -> ClientInfo { + ClientInfo::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 7f018d0..6ef24e9 100644 --- a/examples/servers/src/prompt_stdio.rs +++ b/examples/servers/src/prompt_stdio.rs @@ -363,8 +363,8 @@ impl PromptServer { #[prompt_handler(router = self.prompt_router)] impl ServerHandler for PromptServer { - fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_prompts().build()).with_instructions( + 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, \ 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 41f74c7..2be7f5d 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) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_instructions(concat!( "This is a demo server that requests sampling from clients. It provides tools that use LLM capabilities.\n\n", "IMPORTANT: This server requires a client that supports the 'sampling/createMessage' method. ", diff --git a/examples/servers/src/subscriptions_streamhttp.rs b/examples/servers/src/subscriptions_streamhttp.rs index 7615d59..aead85f 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, ServerConfig, SubscriptionFilter}, + model::{ProtocolVersion, ServerCapabilities, ServerInfo, 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) -> ServerConfig { - ServerConfig::new( + fn get_info(&self) -> ServerInfo { + ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed()