diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index e40cddf43..8b41ae8d0 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Default `prompts/list`, `completion/complete`, `resources/list`, + `resources/templates/list`, and `tools/list` handlers now return JSON-RPC + `-32601` (method not found) when their capability is not advertised. Servers + that relied on the previous empty success responses without declaring the + capability will see a behavior change; advertise the capability to retain + the empty default result. + ## [3.9.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.8.0...rmcp-v3.9.0) - 2026-11-05 ### Added diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index c28d66312..f60e0eda9 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -465,7 +465,11 @@ macro_rules! server_handler_methods { request: CompleteRequestParams, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(CompleteResult::default())) + std::future::ready(if self.get_info().capabilities.completions.is_some() { + Ok(CompleteResult::default()) + } else { + Err(McpError::method_not_found::()) + }) } fn set_level( &self, @@ -486,14 +490,22 @@ macro_rules! server_handler_methods { request: Option, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListPromptsResult::default())) + std::future::ready(if self.get_info().capabilities.prompts.is_some() { + Ok(ListPromptsResult::default()) + } else { + Err(McpError::method_not_found::()) + }) } fn list_resources( &self, request: Option, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListResourcesResult::default())) + std::future::ready(if self.get_info().capabilities.resources.is_some() { + Ok(ListResourcesResult::default()) + } else { + Err(McpError::method_not_found::()) + }) } fn list_resource_templates( &self, @@ -502,7 +514,11 @@ macro_rules! server_handler_methods { ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListResourceTemplatesResult::default())) + std::future::ready(if self.get_info().capabilities.resources.is_some() { + Ok(ListResourceTemplatesResult::default()) + } else { + Err(McpError::method_not_found::()) + }) } fn read_resource( &self, @@ -602,7 +618,11 @@ macro_rules! server_handler_methods { request: Option, context: RequestContext, ) -> impl Future> + MaybeSendFuture + '_ { - std::future::ready(Ok(ListToolsResult::default())) + std::future::ready(if self.get_info().capabilities.tools.is_some() { + Ok(ListToolsResult::default()) + } else { + Err(McpError::method_not_found::()) + }) } /// Get a tool definition by name. /// diff --git a/crates/rmcp/tests/common/handlers.rs b/crates/rmcp/tests/common/handlers.rs index 7e6862131..85b1f39bc 100644 --- a/crates/rmcp/tests/common/handlers.rs +++ b/crates/rmcp/tests/common/handlers.rs @@ -116,7 +116,12 @@ impl TestServer { impl ServerHandler for TestServer { #[allow(deprecated)] fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_logging().build()) + ServerConfig::new( + ServerCapabilities::builder() + .enable_logging() + .enable_tools() + .build(), + ) } #[allow(deprecated)] diff --git a/crates/rmcp/tests/test_client_lifecycle_modes.rs b/crates/rmcp/tests/test_client_lifecycle_modes.rs index 13ff77c8a..7a79a6e61 100644 --- a/crates/rmcp/tests/test_client_lifecycle_modes.rs +++ b/crates/rmcp/tests/test_client_lifecycle_modes.rs @@ -19,7 +19,11 @@ impl ClientHandler for DiscoverClient {} #[derive(Clone, Default)] struct StatelessServer; -impl ServerHandler for StatelessServer {} +impl ServerHandler for StatelessServer { + fn get_info(&self) -> rmcp::model::ServerConfig { + rmcp::model::ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + } +} #[tokio::test] async fn discover_startup_accepts_stringified_numeric_response_id() { diff --git a/crates/rmcp/tests/test_default_list_capabilities.rs b/crates/rmcp/tests/test_default_list_capabilities.rs new file mode 100644 index 000000000..34e0d1b01 --- /dev/null +++ b/crates/rmcp/tests/test_default_list_capabilities.rs @@ -0,0 +1,126 @@ +#![cfg(all(feature = "client", feature = "server"))] + +use rmcp::{ + ClientHandler, RoleClient, RoleServer, ServerHandler, + model::{ + ArgumentInfo, ClientConfig, CompleteRequestParams, ErrorCode, InitializeResult, + ProtocolVersion, Reference, ServerCapabilities, ServerConfig, + }, + service::{ServiceError, serve_directly}, +}; + +#[derive(Debug, Clone)] +struct CapabilityServer(ServerCapabilities); + +impl ServerHandler for CapabilityServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(self.0.clone()) + } +} + +#[derive(Debug, Clone)] +struct ProbeClient; + +impl ClientHandler for ProbeClient { + fn get_info(&self) -> ClientConfig { + let mut info = ClientConfig::default(); + info.protocol_version = ProtocolVersion::V_2026_07_28; + info + } +} + +fn expect_result( + method: &str, + result: Result, + advertised: bool, +) -> Option { + if advertised { + Some(result.unwrap_or_else(|error| panic!("{method} should succeed: {error:?}"))) + } else { + match result { + Err(ServiceError::McpError(error)) => { + assert_eq!(error.code, ErrorCode::METHOD_NOT_FOUND, "{method}"); + } + other => panic!("{method} should return method not found: {other:?}"), + } + None + } +} + +async fn check_capabilities(capabilities: ServerCapabilities) { + let prompts = capabilities.prompts.is_some(); + let completions = capabilities.completions.is_some(); + let resources = capabilities.resources.is_some(); + let tools = capabilities.tools.is_some(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = serve_directly::( + CapabilityServer(capabilities), + server_transport, + Some(ProbeClient.get_info()), + ); + let mut peer_info = InitializeResult::default(); + peer_info.protocol_version = ProtocolVersion::V_2026_07_28; + let client = serve_directly::( + ProbeClient, + client_transport, + Some(peer_info.into()), + ); + + if let Some(result) = expect_result("prompts/list", client.list_prompts(None).await, prompts) { + assert!(result.prompts.is_empty()); + } + if let Some(result) = expect_result( + "completion/complete", + client + .complete(CompleteRequestParams::new( + Reference::for_prompt("unused"), + ArgumentInfo::new("argument", "value"), + )) + .await, + completions, + ) { + assert!(result.completion.values.is_empty()); + } + if let Some(result) = expect_result( + "resources/list", + client.list_resources(None).await, + resources, + ) { + assert!(result.resources.is_empty()); + } + if let Some(result) = expect_result( + "resources/templates/list", + client.list_resource_templates(None).await, + resources, + ) { + assert!(result.resource_templates.is_empty()); + } + if let Some(result) = expect_result("tools/list", client.list_tools(None).await, tools) { + assert!(result.tools.is_empty()); + } + + client.cancel().await.expect("stop client"); + server.cancel().await.expect("stop server"); +} + +#[tokio::test] +async fn default_list_methods_follow_advertised_capabilities() { + // LocalSet also lets this test run with --all-features, where rmcp's + // `local` feature uses spawn_local for its service tasks. + tokio::task::LocalSet::new() + .run_until(async { + check_capabilities(ServerCapabilities::default()).await; + check_capabilities(ServerCapabilities::builder().enable_tools().build()).await; + check_capabilities( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .enable_prompts() + .enable_completions() + .build(), + ) + .await; + }) + .await; +} diff --git a/crates/rmcp/tests/test_discover_http_client_startup.rs b/crates/rmcp/tests/test_discover_http_client_startup.rs index 70aa219dc..dc4616569 100644 --- a/crates/rmcp/tests/test_discover_http_client_startup.rs +++ b/crates/rmcp/tests/test_discover_http_client_startup.rs @@ -16,7 +16,10 @@ use axum::{ }; use rmcp::{ ClientLifecycleMode, ClientServiceExt, ServerHandler, - model::{ClientConfig, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion}, + model::{ + ClientConfig, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion, ServerCapabilities, + ServerConfig, + }, service::{MaybeSendFuture, RequestContext, RoleServer}, transport::{ StreamableHttpClientTransport, @@ -34,6 +37,10 @@ use tokio_util::sync::CancellationToken; struct DiscoverHttpServer; impl ServerHandler for DiscoverHttpServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + } + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { Cow::Borrowed(&[ProtocolVersion::V_2026_07_28]) } @@ -43,6 +50,10 @@ impl ServerHandler for DiscoverHttpServer { struct LegacyHttpServer; impl ServerHandler for LegacyHttpServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + } + fn discover( &self, _context: RequestContext, diff --git a/crates/rmcp/tests/test_handler_cache_hints.rs b/crates/rmcp/tests/test_handler_cache_hints.rs index 093089e09..578b92a55 100644 --- a/crates/rmcp/tests/test_handler_cache_hints.rs +++ b/crates/rmcp/tests/test_handler_cache_hints.rs @@ -7,7 +7,8 @@ use rmcp::{ model::{ CacheScope, ClientConfig, InitializeResult, ListPromptsResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, ReadResourceRequestParams, - ReadResourceResponse, ReadResourceResult, ResourceContents, + ReadResourceResponse, ReadResourceResult, ResourceContents, ServerCapabilities, + ServerConfig, }, prompt_handler, service::{RequestContext, RunningService, serve_directly}, @@ -40,6 +41,16 @@ impl ServerHandler for CacheHintServer {} struct ManualServer; impl ServerHandler for ManualServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new( + ServerCapabilities::builder() + .enable_tools() + .enable_prompts() + .enable_resources() + .build(), + ) + } + async fn list_tools( &self, _request: Option, @@ -70,6 +81,10 @@ impl ServerHandler for ManualServer { struct ExplicitHintServer; impl ServerHandler for ExplicitHintServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_resources().build()) + } + async fn list_resources( &self, _request: Option, diff --git a/crates/rmcp/tests/test_stateless_server_requests.rs b/crates/rmcp/tests/test_stateless_server_requests.rs index abec4d3df..bed509f1d 100644 --- a/crates/rmcp/tests/test_stateless_server_requests.rs +++ b/crates/rmcp/tests/test_stateless_server_requests.rs @@ -11,8 +11,8 @@ use rmcp::{ ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest, DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest, ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotificationParam, - ProgressToken, ProtocolVersion, RequestId, RequestMetaObject, ServerJsonRpcMessage, - ServerNotification, + ProgressToken, ProtocolVersion, RequestId, RequestMetaObject, ServerCapabilities, + ServerConfig, ServerJsonRpcMessage, ServerNotification, }, service::{MaybeSendFuture, RequestContext, RoleServer}, transport::{IntoTransport, Transport}, @@ -21,7 +21,15 @@ use rmcp::{ #[derive(Clone, Default)] struct StatelessServer; -impl ServerHandler for StatelessServer {} +impl ServerHandler for StatelessServer { + fn get_info(&self) -> ServerConfig { + tools_info() + } +} + +fn tools_info() -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) +} fn complete_meta() -> RequestMetaObject { complete_meta_for("stateless-client") @@ -129,6 +137,10 @@ struct ContextServer { } impl ServerHandler for ContextServer { + fn get_info(&self) -> ServerConfig { + tools_info() + } + fn list_tools( &self, _request: Option, @@ -260,6 +272,10 @@ async fn stateless_server_rejects_malformed_metadata_without_selecting_lifecycle struct ProgressServer; impl ServerHandler for ProgressServer { + fn get_info(&self) -> ServerConfig { + tools_info() + } + async fn list_tools( &self, _request: Option, diff --git a/crates/rmcp/tests/test_streamable_http_session_store.rs b/crates/rmcp/tests/test_streamable_http_session_store.rs index 8fdb94675..6dc572b4a 100644 --- a/crates/rmcp/tests/test_streamable_http_session_store.rs +++ b/crates/rmcp/tests/test_streamable_http_session_store.rs @@ -115,7 +115,7 @@ async fn test_session_state_persisted_to_store() -> anyhow::Result<()> { .await?; // Make a real request so the session is fully active. - let _resources = client.list_all_resources().await?; + let _tools = client.list_all_tools().await?; // The store should now contain exactly one session entry. assert_eq!( @@ -178,7 +178,7 @@ async fn test_session_state_deleted_from_store_on_delete() -> anyhow::Result<()> .with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE) .serve(transport) .await?; - let _resources = client.list_all_resources().await?; + let _tools = client.list_all_tools().await?; assert_eq!(store.len().await, 1, "store should have one entry"); @@ -422,11 +422,11 @@ async fn test_no_session_created_at_latest_protocol_version() -> anyhow::Result< .header("content-type", "application/json") .header("mcp-protocol-version", ProtocolVersion::LATEST.as_str()) // Required by SEP-2243 from 2026-07-28 onward. - .header("mcp-method", "resources/list") + .header("mcp-method", "tools/list") .json(&serde_json::json!({ "jsonrpc": "2.0", "id": 1, - "method": "resources/list", + "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": ProtocolVersion::LATEST.as_str(), diff --git a/crates/rmcp/tests/test_streamable_http_stale_session.rs b/crates/rmcp/tests/test_streamable_http_stale_session.rs index 880fab553..68cf98d12 100644 --- a/crates/rmcp/tests/test_streamable_http_stale_session.rs +++ b/crates/rmcp/tests/test_streamable_http_stale_session.rs @@ -361,8 +361,8 @@ async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result .serve(transport) .await?; - // Verify the session is established: list_all_resources() succeeds - let _resources = client.list_all_resources().await?; + // Verify the session is established with a method Calculator advertises. + let _tools = client.list_all_tools().await?; // Capture the current session ID from the server let original_session_id = { @@ -381,7 +381,7 @@ async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result } // This call should trigger transparent re-initialization and still succeed - let _resources_after = client.list_all_resources().await?; + let _tools_after = client.list_all_tools().await?; // Verify the server created a new session with a different ID { @@ -441,7 +441,7 @@ async fn test_session_expired_error_when_reinit_disabled() -> anyhow::Result<()> .await?; // Verify the session is established - let _resources = client.list_all_resources().await?; + let _tools = client.list_all_tools().await?; // Force session expiry by removing all sessions from the server-side manager { @@ -450,7 +450,7 @@ async fn test_session_expired_error_when_reinit_disabled() -> anyhow::Result<()> } // This call should fail with a SessionExpired transport error - let result = client.list_all_resources().await; + let result = client.list_all_tools().await; match result { Err(ServiceError::TransportSend(ref dyn_err)) => { let err_msg = format!("{dyn_err}"); diff --git a/crates/rmcp/tests/test_with_js/streamable_client.js b/crates/rmcp/tests/test_with_js/streamable_client.js index 998261312..99daa52b9 100644 --- a/crates/rmcp/tests/test_with_js/streamable_client.js +++ b/crates/rmcp/tests/test_with_js/streamable_client.js @@ -1,5 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ErrorCode } from "@modelcontextprotocol/sdk/types.js"; const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:8001/mcp/`)); @@ -19,10 +20,26 @@ const client = new Client( await client.connect(transport); const tools = await client.listTools(); console.log(tools); -const resources = await client.listResources(); -console.log(resources); -const templates = await client.listResourceTemplates(); -console.log(templates); -const prompts = await client.listPrompts(); -console.log(prompts); +// The calculator server declares only the tools capability, so unsupported +// request methods must fail rather than returning empty successful results. +async function expectMethodNotFound(name, operation) { + try { + const result = await operation(); + throw new Error(`${name} unexpectedly succeeded: ${JSON.stringify(result)}`); + } catch (err) { + if (err.code !== ErrorCode.MethodNotFound) { + throw err; + } + } + console.log(`${name} correctly rejected with method-not-found`); +} +await expectMethodNotFound("resources/list", () => client.listResources()); +await expectMethodNotFound("resources/templates/list", () => client.listResourceTemplates()); +await expectMethodNotFound("prompts/list", () => client.listPrompts()); +await expectMethodNotFound("completion/complete", () => + client.complete({ + ref: { type: "ref/prompt", name: "unknown" }, + argument: { name: "input", value: "value" }, + }), +); await client.close();