| #![cfg(all(feature = "server", feature = "macros", not(feature = "local")))] | ||
| use rmcp::{ | ||
| Json, | ||
| handler::server::wrapper::Parameters, | ||
| model::{ListToolsResult, NumberOrString, ServerJsonRpcMessage, ServerResult}, | ||
| }; | ||
| /// Parameters for adding two numbers. | ||
| #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] | ||
| struct AddRequest { | ||
| /// The left-hand number. | ||
| a: f64, | ||
| /// The right-hand number. | ||
| b: f64, | ||
| } | ||
| /// Result of adding two numbers. | ||
| #[derive(Debug, serde::Serialize, schemars::JsonSchema)] | ||
| struct AddResult { | ||
| /// The sum of the two numbers. | ||
| sum: f64, | ||
| } | ||
| /// Add two numbers. | ||
| #[rmcp::tool] | ||
| fn add(Parameters(AddRequest { a, b }): Parameters<AddRequest>) -> Json<AddResult> { | ||
| Json(AddResult { sum: a + b }) | ||
| } | ||
| #[test] | ||
| fn list_tools_result_matches_expected_json() { | ||
| let expected_json = std::fs::read("tests/test_list_tools_result/list_tools_result.json") | ||
| .expect("missing expected list tools result JSON fixture"); | ||
| let expected: serde_json::Value = | ||
| serde_json::from_slice(&expected_json).expect("invalid expected JSON fixture"); | ||
| assert_eq!(add(Parameters(AddRequest { a: 1.0, b: 2.0 })).0.sum, 3.0); | ||
| let result = ListToolsResult::with_all_items(vec![add_tool_attr()]); | ||
| let response = ServerJsonRpcMessage::response( | ||
| ServerResult::ListToolsResult(result), | ||
| NumberOrString::Number(2), | ||
| ); | ||
| let actual = serde_json::to_value(response).expect("failed to serialize list tools response"); | ||
| assert_eq!(actual, expected); | ||
| } |
| { | ||
| "result": { | ||
| "tools": [ | ||
| { | ||
| "name": "add", | ||
| "description": "Add two numbers.", | ||
| "inputSchema": { | ||
| "$schema": "https://json-schema.org/draft/2020-12/schema", | ||
| "type": "object", | ||
| "properties": { | ||
| "a": { | ||
| "description": "The left-hand number.", | ||
| "format": "double", | ||
| "type": "number" | ||
| }, | ||
| "b": { | ||
| "description": "The right-hand number.", | ||
| "format": "double", | ||
| "type": "number" | ||
| } | ||
| }, | ||
| "required": [ | ||
| "a", | ||
| "b" | ||
| ] | ||
| }, | ||
| "outputSchema": { | ||
| "$schema": "https://json-schema.org/draft/2020-12/schema", | ||
| "type": "object", | ||
| "properties": { | ||
| "sum": { | ||
| "description": "The sum of the two numbers.", | ||
| "format": "double", | ||
| "type": "number" | ||
| } | ||
| }, | ||
| "required": [ | ||
| "sum" | ||
| ] | ||
| } | ||
| } | ||
| ] | ||
| }, | ||
| "jsonrpc": "2.0", | ||
| "id": 2 | ||
| } |
| #![cfg(not(feature = "local"))] | ||
| use std::{ | ||
| sync::{ | ||
| Arc, | ||
| atomic::{AtomicUsize, Ordering}, | ||
| }, | ||
| time::Duration, | ||
| }; | ||
| use rmcp::{ | ||
| ClientHandler, Peer, RoleServer, ServiceError, ServiceExt, | ||
| model::{ | ||
| CallToolRequestParams, ClientRequest, Meta, NumberOrString, ProgressNotificationParam, | ||
| ProgressToken, Request, | ||
| }, | ||
| service::PeerRequestOptions, | ||
| tool, tool_router, | ||
| }; | ||
| #[derive(Clone, Default)] | ||
| struct ProgressCountingClient { | ||
| progress_count: Arc<AtomicUsize>, | ||
| } | ||
| impl ClientHandler for ProgressCountingClient { | ||
| async fn on_progress( | ||
| &self, | ||
| _params: ProgressNotificationParam, | ||
| _context: rmcp::service::NotificationContext<rmcp::RoleClient>, | ||
| ) { | ||
| self.progress_count.fetch_add(1, Ordering::SeqCst); | ||
| } | ||
| } | ||
| struct ProgressTimeoutServer; | ||
| #[tool_router(server_handler)] | ||
| impl ProgressTimeoutServer { | ||
| #[tool] | ||
| async fn delayed_without_progress(&self) -> Result<(), rmcp::ErrorData> { | ||
| tokio::time::sleep(Duration::from_millis(250)).await; | ||
| Ok(()) | ||
| } | ||
| #[tool] | ||
| async fn delayed_with_progress( | ||
| &self, | ||
| meta: Meta, | ||
| client: Peer<RoleServer>, | ||
| ) -> Result<(), rmcp::ErrorData> { | ||
| let progress_token = meta | ||
| .get_progress_token() | ||
| .ok_or(rmcp::ErrorData::invalid_params( | ||
| "Progress token is required", | ||
| None, | ||
| ))?; | ||
| for step in 0..4 { | ||
| tokio::time::sleep(Duration::from_millis(50)).await; | ||
| let _ = client | ||
| .notify_progress(ProgressNotificationParam { | ||
| progress_token: progress_token.clone(), | ||
| progress: step as f64, | ||
| total: Some(4.0), | ||
| message: Some("working".into()), | ||
| }) | ||
| .await; | ||
| } | ||
| Ok(()) | ||
| } | ||
| #[tool] | ||
| async fn delayed_with_unrelated_progress( | ||
| &self, | ||
| client: Peer<RoleServer>, | ||
| ) -> Result<(), rmcp::ErrorData> { | ||
| for step in 0..4 { | ||
| tokio::time::sleep(Duration::from_millis(50)).await; | ||
| let _ = client | ||
| .notify_progress(ProgressNotificationParam { | ||
| progress_token: ProgressToken(NumberOrString::Number(999_999)), | ||
| progress: step as f64, | ||
| total: Some(4.0), | ||
| message: Some("unrelated".into()), | ||
| }) | ||
| .await; | ||
| } | ||
| Ok(()) | ||
| } | ||
| } | ||
| async fn start_pair() | ||
| -> anyhow::Result<rmcp::service::RunningService<rmcp::RoleClient, ProgressCountingClient>> { | ||
| let server = ProgressTimeoutServer; | ||
| let client = ProgressCountingClient::default(); | ||
| let (transport_server, transport_client) = tokio::io::duplex(4096); | ||
| tokio::spawn(async move { | ||
| let service = server.serve(transport_server).await?; | ||
| service.waiting().await?; | ||
| anyhow::Ok(()) | ||
| }); | ||
| Ok(client.serve(transport_client).await?) | ||
| } | ||
| async fn call_tool_with_options( | ||
| client: &rmcp::service::RunningService<rmcp::RoleClient, ProgressCountingClient>, | ||
| name: &str, | ||
| options: PeerRequestOptions, | ||
| ) -> Result<rmcp::model::ServerResult, ServiceError> { | ||
| client | ||
| .send_request_with_option( | ||
| ClientRequest::CallToolRequest(Request::new(CallToolRequestParams::new( | ||
| name.to_owned(), | ||
| ))), | ||
| options, | ||
| ) | ||
| .await? | ||
| .await_response() | ||
| .await | ||
| } | ||
| #[tokio::test] | ||
| async fn request_timeout_still_expires_without_progress() -> anyhow::Result<()> { | ||
| let client = start_pair().await?; | ||
| let result = call_tool_with_options( | ||
| &client, | ||
| "delayed_without_progress", | ||
| PeerRequestOptions::with_timeout(Duration::from_millis(75)), | ||
| ) | ||
| .await; | ||
| assert!(matches!(result, Err(ServiceError::Timeout { .. }))); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn progress_does_not_reset_timeout_by_default() -> anyhow::Result<()> { | ||
| let client = start_pair().await?; | ||
| let result = call_tool_with_options( | ||
| &client, | ||
| "delayed_with_progress", | ||
| PeerRequestOptions::with_timeout(Duration::from_millis(75)), | ||
| ) | ||
| .await; | ||
| assert!(matches!(result, Err(ServiceError::Timeout { .. }))); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn matching_progress_resets_timeout_when_enabled() -> anyhow::Result<()> { | ||
| let client = start_pair().await?; | ||
| let result = call_tool_with_options( | ||
| &client, | ||
| "delayed_with_progress", | ||
| PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(), | ||
| ) | ||
| .await; | ||
| assert!(result.is_ok()); | ||
| assert!(client.service().progress_count.load(Ordering::SeqCst) > 0); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn generated_progress_token_overrides_option_meta_token() -> anyhow::Result<()> { | ||
| let client = start_pair().await?; | ||
| let mut options = | ||
| PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(); | ||
| options.meta = Some(Meta::with_progress_token(ProgressToken( | ||
| NumberOrString::Number(999_999), | ||
| ))); | ||
| let result = call_tool_with_options(&client, "delayed_with_progress", options).await; | ||
| assert!(result.is_ok()); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn max_total_timeout_wins_over_progress_reset() -> anyhow::Result<()> { | ||
| let client = start_pair().await?; | ||
| let result = call_tool_with_options( | ||
| &client, | ||
| "delayed_with_progress", | ||
| PeerRequestOptions::with_timeout(Duration::from_millis(75)) | ||
| .reset_timeout_on_progress() | ||
| .with_max_total_timeout(Duration::from_millis(125)), | ||
| ) | ||
| .await; | ||
| assert!(matches!(result, Err(ServiceError::Timeout { .. }))); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn unrelated_progress_does_not_reset_timeout() -> anyhow::Result<()> { | ||
| let client = start_pair().await?; | ||
| let result = call_tool_with_options( | ||
| &client, | ||
| "delayed_with_unrelated_progress", | ||
| PeerRequestOptions::with_timeout(Duration::from_millis(75)).reset_timeout_on_progress(), | ||
| ) | ||
| .await; | ||
| assert!(matches!(result, Err(ServiceError::Timeout { .. }))); | ||
| Ok(()) | ||
| } |
| //! SEP-2164: the resource-not-found error code follows the negotiated protocol version. | ||
| //! | ||
| //! `2026-07-28` and newer get the standard `INVALID_PARAMS` (-32602); older versions | ||
| //! keep the legacy `RESOURCE_NOT_FOUND` (-32002). | ||
| #![cfg(not(feature = "local"))] | ||
| #![cfg(feature = "client")] | ||
| use rmcp::{ | ||
| ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt, | ||
| model::{ | ||
| ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams, | ||
| ReadResourceResult, | ||
| }, | ||
| service::RequestContext, | ||
| }; | ||
| #[derive(Debug, Clone, Default)] | ||
| struct ResourceServer; | ||
| impl ServerHandler for ResourceServer { | ||
| async fn read_resource( | ||
| &self, | ||
| _request: ReadResourceRequestParams, | ||
| _context: RequestContext<RoleServer>, | ||
| ) -> Result<ReadResourceResult, ErrorData> { | ||
| Err(ErrorData::resource_not_found("resource not found", None)) | ||
| } | ||
| } | ||
| #[derive(Debug, Clone)] | ||
| struct VersionedClient { | ||
| protocol_version: ProtocolVersion, | ||
| } | ||
| impl ClientHandler for VersionedClient { | ||
| fn get_info(&self) -> ClientInfo { | ||
| let mut info = ClientInfo::default(); | ||
| info.protocol_version = self.protocol_version.clone(); | ||
| info | ||
| } | ||
| } | ||
| async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode { | ||
| let (server_transport, client_transport) = tokio::io::duplex(4096); | ||
| let server_handle = tokio::spawn(async move { | ||
| ResourceServer | ||
| .serve(server_transport) | ||
| .await? | ||
| .waiting() | ||
| .await?; | ||
| anyhow::Ok(()) | ||
| }); | ||
| let client = VersionedClient { | ||
| protocol_version: client_version, | ||
| } | ||
| .serve(client_transport) | ||
| .await | ||
| .expect("client should connect"); | ||
| let error = client | ||
| .read_resource(ReadResourceRequestParams::new("missing://resource")) | ||
| .await | ||
| .expect_err("missing resource should error"); | ||
| let code = match error { | ||
| ServiceError::McpError(data) => data.code, | ||
| other => panic!("expected McpError, got: {other:?}"), | ||
| }; | ||
| client.cancel().await.expect("client should cancel"); | ||
| server_handle.await.expect("server task").expect("server"); | ||
| code | ||
| } | ||
| #[tokio::test] | ||
| async fn legacy_version_gets_resource_not_found_code() { | ||
| assert_eq!( | ||
| not_found_code(ProtocolVersion::V_2025_11_25).await, | ||
| ErrorCode::RESOURCE_NOT_FOUND, | ||
| ); | ||
| } | ||
| #[tokio::test] | ||
| async fn sep_2164_version_gets_invalid_params_code() { | ||
| assert_eq!( | ||
| not_found_code(ProtocolVersion::V_2026_07_28).await, | ||
| ErrorCode::INVALID_PARAMS, | ||
| ); | ||
| } |
| #![cfg(all( | ||
| feature = "transport-streamable-http-client", | ||
| feature = "transport-streamable-http-client-reqwest", | ||
| not(feature = "local") | ||
| ))] | ||
| use std::{collections::HashMap, sync::Arc}; | ||
| use rmcp::{ | ||
| model::{ClientJsonRpcMessage, ClientNotification, InitializedNotification}, | ||
| transport::streamable_http_client::{StreamableHttpClient, StreamableHttpPostResponse}, | ||
| }; | ||
| async fn spawn_empty_ok_server() -> String { | ||
| use axum::{Router, http::StatusCode, routing::post}; | ||
| let router = Router::new().route("/mcp", post(|| async { StatusCode::OK })); | ||
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
| let addr = listener.local_addr().unwrap(); | ||
| tokio::spawn(async move { | ||
| axum::serve(listener, router).await.unwrap(); | ||
| }); | ||
| format!("http://{addr}/mcp") | ||
| } | ||
| #[tokio::test] | ||
| async fn empty_success_response_to_notification_is_accepted() { | ||
| let url = spawn_empty_ok_server().await; | ||
| let client = reqwest::Client::new(); | ||
| let result = client | ||
| .post_message( | ||
| Arc::from(url.as_str()), | ||
| ClientJsonRpcMessage::notification(ClientNotification::InitializedNotification( | ||
| InitializedNotification::default(), | ||
| )), | ||
| None, | ||
| None, | ||
| HashMap::new(), | ||
| ) | ||
| .await; | ||
| match result { | ||
| Ok(StreamableHttpPostResponse::Accepted) => {} | ||
| other => panic!("expected Accepted, got: {other:?}"), | ||
| } | ||
| } |
| #![cfg(not(feature = "local"))] | ||
| //! Regression tests for the `MCP-Protocol-Version` header / initialize body consistency check. | ||
| use rmcp::transport::streamable_http_server::{ | ||
| StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, | ||
| }; | ||
| use tokio_util::sync::CancellationToken; | ||
| mod common; | ||
| use common::calculator::Calculator; | ||
| fn init_body(body_version: &str) -> String { | ||
| format!( | ||
| r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"{body_version}","capabilities":{{}},"clientInfo":{{"name":"test","version":"1.0"}}}}}}"# | ||
| ) | ||
| } | ||
| async fn spawn_server( | ||
| config: StreamableHttpServerConfig, | ||
| ) -> (reqwest::Client, String, CancellationToken) { | ||
| let ct = config.cancellation_token.clone(); | ||
| let service: StreamableHttpService<Calculator, LocalSessionManager> = | ||
| StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), config); | ||
| let router = axum::Router::new().nest_service("/mcp", service); | ||
| let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
| let addr = tcp_listener.local_addr().unwrap(); | ||
| tokio::spawn({ | ||
| let ct = ct.clone(); | ||
| async move { | ||
| let _ = axum::serve(tcp_listener, router) | ||
| .with_graceful_shutdown(async move { ct.cancelled_owned().await }) | ||
| .await; | ||
| } | ||
| }); | ||
| let client = reqwest::Client::new(); | ||
| let base_url = format!("http://{addr}/mcp"); | ||
| (client, base_url, ct) | ||
| } | ||
| fn stateless_json_config() -> StreamableHttpServerConfig { | ||
| StreamableHttpServerConfig::default() | ||
| .with_stateful_mode(false) | ||
| .with_json_response(true) | ||
| .with_sse_keep_alive(None) | ||
| .with_cancellation_token(CancellationToken::new()) | ||
| } | ||
| fn stateful_config() -> StreamableHttpServerConfig { | ||
| StreamableHttpServerConfig::default() | ||
| .with_stateful_mode(true) | ||
| .with_sse_keep_alive(None) | ||
| .with_cancellation_token(CancellationToken::new()) | ||
| } | ||
| async fn post_init( | ||
| client: &reqwest::Client, | ||
| url: &str, | ||
| header: Option<&str>, | ||
| body_version: &str, | ||
| ) -> reqwest::Response { | ||
| let mut req = client | ||
| .post(url) | ||
| .header("Content-Type", "application/json") | ||
| .header("Accept", "application/json, text/event-stream") | ||
| .body(init_body(body_version)); | ||
| if let Some(h) = header { | ||
| req = req.header("MCP-Protocol-Version", h); | ||
| } | ||
| req.send().await.expect("send initialize request") | ||
| } | ||
| #[tokio::test] | ||
| async fn stateless_init_rejects_when_header_older_than_body() -> anyhow::Result<()> { | ||
| let (client, url, ct) = spawn_server(stateless_json_config()).await; | ||
| let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await; | ||
| assert_eq!(response.status(), 400); | ||
| let body: serde_json::Value = response.json().await?; | ||
| assert_eq!(body["error"]["code"], -32600); | ||
| assert!( | ||
| body["error"]["message"] | ||
| .as_str() | ||
| .unwrap_or_default() | ||
| .contains("MCP-Protocol-Version"), | ||
| "expected error message to mention the header, got: {body}" | ||
| ); | ||
| ct.cancel(); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn stateless_init_rejects_when_header_newer_than_body() -> anyhow::Result<()> { | ||
| let (client, url, ct) = spawn_server(stateless_json_config()).await; | ||
| let response = post_init(&client, &url, Some("2025-11-25"), "2025-03-26").await; | ||
| assert_eq!(response.status(), 400); | ||
| let body: serde_json::Value = response.json().await?; | ||
| assert_eq!(body["error"]["code"], -32600); | ||
| ct.cancel(); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn stateless_init_accepts_when_header_matches_body() -> anyhow::Result<()> { | ||
| let (client, url, ct) = spawn_server(stateless_json_config()).await; | ||
| let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await; | ||
| assert_eq!(response.status(), 200); | ||
| let body: serde_json::Value = response.json().await?; | ||
| assert!( | ||
| body["result"].is_object(), | ||
| "expected an InitializeResult, got: {body}" | ||
| ); | ||
| ct.cancel(); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn stateless_init_accepts_when_header_absent() -> anyhow::Result<()> { | ||
| let (client, url, ct) = spawn_server(stateless_json_config()).await; | ||
| let response = post_init(&client, &url, None, "2025-11-25").await; | ||
| assert_eq!(response.status(), 200); | ||
| ct.cancel(); | ||
| Ok(()) | ||
| } | ||
| #[tokio::test] | ||
| async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<()> { | ||
| let (client, url, ct) = spawn_server(stateful_config()).await; | ||
| let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await; | ||
| assert_eq!(response.status(), 400); | ||
| let body: serde_json::Value = response.json().await?; | ||
| assert_eq!(body["error"]["code"], -32600); | ||
| ct.cancel(); | ||
| Ok(()) | ||
| } |
| { | ||
| "git": { | ||
| "sha1": "3529c3675ff64db805bd947ca6ece6090809e43d" | ||
| "sha1": "25220361d5540715294c501c289d79de4bec2bfc" | ||
| }, | ||
| "path_in_vcs": "crates/rmcp" | ||
| } |
+33
-2
@@ -15,3 +15,3 @@ # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO | ||
| name = "rmcp" | ||
| version = "1.7.0" | ||
| version = "1.8.0" | ||
| build = "build.rs" | ||
@@ -243,2 +243,6 @@ autolib = false | ||
| [[test]] | ||
| name = "test_list_tools_result" | ||
| path = "tests/test_list_tools_result.rs" | ||
| [[test]] | ||
| name = "test_logging" | ||
@@ -303,2 +307,11 @@ path = "tests/test_logging.rs" | ||
| [[test]] | ||
| name = "test_request_timeout_progress" | ||
| path = "tests/test_request_timeout_progress.rs" | ||
| required-features = [ | ||
| "server", | ||
| "client", | ||
| "macros", | ||
| ] | ||
| [[test]] | ||
| name = "test_resource_link" | ||
@@ -312,2 +325,6 @@ path = "tests/test_resource_link.rs" | ||
| [[test]] | ||
| name = "test_resource_not_found_version" | ||
| path = "tests/test_resource_not_found_version.rs" | ||
| [[test]] | ||
| name = "test_sampling" | ||
@@ -357,2 +374,6 @@ path = "tests/test_sampling.rs" | ||
| [[test]] | ||
| name = "test_streamable_http_empty_2xx_notification" | ||
| path = "tests/test_streamable_http_empty_2xx_notification.rs" | ||
| [[test]] | ||
| name = "test_streamable_http_idle_timeout_log" | ||
@@ -386,2 +407,12 @@ path = "tests/test_streamable_http_idle_timeout_log.rs" | ||
| [[test]] | ||
| name = "test_streamable_http_protocol_version" | ||
| path = "tests/test_streamable_http_protocol_version.rs" | ||
| required-features = [ | ||
| "server", | ||
| "client", | ||
| "transport-streamable-http-server", | ||
| "reqwest", | ||
| ] | ||
| [[test]] | ||
| name = "test_streamable_http_session_store" | ||
@@ -561,3 +592,3 @@ path = "tests/test_streamable_http_session_store.rs" | ||
| [dependencies.rmcp-macros] | ||
| version = "1.7.0" | ||
| version = "1.8.0" | ||
| optional = true | ||
@@ -564,0 +595,0 @@ |
+32
-0
@@ -10,2 +10,34 @@ # Changelog | ||
| ## [1.8.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0) - 2026-06-22 | ||
| ### Added | ||
| - standardize resource-not-found error code (SEP-2164) ([#899](https://github.com/modelcontextprotocol/rust-sdk/pull/899)) | ||
| - validate OAuth authorization response issuer ([#896](https://github.com/modelcontextprotocol/rust-sdk/pull/896)) | ||
| - specify OIDC application_type during dynamic client registration (SEP-837) ([#883](https://github.com/modelcontextprotocol/rust-sdk/pull/883)) | ||
| - deprecate roots, sampling, and logging (SEP-2577) ([#884](https://github.com/modelcontextprotocol/rust-sdk/pull/884)) | ||
| ### Fixed | ||
| - *(auth)* preserve configured reqwest client ([#917](https://github.com/modelcontextprotocol/rust-sdk/pull/917)) | ||
| - *(auth)* align OAuth metadata discovery ordering ([#887](https://github.com/modelcontextprotocol/rust-sdk/pull/887)) | ||
| - align progress timeout token ([#909](https://github.com/modelcontextprotocol/rust-sdk/pull/909)) | ||
| - *(elicitation)* preserve enumNames through ElicitationSchema serde round-trip ([#905](https://github.com/modelcontextprotocol/rust-sdk/pull/905)) | ||
| - return tool errors for invalid arguments ([#894](https://github.com/modelcontextprotocol/rust-sdk/pull/894)) | ||
| - *(auth)* apply offline_access to reauth paths ([#897](https://github.com/modelcontextprotocol/rust-sdk/pull/897)) | ||
| - update peer info on duplicate initialize ([#862](https://github.com/modelcontextprotocol/rust-sdk/pull/862)) | ||
| - strip and validate tool outputSchema and inputSchema ([#860](https://github.com/modelcontextprotocol/rust-sdk/pull/860)) | ||
| - remove unnecessary fields from tools' inputSchema ([#856](https://github.com/modelcontextprotocol/rust-sdk/pull/856)) | ||
| - reject init header/body version mismatch ([#853](https://github.com/modelcontextprotocol/rust-sdk/pull/853)) | ||
| - align protocol version negotiation ([#855](https://github.com/modelcontextprotocol/rust-sdk/pull/855)) | ||
| - accept 200 with empty body in response to notifications in addition to 202 ([#849](https://github.com/modelcontextprotocol/rust-sdk/pull/849)) | ||
| ### Other | ||
| - Allow custom HTTP clients for OAuth ([#908](https://github.com/modelcontextprotocol/rust-sdk/pull/908)) | ||
| - Add progress-aware request timeout reset ([#858](https://github.com/modelcontextprotocol/rust-sdk/pull/858)) | ||
| - *(server)* document Err vs Ok(CallToolResult::error) visibility contract on ServerHandler::call_tool ([#854](https://github.com/modelcontextprotocol/rust-sdk/pull/854)) | ||
| - refine mcpmate listing copy ([#885](https://github.com/modelcontextprotocol/rust-sdk/pull/885)) | ||
| - added jilebi-mcp to the list of built with rmcp ([#861](https://github.com/modelcontextprotocol/rust-sdk/pull/861)) | ||
| ## [1.7.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.6.0...rmcp-v1.7.0) - 2026-05-13 | ||
@@ -12,0 +44,0 @@ |
@@ -25,3 +25,5 @@ use std::sync::Arc; | ||
| ) -> Result<<RoleServer as ServiceRole>::Resp, McpError> { | ||
| match request { | ||
| // `context` is moved into the dispatch below, so read the negotiated version first. | ||
| let protocol_version = context.protocol_version(); | ||
| let result = match request { | ||
| ClientRequest::InitializeRequest(request) => self | ||
@@ -131,3 +133,14 @@ .initialize(request.params, context) | ||
| .map(ServerResult::CancelTaskResult), | ||
| } | ||
| }; | ||
| // SEP-2164: peers negotiating 2026-07-28+ get the standard INVALID_PARAMS code for | ||
| // resource-not-found; older peers keep RESOURCE_NOT_FOUND. ISO `YYYY-MM-DD` versions | ||
| // compare lexically the same as chronologically. | ||
| let use_invalid_params = | ||
| protocol_version.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); | ||
| result.map_err(|mut error| { | ||
| if use_invalid_params && error.code == ErrorCode::RESOURCE_NOT_FOUND { | ||
| error.code = ErrorCode::INVALID_PARAMS; | ||
| } | ||
| error | ||
| }) | ||
| } | ||
@@ -189,5 +202,3 @@ | ||
| ) -> impl Future<Output = Result<InitializeResult, McpError>> + MaybeSendFuture + '_ { | ||
| if context.peer.peer_info().is_none() { | ||
| context.peer.set_peer_info(request); | ||
| } | ||
| context.peer.set_peer_info(request); | ||
| std::future::ready(Ok(self.get_info())) | ||
@@ -264,2 +275,30 @@ } | ||
| } | ||
| /// Handle a `tools/call` request from a client. | ||
| /// | ||
| /// # Choosing a return value | ||
| /// | ||
| /// MCP distinguishes two failure modes; the API forces you to pick | ||
| /// the right one explicitly because they reach the caller's UI very | ||
| /// differently: | ||
| /// | ||
| /// - `Ok(`[`CallToolResult::error`]`(...))` — the tool ran (or tried | ||
| /// to) and produced a failure the caller should see. The | ||
| /// `content` you supply is rendered in the caller's MCP client, | ||
| /// so the user gets your message. **This is the right return | ||
| /// value for almost every "the tool didn't work" path** — empty | ||
| /// results, validation failures the user can fix, downstream | ||
| /// service unavailability, etc. | ||
| /// | ||
| /// - `Err(`[`McpError`]`)` — a JSON-RPC protocol error. Use this | ||
| /// only when the request itself is unroutable: unknown tool | ||
| /// ([`ErrorCode::METHOD_NOT_FOUND`]), malformed request shape that | ||
| /// cannot be treated as a valid `tools/call`, or a server-internal | ||
| /// failure that means the server cannot serve any request right now | ||
| /// ([`ErrorCode::INTERNAL_ERROR`], `-32603`). MCP clients | ||
| /// typically render protocol errors opaquely; **the caller will | ||
| /// not see your message** — they see something like "Tool result | ||
| /// missing due to internal error". If you want the caller to read | ||
| /// your error, use `Ok(CallToolResult::error(...))`. | ||
| /// | ||
| /// See [`CallToolResult::error`] for a worked example. | ||
| fn call_tool( | ||
@@ -266,0 +305,0 @@ &self, |
+100
-34
| //! Common utilities shared between tool and prompt handlers | ||
| use std::{any::TypeId, collections::HashMap, sync::Arc}; | ||
| use std::{ | ||
| any::TypeId, | ||
| collections::HashMap, | ||
| sync::{Arc, LazyLock}, | ||
| }; | ||
@@ -33,8 +37,6 @@ use schemars::JsonSchema; | ||
| let object = serde_json::to_value(schema).expect("failed to serialize schema"); | ||
| let object = match object { | ||
| serde_json::Value::Object(object) => object, | ||
| _ => panic!( | ||
| "Schema serialization produced non-object value: expected JSON object but got {:?}", | ||
| object | ||
| ), | ||
| let serde_json::Value::Object(object) = object else { | ||
| panic!( | ||
| "Schema serialization produced non-object value: expected JSON object but got {object:?}" | ||
| ); | ||
| }; | ||
@@ -52,17 +54,59 @@ let schema = Arc::new(object); | ||
| // TODO: should be updated according to the new specifications | ||
| /// Validate that the schema root is `type: "object"` (per MCP spec) and strip top-level | ||
| /// `title`/`description` (the wrapper type name and doc, which are noise to the LLM). | ||
| fn validate_and_strip(raw: &Arc<JsonObject>, purpose: &str) -> Result<Arc<JsonObject>, String> { | ||
| match raw.get("type") { | ||
| Some(serde_json::Value::String(t)) if t == "object" => { | ||
| let mut object = raw.as_ref().clone(); | ||
| object.remove("title"); | ||
| object.remove("description"); | ||
| Ok(Arc::new(object)) | ||
| } | ||
| Some(serde_json::Value::String(t)) => Err(format!( | ||
| "MCP specification requires tool {purpose} to have root type 'object', but found '{t}'." | ||
| )), | ||
| None => Err(format!( | ||
| "Schema is missing 'type' field. MCP specification requires {purpose} to have root type 'object'." | ||
| )), | ||
| Some(other) => Err(format!( | ||
| "Schema 'type' field has unexpected format: {other:?}. Expected \"object\"." | ||
| )), | ||
| } | ||
| } | ||
| /// Generate, validate, and strip a JSON schema for inputSchema (must have root type "object"; | ||
| /// top-level "title" and "description" are removed). | ||
| pub fn schema_for_input<T: JsonSchema + std::any::Any>() -> Result<Arc<JsonObject>, String> { | ||
| thread_local! { | ||
| static CACHE_FOR_INPUT: std::sync::RwLock<HashMap<TypeId, Result<Arc<JsonObject>, String>>> = Default::default(); | ||
| }; | ||
| CACHE_FOR_INPUT.with(|cache| { | ||
| if let Some(result) = cache | ||
| .read() | ||
| .expect("input schema cache lock poisoned") | ||
| .get(&TypeId::of::<T>()) | ||
| { | ||
| return result.clone(); | ||
| } | ||
| let result = validate_and_strip(&schema_for_type::<T>(), "inputSchema"); | ||
| cache | ||
| .write() | ||
| .expect("input schema cache lock poisoned") | ||
| .insert(TypeId::of::<T>(), result.clone()); | ||
| result | ||
| }) | ||
| } | ||
| /// Schema used when input is empty. | ||
| pub fn schema_for_empty_input() -> Arc<JsonObject> { | ||
| std::sync::Arc::new( | ||
| serde_json::json!({ | ||
| "type": "object", | ||
| "properties": {} | ||
| }) | ||
| .as_object() | ||
| .unwrap() | ||
| .clone(), | ||
| ) | ||
| static EMPTY: LazyLock<Arc<JsonObject>> = LazyLock::new(|| { | ||
| let mut object = JsonObject::new(); | ||
| object.insert("type".into(), serde_json::json!("object")); | ||
| object.insert("properties".into(), serde_json::json!({})); | ||
| Arc::new(object) | ||
| }); | ||
| EMPTY.clone() | ||
| } | ||
| /// Generate and validate a JSON schema for outputSchema (must have root type "object"). | ||
| /// Generate a JSON schema for outputSchema (must have root type "object"; top-level "title" and "description" are removed) | ||
| pub fn schema_for_output<T: JsonSchema + std::any::Any>() -> Result<Arc<JsonObject>, String> { | ||
@@ -83,18 +127,4 @@ thread_local! { | ||
| // Generate and validate schema | ||
| let schema = schema_for_type::<T>(); | ||
| let result = match schema.get("type") { | ||
| Some(serde_json::Value::String(t)) if t == "object" => Ok(schema.clone()), | ||
| Some(serde_json::Value::String(t)) => Err(format!( | ||
| "MCP specification requires tool outputSchema to have root type 'object', but found '{}'.", | ||
| t | ||
| )), | ||
| None => Err( | ||
| "Schema is missing 'type' field. MCP specification requires outputSchema to have root type 'object'.".to_string() | ||
| ), | ||
| Some(other) => Err(format!( | ||
| "Schema 'type' field has unexpected format: {:?}. Expected \"object\".", | ||
| other | ||
| )), | ||
| }; | ||
| // Generate, validate, and strip unnecessary top-level fields | ||
| let result = validate_and_strip(&schema_for_type::<T>(), "outputSchema"); | ||
@@ -292,2 +322,38 @@ // Cache the result (both success and error cases) | ||
| } | ||
| #[test] | ||
| fn test_schema_for_output_strips_top_level_title() { | ||
| let schema = schema_for_output::<TestObject>().unwrap(); | ||
| assert!(!schema.contains_key("title")); | ||
| } | ||
| #[test] | ||
| fn test_schema_for_output_strips_top_level_description() { | ||
| let schema = schema_for_output::<TestObject>().unwrap(); | ||
| assert!(!schema.contains_key("description")); | ||
| } | ||
| #[test] | ||
| fn test_schema_for_input_rejects_primitive() { | ||
| let result = schema_for_input::<i32>(); | ||
| assert!(result.is_err()); | ||
| } | ||
| #[test] | ||
| fn test_schema_for_input_accepts_object() { | ||
| let result = schema_for_input::<TestObject>(); | ||
| assert!(result.is_ok()); | ||
| } | ||
| #[test] | ||
| fn test_schema_for_input_strips_top_level_title() { | ||
| let schema = schema_for_input::<TestObject>().unwrap(); | ||
| assert!(!schema.contains_key("title")); | ||
| } | ||
| #[test] | ||
| fn test_schema_for_input_strips_top_level_description() { | ||
| let schema = schema_for_input::<TestObject>().unwrap(); | ||
| assert!(!schema.contains_key("description")); | ||
| } | ||
| } |
@@ -136,9 +136,24 @@ //! Tools for MCP servers. | ||
| handler::server::{ | ||
| tool::{CallToolHandler, DynCallToolHandler, ToolCallContext, schema_for_type}, | ||
| common::schema_for_input, | ||
| tool::{CallToolHandler, DynCallToolHandler, ToolCallContext}, | ||
| tool_name_validation::validate_and_warn_tool_name, | ||
| }, | ||
| model::{CallToolResult, Tool, ToolAnnotations}, | ||
| model::{CallToolResult, Content, ErrorCode, Tool, ToolAnnotations}, | ||
| service::{MaybeBoxFuture, MaybeSend}, | ||
| }; | ||
| const TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX: &str = "failed to deserialize parameters:"; | ||
| fn into_tool_argument_error(error: crate::ErrorData) -> Result<CallToolResult, crate::ErrorData> { | ||
| if error.code == ErrorCode::INVALID_PARAMS | ||
| && error | ||
| .message | ||
| .starts_with(TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX) | ||
| { | ||
| return Ok(CallToolResult::error(vec![Content::text(error.message)])); | ||
| } | ||
| Err(error) | ||
| } | ||
| #[non_exhaustive] | ||
@@ -253,3 +268,5 @@ pub struct ToolRoute<S> { | ||
| "", | ||
| schema_for_type::<crate::model::JsonObject>(), | ||
| schema_for_input::<crate::model::JsonObject>().unwrap_or_else(|e| { | ||
| panic!("Invalid input schema for JsonObject: {e}"); | ||
| }), | ||
| ), | ||
@@ -291,3 +308,8 @@ call: self, | ||
| pub fn parameters<T: JsonSchema + 'static>(mut self) -> Self { | ||
| self.attr.input_schema = schema_for_type::<T>(); | ||
| self.attr.input_schema = schema_for_input::<T>().unwrap_or_else(|e| { | ||
| panic!( | ||
| "Invalid input schema for `{}`: {e}", | ||
| std::any::type_name::<T>() | ||
| ) | ||
| }); | ||
| self | ||
@@ -553,3 +575,6 @@ } | ||
| let result = (item.call)(context).await?; | ||
| let result = match (item.call)(context).await { | ||
| Ok(result) => result, | ||
| Err(error) => return into_tool_argument_error(error), | ||
| }; | ||
@@ -610,2 +635,3 @@ Ok(result) | ||
| RoleServer, | ||
| handler::server::wrapper::Parameters, | ||
| model::{CallToolRequestParams, ErrorCode, NumberOrString}, | ||
@@ -618,3 +644,54 @@ service::{AtomicU32RequestIdProvider, Peer, RequestContext}, | ||
| #[derive(serde::Deserialize, schemars::JsonSchema)] | ||
| struct RequiredParams { | ||
| project: String, | ||
| } | ||
| fn requires_params(Parameters(params): Parameters<RequiredParams>) -> String { | ||
| params.project | ||
| } | ||
| #[tokio::test] | ||
| async fn test_argument_deserialization_error_returns_tool_error_result() { | ||
| let service = DummyService; | ||
| let router = ToolRouter::new().with_route(ToolRoute::new( | ||
| crate::model::Tool::new( | ||
| "requires_params", | ||
| "requires params", | ||
| Arc::new(Default::default()), | ||
| ), | ||
| requires_params, | ||
| )); | ||
| let id_provider: Arc<dyn crate::service::RequestIdProvider> = | ||
| Arc::new(AtomicU32RequestIdProvider::default()); | ||
| let (peer, _rx) = Peer::<RoleServer>::new(id_provider, None); | ||
| let ctx = crate::handler::server::tool::ToolCallContext::new( | ||
| &service, | ||
| CallToolRequestParams { | ||
| meta: None, | ||
| name: Cow::Borrowed("requires_params"), | ||
| arguments: Some(Default::default()), | ||
| task: None, | ||
| }, | ||
| RequestContext::new(NumberOrString::Number(1), peer), | ||
| ); | ||
| let result = router | ||
| .call(ctx) | ||
| .await | ||
| .expect("argument validation should be a tool result"); | ||
| assert_eq!(result.is_error, Some(true)); | ||
| let text = result | ||
| .content | ||
| .first() | ||
| .and_then(|content| content.raw.as_text()) | ||
| .map(|text| text.text.as_str()) | ||
| .expect("tool error result should include text"); | ||
| assert!(text.contains("failed to deserialize parameters")); | ||
| assert!(text.contains("missing field `project`")); | ||
| } | ||
| #[tokio::test] | ||
| async fn test_call_disabled_tool_returns_error() { | ||
@@ -621,0 +698,0 @@ let service = DummyService; |
@@ -8,4 +8,4 @@ use std::{borrow::Cow, future::Future, sync::Arc}; | ||
| handler::server::{ | ||
| common::schema_for_empty_input, | ||
| tool::{schema_for_output, schema_for_type}, | ||
| common::{schema_for_empty_input, schema_for_input}, | ||
| tool::schema_for_output, | ||
| wrapper::{Json, Parameters}, | ||
@@ -53,3 +53,10 @@ }, | ||
| fn input_schema() -> Option<Arc<JsonObject>> { | ||
| Some(schema_for_type::<Parameters<Self::Parameter>>()) | ||
| Some( | ||
| schema_for_input::<Parameters<Self::Parameter>>().unwrap_or_else(|e| { | ||
| panic!( | ||
| "Invalid input schema for ToolBase::Parameter type `{0}`: {e}", | ||
| std::any::type_name::<Self::Parameter>(), | ||
| ); | ||
| }), | ||
| ) | ||
| } | ||
@@ -56,0 +63,0 @@ |
@@ -13,3 +13,3 @@ use std::{ | ||
| pub use super::{ | ||
| common::{Extension, RequestId, schema_for_output, schema_for_type}, | ||
| common::{Extension, RequestId, schema_for_input, schema_for_output, schema_for_type}, | ||
| router::tool::{ToolRoute, ToolRouter}, | ||
@@ -16,0 +16,0 @@ }; |
@@ -63,2 +63,5 @@ use std::collections::BTreeMap; | ||
| /// Roots capability. Deprecated by SEP-2577; remains functional and will be | ||
| /// removed in a future release. | ||
| /// See <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>. | ||
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] | ||
@@ -101,2 +104,5 @@ #[serde(rename_all = "camelCase")] | ||
| /// Sampling task capability. Deprecated by SEP-2577; remains functional and | ||
| /// will be removed in a future release. | ||
| /// See <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>. | ||
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] | ||
@@ -236,2 +242,6 @@ #[serde(rename_all = "camelCase")] | ||
| /// Sampling capability with optional sub-capabilities (SEP-1577). | ||
| /// | ||
| /// Deprecated by SEP-2577; remains functional and will be removed in a future | ||
| /// release. | ||
| /// See <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>. | ||
| #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] | ||
@@ -256,4 +266,2 @@ #[serde(rename_all = "camelCase")] | ||
| /// .enable_experimental() | ||
| /// .enable_roots() | ||
| /// .enable_roots_list_changed() | ||
| /// .build(); | ||
@@ -273,5 +281,6 @@ /// ``` | ||
| pub extensions: Option<ExtensionCapabilities>, | ||
| /// Capability for filesystem roots (deprecated by SEP-2577). | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub roots: Option<RootsCapabilities>, | ||
| /// Capability for LLM sampling requests (SEP-1577) | ||
| /// Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577). | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
@@ -291,3 +300,2 @@ pub sampling: Option<SamplingCapability>, | ||
| /// let cap = ServerCapabilities::builder() | ||
| /// .enable_logging() | ||
| /// .enable_experimental() | ||
@@ -313,2 +321,3 @@ /// .enable_prompts() | ||
| pub extensions: Option<ExtensionCapabilities>, | ||
| /// Capability for server log message notifications (deprecated by SEP-2577). | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
@@ -330,3 +339,3 @@ pub logging: Option<JsonObject>, | ||
| macro_rules! builder { | ||
| ($Target: ident {$($f: ident: $T: ty),* $(,)?}) => { | ||
| ($Target: ident {$($(#[$fa:meta])* $f: ident: $T: ty),* $(,)?}) => { | ||
| paste! { | ||
@@ -363,16 +372,16 @@ #[derive(Default, Clone, Copy, Debug)] | ||
| } | ||
| builder!($Target @toggle $($f: $T,) *); | ||
| builder!($Target @toggle $($(#[$fa])* $f: $T,)*); | ||
| }; | ||
| ($Target: ident @toggle $f0: ident: $T0: ty, $($f: ident: $T: ty,)*) => { | ||
| builder!($Target @toggle [][$f0: $T0][$($f: $T,)*]); | ||
| ($Target: ident @toggle $(#[$fa0:meta])* $f0: ident: $T0: ty, $($(#[$fa:meta])* $f: ident: $T: ty,)*) => { | ||
| builder!($Target @toggle [][$(#[$fa0])* $f0: $T0][$($(#[$fa])* $f: $T,)*]); | ||
| }; | ||
| ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$fn: ident: $TN: ty][$fn_1: ident: $Tn_1: ty, $($ft: ident: $Tt: ty,)*]) => { | ||
| builder!($Target @impl_toggle [$($ff: $Tf,)*][$fn: $TN][$fn_1: $Tn_1, $($ft:$Tt,)*]); | ||
| builder!($Target @toggle [$($ff: $Tf,)* $fn: $TN,][$fn_1: $Tn_1][$($ft:$Tt,)*]); | ||
| ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][$(#[$fn1a:meta])* $fn_1: ident: $Tn_1: ty, $($(#[$fta:meta])* $ft: ident: $Tt: ty,)*]) => { | ||
| builder!($Target @impl_toggle [$($ff: $Tf,)*][$(#[$fna])* $fn: $TN][$fn_1: $Tn_1, $($ft:$Tt,)*]); | ||
| builder!($Target @toggle [$($ff: $Tf,)* $fn: $TN,][$(#[$fn1a])* $fn_1: $Tn_1][$($(#[$fta])* $ft: $Tt,)*]); | ||
| }; | ||
| ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$fn: ident: $TN: ty][]) => { | ||
| builder!($Target @impl_toggle [$($ff: $Tf,)*][$fn: $TN][]); | ||
| ($Target: ident @toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][]) => { | ||
| builder!($Target @impl_toggle [$($ff: $Tf,)*][$(#[$fna])* $fn: $TN][]); | ||
| }; | ||
| ($Target: ident @impl_toggle [$($ff: ident: $Tf: ty,)*][$fn: ident: $TN: ty][$($ft: ident: $Tt: ty,)*]) => { | ||
| ($Target: ident @impl_toggle [$($ff: ident: $Tf: ty,)*][$(#[$fna:meta])* $fn: ident: $TN: ty][$($ft: ident: $Tt: ty,)*]) => { | ||
| paste! { | ||
@@ -387,2 +396,3 @@ impl< | ||
| >> { | ||
| $(#[$fna])* | ||
| pub fn [<enable_ $fn>](self) -> [<$Target Builder>]<[<$Target BuilderState>]< | ||
@@ -400,2 +410,3 @@ $([<$ff:upper>],)* | ||
| } | ||
| $(#[$fna])* | ||
| pub fn [<enable_ $fn _with>](self, $fn: $TN) -> [<$Target Builder>]<[<$Target BuilderState>]< | ||
@@ -445,2 +456,6 @@ $([<$ff:upper>],)* | ||
| extensions: ExtensionCapabilities, | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| logging: JsonObject, | ||
@@ -524,3 +539,11 @@ completions: JsonObject, | ||
| extensions: ExtensionCapabilities, | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| roots: RootsCapabilities, | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| sampling: SamplingCapability, | ||
@@ -536,2 +559,6 @@ elicitation: ElicitationCapability, | ||
| { | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| pub fn enable_roots_list_changed(mut self) -> Self { | ||
@@ -550,2 +577,6 @@ if let Some(c) = self.roots.as_mut() { | ||
| /// Enable tool calling in sampling requests | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| pub fn enable_sampling_tools(mut self) -> Self { | ||
@@ -559,2 +590,6 @@ if let Some(c) = self.sampling.as_mut() { | ||
| /// Enable context inclusion in sampling (soft-deprecated) | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| pub fn enable_sampling_context(mut self) -> Self { | ||
@@ -590,2 +625,3 @@ if let Some(c) = self.sampling.as_mut() { | ||
| #[test] | ||
| #[allow(deprecated)] | ||
| fn test_builder() { | ||
@@ -693,2 +729,3 @@ let builder = <ServerCapabilitiesBuilder>::default() | ||
| #[test] | ||
| #[allow(deprecated)] | ||
| fn test_client_extensions_capability() { | ||
@@ -695,0 +732,0 @@ // Test building ClientCapabilities with extensions (MCP Apps support) |
@@ -333,3 +333,4 @@ use std::{borrow::Cow, sync::Arc}; | ||
| pub fn with_input_schema<T: JsonSchema + 'static>(mut self) -> Self { | ||
| self.input_schema = crate::handler::server::tool::schema_for_type::<T>(); | ||
| self.input_schema = crate::handler::server::tool::schema_for_input::<T>() | ||
| .unwrap_or_else(|e| panic!("Invalid input schema for tool '{}': {}", self.name, e)); | ||
| self | ||
@@ -336,0 +337,0 @@ } |
+251
-33
@@ -46,3 +46,7 @@ use futures::FutureExt; | ||
| #[cfg(feature = "server")] | ||
| use crate::model::ClientNotification; | ||
| #[cfg(feature = "server")] | ||
| use crate::model::ServerJsonRpcMessage; | ||
| #[cfg(feature = "client")] | ||
| use crate::model::ServerNotification; | ||
| use crate::{ | ||
@@ -303,3 +307,33 @@ error::ErrorData as McpError, | ||
| #[doc(hidden)] | ||
| pub trait ProgressNotificationToken { | ||
| fn progress_token(&self) -> Option<&ProgressToken>; | ||
| } | ||
| #[cfg(feature = "server")] | ||
| impl ProgressNotificationToken for ClientNotification { | ||
| fn progress_token(&self) -> Option<&ProgressToken> { | ||
| match self { | ||
| ClientNotification::ProgressNotification(notification) => { | ||
| Some(¬ification.params.progress_token) | ||
| } | ||
| _ => None, | ||
| } | ||
| } | ||
| } | ||
| #[cfg(feature = "client")] | ||
| impl ProgressNotificationToken for ServerNotification { | ||
| fn progress_token(&self) -> Option<&ProgressToken> { | ||
| match self { | ||
| ServerNotification::ProgressNotification(notification) => { | ||
| Some(¬ification.params.progress_token) | ||
| } | ||
| _ => None, | ||
| } | ||
| } | ||
| } | ||
| type Responder<T> = tokio::sync::oneshot::Sender<T>; | ||
| type ProgressTimeoutWatchers = Arc<tokio::sync::RwLock<HashMap<ProgressToken, mpsc::Sender<()>>>>; | ||
@@ -319,2 +353,3 @@ /// A handle to a remote request | ||
| pub progress_token: ProgressToken, | ||
| progress_reset_rx: Option<mpsc::Receiver<()>>, | ||
| } | ||
@@ -324,27 +359,109 @@ | ||
| pub const REQUEST_TIMEOUT_REASON: &str = "request timeout"; | ||
| pub async fn await_response(self) -> Result<R::PeerResp, ServiceError> { | ||
| if let Some(timeout) = self.options.timeout { | ||
| let timeout_result = tokio::time::timeout(timeout, async move { | ||
| self.rx.await.map_err(|_e| ServiceError::TransportClosed)? | ||
| }) | ||
| .await; | ||
| match timeout_result { | ||
| Ok(response) => response, | ||
| pub const REQUEST_MAX_TOTAL_TIMEOUT_REASON: &str = "maximum total timeout exceeded"; | ||
| pub async fn await_response(mut self) -> Result<R::PeerResp, ServiceError> { | ||
| let timeout = self.options.timeout; | ||
| let max_total_timeout = self.options.max_total_timeout; | ||
| let reset_timeout_on_progress = self.options.reset_timeout_on_progress; | ||
| let has_progress_reset_rx = self.progress_reset_rx.is_some(); | ||
| let progress_token = self.progress_token.clone(); | ||
| let result = match (timeout, max_total_timeout, reset_timeout_on_progress) { | ||
| (Some(timeout), None, false) => match tokio::time::timeout(timeout, &mut self.rx).await | ||
| { | ||
| Ok(response) => response.map_err(|_e| ServiceError::TransportClosed)?, | ||
| Err(_) => { | ||
| let error = Err(ServiceError::Timeout { timeout }); | ||
| // cancel this request | ||
| let notification = CancelledNotification { | ||
| params: CancelledNotificationParam { | ||
| request_id: self.id, | ||
| reason: Some(Self::REQUEST_TIMEOUT_REASON.to_owned()), | ||
| }, | ||
| method: crate::model::CancelledNotificationMethod, | ||
| extensions: Default::default(), | ||
| }; | ||
| let _ = self.peer.send_notification(notification.into()).await; | ||
| self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON) | ||
| .await; | ||
| error | ||
| } | ||
| }, | ||
| (None, None, _) => (&mut self.rx) | ||
| .await | ||
| .map_err(|_e| ServiceError::TransportClosed)?, | ||
| _ => { | ||
| self.await_response_with_progress_timeout( | ||
| timeout, | ||
| max_total_timeout, | ||
| reset_timeout_on_progress, | ||
| ) | ||
| .await | ||
| } | ||
| } else { | ||
| self.rx.await.map_err(|_e| ServiceError::TransportClosed)? | ||
| }; | ||
| Self::cleanup_progress_timeout_watcher( | ||
| &self.peer.progress_timeout_watchers, | ||
| &progress_token, | ||
| has_progress_reset_rx, | ||
| ) | ||
| .await; | ||
| result | ||
| } | ||
| async fn send_timeout_cancel_notification(&self, reason: &str) { | ||
| let notification = CancelledNotification { | ||
| params: CancelledNotificationParam { | ||
| request_id: self.id.clone(), | ||
| reason: Some(reason.to_owned()), | ||
| }, | ||
| method: crate::model::CancelledNotificationMethod, | ||
| extensions: Default::default(), | ||
| }; | ||
| let _ = self.peer.send_notification(notification.into()).await; | ||
| } | ||
| async fn await_response_with_progress_timeout( | ||
| &mut self, | ||
| timeout: Option<Duration>, | ||
| max_total_timeout: Option<Duration>, | ||
| reset_timeout_on_progress: bool, | ||
| ) -> Result<R::PeerResp, ServiceError> { | ||
| let mut idle_sleep = | ||
| timeout.map(|timeout| (timeout, Box::pin(tokio::time::sleep(timeout)))); | ||
| let mut max_total_sleep = | ||
| max_total_timeout.map(|timeout| (timeout, Box::pin(tokio::time::sleep(timeout)))); | ||
| loop { | ||
| tokio::select! { | ||
| biased; | ||
| response = &mut self.rx => { | ||
| return response.map_err(|_e| ServiceError::TransportClosed)?; | ||
| } | ||
| _ = async { | ||
| if let Some((_, sleep)) = idle_sleep.as_mut() { | ||
| sleep.as_mut().await; | ||
| } | ||
| }, if idle_sleep.is_some() => { | ||
| if let Some((timeout, _)) = idle_sleep.as_ref() { | ||
| self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON).await; | ||
| return Err(ServiceError::Timeout { timeout: *timeout }); | ||
| } | ||
| } | ||
| _ = async { | ||
| if let Some((_, sleep)) = max_total_sleep.as_mut() { | ||
| sleep.as_mut().await; | ||
| } | ||
| }, if max_total_sleep.is_some() => { | ||
| if let Some((timeout, _)) = max_total_sleep.as_ref() { | ||
| self.send_timeout_cancel_notification(Self::REQUEST_MAX_TOTAL_TIMEOUT_REASON).await; | ||
| return Err(ServiceError::Timeout { timeout: *timeout }); | ||
| } | ||
| } | ||
| progress = async { | ||
| match self.progress_reset_rx.as_mut() { | ||
| Some(rx) => rx.recv().await, | ||
| None => None, | ||
| } | ||
| }, if reset_timeout_on_progress && idle_sleep.is_some() && self.progress_reset_rx.is_some() => { | ||
| if progress.is_some() { | ||
| if let Some((timeout, sleep)) = idle_sleep.as_mut() { | ||
| sleep.as_mut().reset(tokio::time::Instant::now() + *timeout); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
@@ -355,2 +472,8 @@ } | ||
| pub async fn cancel(self, reason: Option<String>) -> Result<(), ServiceError> { | ||
| Self::cleanup_progress_timeout_watcher( | ||
| &self.peer.progress_timeout_watchers, | ||
| &self.progress_token, | ||
| self.progress_reset_rx.is_some(), | ||
| ) | ||
| .await; | ||
| let notification = CancelledNotification { | ||
@@ -367,2 +490,15 @@ params: CancelledNotificationParam { | ||
| } | ||
| async fn cleanup_progress_timeout_watcher( | ||
| progress_timeout_watchers: &ProgressTimeoutWatchers, | ||
| progress_token: &ProgressToken, | ||
| has_progress_reset_rx: bool, | ||
| ) { | ||
| if has_progress_reset_rx { | ||
| progress_timeout_watchers | ||
| .write() | ||
| .await | ||
| .remove(progress_token); | ||
| } | ||
| } | ||
| } | ||
@@ -393,3 +529,4 @@ | ||
| progress_token_provider: Arc<dyn ProgressTokenProvider>, | ||
| info: Arc<tokio::sync::OnceCell<R::PeerInfo>>, | ||
| progress_timeout_watchers: ProgressTimeoutWatchers, | ||
| info: Arc<std::sync::RwLock<Option<Arc<R::PeerInfo>>>>, | ||
| } | ||
@@ -413,2 +550,6 @@ | ||
| pub meta: Option<Meta>, | ||
| /// Reset the request timeout when a matching progress notification is received. | ||
| pub reset_timeout_on_progress: bool, | ||
| /// Maximum total time to wait for the request, regardless of progress notifications. | ||
| pub max_total_timeout: Option<Duration>, | ||
| } | ||
@@ -420,2 +561,19 @@ | ||
| } | ||
| pub fn with_timeout(timeout: Duration) -> Self { | ||
| Self { | ||
| timeout: Some(timeout), | ||
| ..Self::default() | ||
| } | ||
| } | ||
| pub fn reset_timeout_on_progress(mut self) -> Self { | ||
| self.reset_timeout_on_progress = true; | ||
| self | ||
| } | ||
| pub fn with_max_total_timeout(mut self, timeout: Duration) -> Self { | ||
| self.max_total_timeout = Some(timeout); | ||
| self | ||
| } | ||
| } | ||
@@ -435,3 +593,4 @@ | ||
| progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()), | ||
| info: Arc::new(tokio::sync::OnceCell::new_with(peer_info)), | ||
| progress_timeout_watchers: Default::default(), | ||
| info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), | ||
| }, | ||
@@ -474,10 +633,21 @@ rx, | ||
| let progress_token = self.progress_token_provider.next_progress_token(); | ||
| if let Some(meta) = options.meta.clone() { | ||
| request.get_meta_mut().extend(meta); | ||
| } | ||
| request | ||
| .get_meta_mut() | ||
| .set_progress_token(progress_token.clone()); | ||
| if let Some(meta) = options.meta.clone() { | ||
| request.get_meta_mut().extend(meta); | ||
| } | ||
| let (responder, receiver) = tokio::sync::oneshot::channel(); | ||
| self.tx | ||
| let progress_reset_rx = if options.reset_timeout_on_progress && options.timeout.is_some() { | ||
| let (sender, receiver) = mpsc::channel(1); | ||
| self.progress_timeout_watchers | ||
| .write() | ||
| .await | ||
| .insert(progress_token.clone(), sender); | ||
| Some(receiver) | ||
| } else { | ||
| None | ||
| }; | ||
| if self | ||
| .tx | ||
| .send(PeerSinkMessage::Request { | ||
@@ -489,3 +659,12 @@ request, | ||
| .await | ||
| .map_err(|_m| ServiceError::TransportClosed)?; | ||
| .is_err() | ||
| { | ||
| if progress_reset_rx.is_some() { | ||
| self.progress_timeout_watchers | ||
| .write() | ||
| .await | ||
| .remove(&progress_token); | ||
| } | ||
| return Err(ServiceError::TransportClosed); | ||
| } | ||
| Ok(RequestHandle { | ||
@@ -497,14 +676,37 @@ id, | ||
| peer: self.clone(), | ||
| progress_reset_rx, | ||
| }) | ||
| } | ||
| pub fn peer_info(&self) -> Option<&R::PeerInfo> { | ||
| self.info.get() | ||
| async fn notify_progress_timeout_watcher(&self, progress_token: &ProgressToken) { | ||
| let sender = self | ||
| .progress_timeout_watchers | ||
| .read() | ||
| .await | ||
| .get(progress_token) | ||
| .cloned(); | ||
| if let Some(sender) = sender { | ||
| match sender.try_send(()) { | ||
| Ok(()) => {} | ||
| Err(mpsc::error::TrySendError::Full(_)) => { | ||
| tracing::trace!(?progress_token, "progress timeout watcher channel is full"); | ||
| } | ||
| Err(mpsc::error::TrySendError::Closed(_)) => { | ||
| self.progress_timeout_watchers | ||
| .write() | ||
| .await | ||
| .remove(progress_token); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| /// Snapshot of the peer's handshake info. | ||
| pub fn peer_info(&self) -> Option<Arc<R::PeerInfo>> { | ||
| self.info.read().expect("peer info lock poisoned").clone() | ||
| } | ||
| /// Stores the peer's handshake info, overwriting any previous value. | ||
| pub fn set_peer_info(&self, info: R::PeerInfo) { | ||
| if self.info.initialized() { | ||
| tracing::warn!("trying to set peer info, which is already initialized"); | ||
| } else { | ||
| let _ = self.info.set(info); | ||
| } | ||
| *self.info.write().expect("peer info lock poisoned") = Some(Arc::new(info)); | ||
| } | ||
@@ -690,2 +892,12 @@ | ||
| #[cfg(feature = "server")] | ||
| impl RequestContext<RoleServer> { | ||
| /// The protocol version the client negotiated, or `None` before peer info is recorded. | ||
| pub fn protocol_version(&self) -> Option<crate::model::ProtocolVersion> { | ||
| self.peer | ||
| .peer_info() | ||
| .map(|info| info.protocol_version.clone()) | ||
| } | ||
| } | ||
| /// Request execution context | ||
@@ -709,2 +921,3 @@ #[derive(Debug, Clone)] | ||
| R: ServiceRole, | ||
| R::PeerNot: ProgressNotificationToken, | ||
| S: Service<R>, | ||
@@ -726,2 +939,3 @@ T: IntoTransport<R, E, A>, | ||
| R: ServiceRole, | ||
| R::PeerNot: ProgressNotificationToken, | ||
| S: Service<R>, | ||
@@ -767,2 +981,3 @@ T: IntoTransport<R, E, A>, | ||
| R: ServiceRole, | ||
| R::PeerNot: ProgressNotificationToken, | ||
| S: Service<R>, | ||
@@ -1014,2 +1229,5 @@ T: Transport<R> + 'static, | ||
| }; | ||
| if let Some(progress_token) = notification.progress_token() { | ||
| peer.notify_progress_timeout_watcher(progress_token).await; | ||
| } | ||
| { | ||
@@ -1016,0 +1234,0 @@ let service = shared_service.clone(); |
@@ -273,3 +273,4 @@ use std::borrow::Cow; | ||
| macro_rules! method { | ||
| (peer_req $method:ident $Req:ident() => $Resp: ident ) => { | ||
| ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self) -> Result<$Resp, ServiceError> { | ||
@@ -287,3 +288,4 @@ let result = self | ||
| }; | ||
| (peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { | ||
| ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> { | ||
@@ -303,3 +305,4 @@ let result = self | ||
| }; | ||
| (peer_req $method:ident $Req:ident($Param: ident)? => $Resp: ident ) => { | ||
| ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)? => $Resp: ident ) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self, params: Option<$Param>) -> Result<$Resp, ServiceError> { | ||
@@ -319,3 +322,4 @@ let result = self | ||
| }; | ||
| (peer_req $method:ident $Req:ident($Param: ident)) => { | ||
| ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { | ||
@@ -336,3 +340,4 @@ let result = self | ||
| (peer_not $method:ident $Not:ident($Param: ident)) => { | ||
| ($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { | ||
@@ -348,3 +353,4 @@ self.send_notification(ClientNotification::$Not($Not { | ||
| }; | ||
| (peer_not $method:ident $Not:ident) => { | ||
| ($(#[$meta:meta])* peer_not $method:ident $Not:ident) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self) -> Result<(), ServiceError> { | ||
@@ -363,3 +369,9 @@ self.send_notification(ClientNotification::$Not($Not { | ||
| method!(peer_req complete CompleteRequest(CompleteRequestParams) => CompleteResult); | ||
| method!(peer_req set_level SetLevelRequest(SetLevelRequestParams)); | ||
| method!( | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| peer_req set_level SetLevelRequest(SetLevelRequestParams) | ||
| ); | ||
| method!(peer_req get_prompt GetPromptRequest(GetPromptRequestParams) => GetPromptResult); | ||
@@ -366,0 +378,0 @@ method!(peer_req list_prompts ListPromptsRequest(PaginatedRequestParams)? => ListPromptsResult); |
+61
-19
@@ -72,2 +72,6 @@ use std::borrow::Cow; | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Negotiation now falls back to the server-configured version. This variant is never constructed and will be removed in a future major release." | ||
| )] | ||
| #[error("unsupported protocol version: {0}")] | ||
@@ -159,2 +163,19 @@ UnsupportedProtocolVersion(ProtocolVersion), | ||
| /// Echoes the client-requested version if known; otherwise returns `server_fallback`. | ||
| fn negotiate_protocol_version( | ||
| client_requested: &ProtocolVersion, | ||
| server_fallback: ProtocolVersion, | ||
| ) -> ProtocolVersion { | ||
| if ProtocolVersion::KNOWN_VERSIONS.contains(client_requested) { | ||
| client_requested.clone() | ||
| } else { | ||
| tracing::warn!( | ||
| client_requested = %client_requested, | ||
| server_fallback = %server_fallback, | ||
| "client requested unsupported protocol version; falling back to server default" | ||
| ); | ||
| server_fallback | ||
| } | ||
| } | ||
| async fn serve_server_with_ct_inner<S, T>( | ||
@@ -232,12 +253,6 @@ service: S, | ||
| }; | ||
| let peer_protocol_version = peer_info.params.protocol_version.clone(); | ||
| let protocol_version = match peer_protocol_version | ||
| .partial_cmp(&init_response.protocol_version) | ||
| .ok_or(ServerInitializeError::UnsupportedProtocolVersion( | ||
| peer_protocol_version, | ||
| ))? { | ||
| std::cmp::Ordering::Less => peer_info.params.protocol_version.clone(), | ||
| _ => init_response.protocol_version, | ||
| }; | ||
| init_response.protocol_version = protocol_version; | ||
| init_response.protocol_version = negotiate_protocol_version( | ||
| &peer_info.params.protocol_version, | ||
| init_response.protocol_version, | ||
| ); | ||
| transport | ||
@@ -263,3 +278,4 @@ .send(ServerJsonRpcMessage::response( | ||
| macro_rules! method { | ||
| (peer_req $method:ident $Req:ident() => $Resp: ident ) => { | ||
| ($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self) -> Result<$Resp, ServiceError> { | ||
@@ -278,3 +294,4 @@ let result = self | ||
| }; | ||
| (peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { | ||
| ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> { | ||
@@ -294,3 +311,4 @@ let result = self | ||
| }; | ||
| (peer_req $method:ident $Req:ident($Param: ident)) => { | ||
| ($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => { | ||
| $(#[$meta])* | ||
| pub fn $method( | ||
@@ -315,3 +333,4 @@ &self, | ||
| (peer_not $method:ident $Not:ident($Param: ident)) => { | ||
| ($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> { | ||
@@ -327,3 +346,4 @@ self.send_notification(ServerNotification::$Not($Not { | ||
| }; | ||
| (peer_not $method:ident $Not:ident) => { | ||
| ($(#[$meta:meta])* peer_not $method:ident $Not:ident) => { | ||
| $(#[$meta])* | ||
| pub async fn $method(&self) -> Result<(), ServiceError> { | ||
@@ -340,3 +360,4 @@ self.send_notification(ServerNotification::$Not($Not { | ||
| // Timeout-only variants (base method should be created separately with peer_req) | ||
| (peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => { | ||
| ($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => { | ||
| $(#[$meta])* | ||
| pub async fn $method_with_timeout( | ||
@@ -353,2 +374,4 @@ &self, | ||
| meta: None, | ||
| reset_timeout_on_progress: false, | ||
| max_total_timeout: None, | ||
| }; | ||
@@ -367,3 +390,4 @@ let result = self | ||
| (peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => { | ||
| ($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => { | ||
| $(#[$meta])* | ||
| pub async fn $method_with_timeout( | ||
@@ -382,2 +406,4 @@ &self, | ||
| meta: None, | ||
| reset_timeout_on_progress: false, | ||
| max_total_timeout: None, | ||
| }; | ||
@@ -412,2 +438,6 @@ let result = self | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| pub async fn create_message( | ||
@@ -442,3 +472,9 @@ &self, | ||
| } | ||
| method!(peer_req list_roots ListRootsRequest() => ListRootsResult); | ||
| method!( | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| peer_req list_roots ListRootsRequest() => ListRootsResult | ||
| ); | ||
| #[cfg(feature = "elicitation")] | ||
@@ -453,3 +489,9 @@ method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult); | ||
| method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); | ||
| method!(peer_not notify_logging_message LoggingMessageNotification(LoggingMessageNotificationParam)); | ||
| method!( | ||
| #[deprecated( | ||
| since = "1.8.0", | ||
| note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577" | ||
| )] | ||
| peer_not notify_logging_message LoggingMessageNotification(LoggingMessageNotificationParam) | ||
| ); | ||
| method!(peer_not notify_resource_updated ResourceUpdatedNotification(ResourceUpdatedNotificationParam)); | ||
@@ -456,0 +498,0 @@ method!(peer_not notify_resource_list_changed ResourceListChangedNotification); |
+3
-2
@@ -104,4 +104,5 @@ //! # Transport | ||
| ClientCredentialsConfig, CredentialStore, EXTENSION_OAUTH_CLIENT_CREDENTIALS, | ||
| InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore, | ||
| StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams, | ||
| InMemoryCredentialStore, InMemoryStateStore, OAuthHttpClient, OAuthHttpClientError, | ||
| OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, | ||
| StateStore, StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams, | ||
| }; | ||
@@ -108,0 +109,0 @@ |
@@ -181,2 +181,3 @@ use std::{borrow::Cow, collections::HashMap, sync::Arc}; | ||
| .map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string()); | ||
| let content_length = response.content_length(); | ||
| let session_id = response | ||
@@ -187,2 +188,15 @@ .headers() | ||
| .map(|s| s.to_string()); | ||
| // Spec requires 202 Accepted for these, but some servers return an empty 200. | ||
| // Treat empty success responses as equivalent to Accepted. | ||
| if status.is_success() | ||
| && content_length == Some(0) | ||
| && matches!( | ||
| message, | ||
| ClientJsonRpcMessage::Notification(_) | ||
| | ClientJsonRpcMessage::Response(_) | ||
| | ClientJsonRpcMessage::Error(_) | ||
| ) | ||
| { | ||
| return Ok(StreamableHttpPostResponse::Accepted); | ||
| } | ||
| // Non-success responses may carry valid JSON-RPC error payloads that | ||
@@ -189,0 +203,0 @@ // should be surfaced as McpError rather than lost in TransportSend. |
@@ -260,2 +260,7 @@ use std::{borrow::Cow, collections::HashMap, sync::Arc}; | ||
| let content_type = response.headers().get(http::header::CONTENT_TYPE).cloned(); | ||
| let content_length = response | ||
| .headers() | ||
| .get(http::header::CONTENT_LENGTH) | ||
| .and_then(|v| v.to_str().ok()) | ||
| .and_then(|v| v.parse::<u64>().ok()); | ||
| let session_id = response | ||
@@ -267,2 +272,14 @@ .headers() | ||
| if status.is_success() | ||
| && content_length == Some(0) | ||
| && matches!( | ||
| message, | ||
| ClientJsonRpcMessage::Notification(_) | ||
| | ClientJsonRpcMessage::Response(_) | ||
| | ClientJsonRpcMessage::Error(_) | ||
| ) | ||
| { | ||
| return Ok(StreamableHttpPostResponse::Accepted); | ||
| } | ||
| match content_type { | ||
@@ -269,0 +286,0 @@ Some(ref ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { |
@@ -1,2 +0,4 @@ | ||
| use std::{collections::HashMap, convert::Infallible, fmt::Display, sync::Arc, time::Duration}; | ||
| use std::{ | ||
| borrow::Cow, collections::HashMap, convert::Infallible, fmt::Display, sync::Arc, time::Duration, | ||
| }; | ||
@@ -17,4 +19,4 @@ use bytes::Bytes; | ||
| model::{ | ||
| ClientJsonRpcMessage, ClientNotification, ClientRequest, GetExtensions, InitializeRequest, | ||
| InitializedNotification, ProtocolVersion, | ||
| ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, | ||
| InitializeRequest, InitializedNotification, JsonRpcError, ProtocolVersion, RequestId, | ||
| }, | ||
@@ -213,2 +215,50 @@ serve_server, | ||
| fn invalid_request_jsonrpc_response( | ||
| id: Option<RequestId>, | ||
| message: impl Into<Cow<'static, str>>, | ||
| ) -> BoxResponse { | ||
| let err = JsonRpcError::new(id, ErrorData::invalid_request(message, None)); | ||
| let body = serde_json::to_vec(&err).expect("serialize JsonRpcError"); | ||
| Response::builder() | ||
| .status(http::StatusCode::BAD_REQUEST) | ||
| .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) | ||
| .body(Full::new(Bytes::from(body)).boxed()) | ||
| .expect("valid response") | ||
| } | ||
| #[expect( | ||
| clippy::result_large_err, | ||
| reason = "BoxResponse is intentionally large; matches other handlers in this file" | ||
| )] | ||
| /// Absent header is allowed; the first initialize round-trip may legitimately omit it. | ||
| fn validate_header_matches_init_body( | ||
| headers: &http::HeaderMap, | ||
| body_version: &str, | ||
| request_id: Option<RequestId>, | ||
| ) -> Result<(), BoxResponse> { | ||
| let Some(header_value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { | ||
| return Ok(()); | ||
| }; | ||
| let header_str = header_value.to_str().map_err(|_| { | ||
| invalid_request_jsonrpc_response( | ||
| request_id.clone(), | ||
| "Invalid Request: MCP-Protocol-Version header is not valid UTF-8", | ||
| ) | ||
| })?; | ||
| if header_str != body_version { | ||
| tracing::warn!( | ||
| header = header_str, | ||
| body = body_version, | ||
| "rejecting initialize: MCP-Protocol-Version header does not match params.protocolVersion" | ||
| ); | ||
| return Err(invalid_request_jsonrpc_response( | ||
| request_id, | ||
| format!( | ||
| "Invalid Request: MCP-Protocol-Version header ({header_str}) does not match initialize params.protocolVersion ({body_version})" | ||
| ), | ||
| )); | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn forbidden_response(message: impl Into<String>) -> BoxResponse { | ||
@@ -1100,5 +1150,11 @@ Response::builder() | ||
| if let ClientJsonRpcMessage::Request(req) = &mut message { | ||
| if !matches!(req.request, ClientRequest::InitializeRequest(_)) { | ||
| let ClientRequest::InitializeRequest(init_req) = &req.request else { | ||
| return Err(unexpected_message_response("initialize request")); | ||
| } | ||
| }; | ||
| // Reject mismatched MCP-Protocol-Version header before binding the session to anything. | ||
| validate_header_matches_init_body( | ||
| &part.headers, | ||
| init_req.params.protocol_version.as_str(), | ||
| Some(req.id.clone()), | ||
| )?; | ||
| // inject request part to extensions | ||
@@ -1169,9 +1225,20 @@ req.request.extensions_mut().insert(part); | ||
| } else { | ||
| // Stateless mode: validate MCP-Protocol-Version on non-init requests | ||
| let is_init = matches!( | ||
| &message, | ||
| ClientJsonRpcMessage::Request(req) if matches!(req.request, ClientRequest::InitializeRequest(_)) | ||
| ); | ||
| if !is_init { | ||
| validate_protocol_version_header(&part.headers)?; | ||
| // Stateless mode: | ||
| // - on initialize: the header (if present) must match `params.protocolVersion` | ||
| // - on every other request: the header must name a known version. | ||
| match &message { | ||
| ClientJsonRpcMessage::Request(req) => { | ||
| if let ClientRequest::InitializeRequest(init_req) = &req.request { | ||
| validate_header_matches_init_body( | ||
| &part.headers, | ||
| init_req.params.protocol_version.as_str(), | ||
| Some(req.id.clone()), | ||
| )?; | ||
| } else { | ||
| validate_protocol_version_header(&part.headers)?; | ||
| } | ||
| } | ||
| _ => { | ||
| validate_protocol_version_header(&part.headers)?; | ||
| } | ||
| } | ||
@@ -1178,0 +1245,0 @@ let service = self |
@@ -115,2 +115,3 @@ use std::{ | ||
| impl ServerHandler for TestServer { | ||
| #[allow(deprecated)] | ||
| fn get_info(&self) -> ServerInfo { | ||
@@ -120,2 +121,3 @@ ServerInfo::new(ServerCapabilities::builder().enable_logging().build()) | ||
| #[allow(deprecated)] | ||
| fn set_level( | ||
@@ -122,0 +124,0 @@ &self, |
@@ -139,2 +139,21 @@ use std::{convert::Infallible, net::SocketAddr}; | ||
| async fn start_path_insert_metadata_server() -> (String, SocketAddr) { | ||
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
| let addr = listener.local_addr().unwrap(); | ||
| let base_url = format!("http://{}", addr); | ||
| let app = Router::new() | ||
| .route( | ||
| "/.well-known/oauth-authorization-server/mcp", | ||
| get(auth_server_metadata_handler), | ||
| ) | ||
| .route("/token", post(token_handler)); | ||
| tokio::spawn(async move { | ||
| axum::serve(listener, app).await.unwrap(); | ||
| }); | ||
| (base_url, addr) | ||
| } | ||
| #[tokio::test] | ||
@@ -167,2 +186,29 @@ async fn test_client_credentials_flow_client_secret() { | ||
| #[tokio::test] | ||
| async fn test_client_credentials_discovers_path_inserted_oauth_metadata() { | ||
| let (base_url, _addr) = start_path_insert_metadata_server().await; | ||
| let resource_url = format!("{base_url}/mcp"); | ||
| let mut oauth_state = OAuthState::new(&resource_url, None).await.unwrap(); | ||
| let config = ClientCredentialsConfig::ClientSecret { | ||
| client_id: "test-m2m-client".to_string(), | ||
| client_secret: "test-m2m-secret".to_string(), | ||
| scopes: vec!["read".to_string()], | ||
| resource: Some(resource_url), | ||
| }; | ||
| oauth_state | ||
| .authenticate_client_credentials(config) | ||
| .await | ||
| .unwrap(); | ||
| let manager = oauth_state | ||
| .into_authorization_manager() | ||
| .expect("Should be in Authorized state"); | ||
| let token = manager.get_access_token().await.unwrap(); | ||
| assert_eq!(token, "m2m-access-token-12345"); | ||
| } | ||
| #[tokio::test] | ||
| async fn test_client_credentials_invalid_secret() { | ||
@@ -169,0 +215,0 @@ let (base_url, _addr) = start_mock_server().await; |
@@ -94,3 +94,2 @@ #![allow(clippy::exhaustive_structs, clippy::exhaustive_enums)] | ||
| ], | ||
| "title": "ChatRequest", | ||
| "type": "object" | ||
@@ -97,0 +96,0 @@ }) |
@@ -869,2 +869,3 @@ #![cfg(not(feature = "local"))] | ||
| assert_eq!(ProtocolVersion::V_2026_07_28.as_str(), "2026-07-28"); | ||
| assert_eq!(ProtocolVersion::V_2025_11_25.as_str(), "2025-11-25"); | ||
@@ -875,3 +876,3 @@ assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18"); | ||
| assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 4); | ||
| assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 5); | ||
| assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05)); | ||
@@ -881,2 +882,3 @@ assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26)); | ||
| assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_11_25)); | ||
| assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28)); | ||
| } | ||
@@ -883,0 +885,0 @@ |
| // cargo test --features "server client" --package rmcp test_logging | ||
| #![cfg(not(feature = "local"))] | ||
| #![allow(deprecated)] | ||
| mod common; | ||
@@ -4,0 +5,0 @@ |
@@ -275,3 +275,3 @@ { | ||
| "title": "Builder", | ||
| "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .enable_roots()\n .enable_roots_list_changed()\n .build();\n```", | ||
| "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", | ||
| "type": "object", | ||
@@ -312,2 +312,3 @@ "properties": { | ||
| "roots": { | ||
| "description": "Capability for filesystem roots (deprecated by SEP-2577).", | ||
| "anyOf": [ | ||
@@ -323,3 +324,3 @@ { | ||
| "sampling": { | ||
| "description": "Capability for LLM sampling requests (SEP-1577)", | ||
| "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", | ||
| "anyOf": [ | ||
@@ -1817,2 +1818,3 @@ { | ||
| "RootsCapabilities": { | ||
| "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>.", | ||
| "type": "object", | ||
@@ -1834,3 +1836,3 @@ "properties": { | ||
| "SamplingCapability": { | ||
| "description": "Sampling capability with optional sub-capabilities (SEP-1577).", | ||
| "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>.", | ||
| "type": "object", | ||
@@ -1963,2 +1965,3 @@ "properties": { | ||
| "SamplingTaskCapability": { | ||
| "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>.", | ||
| "type": "object", | ||
@@ -1965,0 +1968,0 @@ "properties": { |
@@ -275,3 +275,3 @@ { | ||
| "title": "Builder", | ||
| "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .enable_roots()\n .enable_roots_list_changed()\n .build();\n```", | ||
| "description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .build();\n```", | ||
| "type": "object", | ||
@@ -312,2 +312,3 @@ "properties": { | ||
| "roots": { | ||
| "description": "Capability for filesystem roots (deprecated by SEP-2577).", | ||
| "anyOf": [ | ||
@@ -323,3 +324,3 @@ { | ||
| "sampling": { | ||
| "description": "Capability for LLM sampling requests (SEP-1577)", | ||
| "description": "Capability for LLM sampling requests (SEP-1577, deprecated by SEP-2577).", | ||
| "anyOf": [ | ||
@@ -1817,2 +1818,3 @@ { | ||
| "RootsCapabilities": { | ||
| "description": "Roots capability. Deprecated by SEP-2577; remains functional and will be\nremoved in a future release.\nSee <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>.", | ||
| "type": "object", | ||
@@ -1834,3 +1836,3 @@ "properties": { | ||
| "SamplingCapability": { | ||
| "description": "Sampling capability with optional sub-capabilities (SEP-1577).", | ||
| "description": "Sampling capability with optional sub-capabilities (SEP-1577).\n\nDeprecated by SEP-2577; remains functional and will be removed in a future\nrelease.\nSee <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>.", | ||
| "type": "object", | ||
@@ -1963,2 +1965,3 @@ "properties": { | ||
| "SamplingTaskCapability": { | ||
| "description": "Sampling task capability. Deprecated by SEP-2577; remains functional and\nwill be removed in a future release.\nSee <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>.", | ||
| "type": "object", | ||
@@ -1965,0 +1968,0 @@ "properties": { |
| #![cfg(not(feature = "local"))] | ||
| #![allow(deprecated)] | ||
| mod common; | ||
@@ -3,0 +4,0 @@ |
@@ -7,4 +7,7 @@ // cargo test --features "client" --package rmcp -- server_init | ||
| use rmcp::{ | ||
| ServiceExt, | ||
| model::{ClientJsonRpcMessage, ServerJsonRpcMessage, ServerResult}, | ||
| ServerHandler, ServiceExt, | ||
| model::{ | ||
| ClientJsonRpcMessage, ProtocolVersion, ServerCapabilities, ServerInfo, | ||
| ServerJsonRpcMessage, ServerResult, | ||
| }, | ||
| transport::{IntoTransport, Transport}, | ||
@@ -224,2 +227,139 @@ }; | ||
| fn init_request_with_version(v: &str) -> ClientJsonRpcMessage { | ||
| msg(&format!( | ||
| r#"{{ | ||
| "jsonrpc": "2.0", | ||
| "id": 1, | ||
| "method": "initialize", | ||
| "params": {{ | ||
| "protocolVersion": "{v}", | ||
| "capabilities": {{}}, | ||
| "clientInfo": {{ "name": "test-client", "version": "0.0.1" }} | ||
| }} | ||
| }}"# | ||
| )) | ||
| } | ||
| async fn negotiate_version<H>(handler: H, client_version: &str) -> ProtocolVersion | ||
| where | ||
| H: ServerHandler + 'static, | ||
| { | ||
| let (server_transport, client_transport) = tokio::io::duplex(4096); | ||
| let _server = tokio::spawn(async move { handler.serve(server_transport).await }); | ||
| let mut client = IntoTransport::<rmcp::RoleClient, _, _>::into_transport(client_transport); | ||
| client | ||
| .send(init_request_with_version(client_version)) | ||
| .await | ||
| .unwrap(); | ||
| let response = client.receive().await.unwrap(); | ||
| let ServerJsonRpcMessage::Response(r) = response else { | ||
| panic!("expected initialize response, got {response:?}"); | ||
| }; | ||
| let ServerResult::InitializeResult(init) = r.result else { | ||
| panic!("expected InitializeResult"); | ||
| }; | ||
| init.protocol_version | ||
| } | ||
| #[tokio::test] | ||
| async fn server_echoes_client_protocol_version_when_known_old() { | ||
| let negotiated = negotiate_version(TestServer::new(), "2024-11-05").await; | ||
| assert_eq!(negotiated, ProtocolVersion::V_2024_11_05); | ||
| } | ||
| #[tokio::test] | ||
| async fn server_echoes_client_protocol_version_when_latest() { | ||
| let negotiated = negotiate_version(TestServer::new(), "2025-11-25").await; | ||
| assert_eq!(negotiated, ProtocolVersion::LATEST); | ||
| } | ||
| #[tokio::test] | ||
| async fn server_falls_back_when_client_protocol_version_unknown() { | ||
| let negotiated = negotiate_version(TestServer::new(), "2099-99-99").await; | ||
| assert_eq!(negotiated, ProtocolVersion::LATEST); | ||
| } | ||
| struct PinnedServer; | ||
| impl ServerHandler for PinnedServer { | ||
| fn get_info(&self) -> ServerInfo { | ||
| ServerInfo::new(ServerCapabilities::builder().build()) | ||
| .with_protocol_version(ProtocolVersion::V_2025_06_18) | ||
| } | ||
| } | ||
| #[tokio::test] | ||
| async fn server_pinned_version_does_not_override_known_client_request() { | ||
| let negotiated = negotiate_version(PinnedServer, "2025-11-25").await; | ||
| assert_eq!(negotiated, ProtocolVersion::LATEST); | ||
| } | ||
| #[tokio::test] | ||
| async fn server_pinned_version_used_as_fallback_for_unknown_client_request() { | ||
| let negotiated = negotiate_version(PinnedServer, "2099-99-99").await; | ||
| assert_eq!(negotiated, ProtocolVersion::V_2025_06_18); | ||
| } | ||
| fn duplicate_init_request(id: u64, version: &str) -> ClientJsonRpcMessage { | ||
| msg(&format!( | ||
| r#"{{ | ||
| "jsonrpc": "2.0", | ||
| "id": {id}, | ||
| "method": "initialize", | ||
| "params": {{ | ||
| "protocolVersion": "{version}", | ||
| "capabilities": {{ "sampling": {{}} }}, | ||
| "clientInfo": {{ "name": "renegotiated-client", "version": "9.9.9" }} | ||
| }} | ||
| }}"# | ||
| )) | ||
| } | ||
| #[tokio::test] | ||
| async fn server_accepts_duplicate_initialize() { | ||
| let (server_transport, client_transport) = tokio::io::duplex(4096); | ||
| let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); | ||
| let mut client = IntoTransport::<rmcp::RoleClient, _, _>::into_transport(client_transport); | ||
| do_initialize(&mut client).await; | ||
| client.send(initialized_notification()).await.unwrap(); | ||
| client | ||
| .send(duplicate_init_request(2, "2025-11-25")) | ||
| .await | ||
| .unwrap(); | ||
| let response = client.receive().await.unwrap(); | ||
| assert!( | ||
| matches!(response, ServerJsonRpcMessage::Response(_)), | ||
| "expected successful InitializeResult, got: {response:?}" | ||
| ); | ||
| } | ||
| #[tokio::test] | ||
| async fn server_session_remains_usable_after_renegotiation() { | ||
| let (server_transport, client_transport) = tokio::io::duplex(4096); | ||
| let _server = tokio::spawn(async move { TestServer::new().serve(server_transport).await }); | ||
| let mut client = IntoTransport::<rmcp::RoleClient, _, _>::into_transport(client_transport); | ||
| do_initialize(&mut client).await; | ||
| client.send(initialized_notification()).await.unwrap(); | ||
| client | ||
| .send(duplicate_init_request(2, "2025-11-25")) | ||
| .await | ||
| .unwrap(); | ||
| let _renegotiated = client.receive().await.unwrap(); | ||
| client.send(ping_request(3)).await.unwrap(); | ||
| let pong = client.receive().await.unwrap(); | ||
| assert!( | ||
| matches!( | ||
| pong, | ||
| ServerJsonRpcMessage::Response(ref r) | ||
| if matches!(r.result, ServerResult::EmptyResult(_)) | ||
| ), | ||
| "expected EmptyResult ping after renegotiation, got: {pong:?}" | ||
| ); | ||
| } | ||
| // Server buffers multiple requests before initialized and processes them in order. | ||
@@ -226,0 +366,0 @@ #[tokio::test] |
@@ -31,2 +31,12 @@ #![allow(clippy::exhaustive_structs)] | ||
| #[derive(Serialize, Deserialize, JsonSchema)] | ||
| pub struct GreetingRequest { | ||
| pub name: String, | ||
| } | ||
| #[derive(Serialize, Deserialize, JsonSchema)] | ||
| pub struct GetUserRequest { | ||
| pub user_id: String, | ||
| } | ||
| #[tool_handler(router = self.tool_router)] | ||
@@ -68,4 +78,4 @@ impl ServerHandler for TestServer {} | ||
| #[tool(name = "get-greeting", description = "Get a greeting")] | ||
| pub async fn get_greeting(&self, name: Parameters<String>) -> String { | ||
| format!("Hello, {}!", name.0) | ||
| pub async fn get_greeting(&self, params: Parameters<GreetingRequest>) -> String { | ||
| format!("Hello, {}!", params.0.name) | ||
| } | ||
@@ -75,4 +85,7 @@ | ||
| #[tool(name = "get-user", description = "Get user info")] | ||
| pub async fn get_user(&self, user_id: Parameters<String>) -> Result<Json<UserInfo>, String> { | ||
| if user_id.0 == "123" { | ||
| pub async fn get_user( | ||
| &self, | ||
| params: Parameters<GetUserRequest>, | ||
| ) -> Result<Json<UserInfo>, String> { | ||
| if params.0.user_id == "123" { | ||
| Ok(Json(UserInfo { | ||
@@ -79,0 +92,0 @@ name: "Alice".to_string(), |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display