chore: add #[non_exhaustive] to remaining public structs (#768)

* chore: add #[non_exhaustive] to remaining public structs

* chore: add #[non_exhaustive] to remaining public types

* chore: enable exhaustive_structs/enums clippy lints

* test: add untagged ServerResult deserialization regression tests
This commit is contained in:
Dale Seo 2026-03-26 10:23:58 -04:00 • committed by GitHub
commit 6a3b32d3ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 430 additions and 211 deletions

View file

@ -1,3 +1,5 @@
#![allow(clippy::exhaustive_structs, clippy::exhaustive_enums)]
use rmcp::{
ErrorData as McpError, handler::server::wrapper::Parameters, model::*, schemars, tool,
tool_router,

View file

@ -13,3 +13,122 @@ fn test_tool_list_result() {
})
));
}
/// Regression tests for `#[serde(untagged)]` deserialization of `ServerResult`.
///
/// `ServerResult` is an untagged enum, so serde tries each variant in declaration
/// order. `GetTaskPayloadResult` has a custom `Deserialize` impl that always fails
/// so it is skipped, and `CustomResult(Value)` acts as the catch-all. If variant
/// ordering changes or the custom impl is removed, these tests will catch the
/// regression.
mod untagged_server_result {
use rmcp::model::{CallToolResult, JsonRpcResponse, ServerJsonRpcMessage, ServerResult};
use serde_json::json;
/// Helper: wrap a result value in a JSON-RPC response envelope.
fn wrap_response(result: serde_json::Value) -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"id": 1,
"result": result
})
}
/// Parse a JSON-RPC response and return the inner `ServerResult`.
fn parse_result(json: serde_json::Value) -> ServerResult {
let msg: ServerJsonRpcMessage = serde_json::from_value(json).unwrap();
match msg {
ServerJsonRpcMessage::Response(JsonRpcResponse { result, .. }) => result,
other => panic!("expected Response, got {other:?}"),
}
}
#[test]
fn initialize_result_deserializes_to_correct_variant() {
let result = parse_result(wrap_response(json!({
"protocolVersion": "2025-03-26",
"capabilities": {},
"serverInfo": {
"name": "test-server",
"version": "1.0.0"
}
})));
assert!(
matches!(result, ServerResult::InitializeResult(_)),
"expected InitializeResult, got {result:?}"
);
}
#[test]
fn call_tool_result_deserializes_to_correct_variant() {
let result = parse_result(wrap_response(json!({
"content": [
{ "type": "text", "text": "hello" }
]
})));
assert!(
matches!(result, ServerResult::CallToolResult(_)),
"expected CallToolResult, got {result:?}"
);
}
#[test]
fn empty_object_deserializes_to_empty_result() {
let result = parse_result(wrap_response(json!({})));
assert!(
matches!(result, ServerResult::EmptyResult(_)),
"expected EmptyResult, got {result:?}"
);
}
#[test]
fn unknown_shape_falls_through_to_custom_result() {
// A value that doesn't match any known result type should land in
// CustomResult, NOT GetTaskPayloadResult.
let result = parse_result(wrap_response(json!({
"some_unknown_field": "some_value",
"number": 42
})));
assert!(
matches!(result, ServerResult::CustomResult(_)),
"expected CustomResult, got {result:?}"
);
}
#[test]
fn arbitrary_json_value_does_not_deserialize_as_get_task_payload_result() {
// GetTaskPayloadResult wraps a bare Value, but its custom Deserialize
// always fails so serde skips it during untagged resolution.
// Any JSON value must fall through to CustomResult instead.
for value in [json!(42), json!("hello"), json!(null), json!([1, 2, 3])] {
let result = parse_result(wrap_response(value.clone()));
assert!(
matches!(result, ServerResult::CustomResult(_)),
"value {value} should deserialize as CustomResult, got {result:?}"
);
}
}
#[test]
fn round_trip_initialize_result_preserves_variant() {
let json = json!({
"protocolVersion": "2025-03-26",
"capabilities": {},
"serverInfo": { "name": "test", "version": "1.0" }
});
// Parse as ServerResult, serialize back, parse again — must stay InitializeResult.
let result = parse_result(wrap_response(json.clone()));
assert!(matches!(&result, ServerResult::InitializeResult(_)));
let reserialized = serde_json::to_value(&result).unwrap();
let result2 = parse_result(wrap_response(reserialized));
assert!(matches!(result2, ServerResult::InitializeResult(_)));
}
#[test]
fn round_trip_call_tool_result_preserves_variant() {
let original = CallToolResult::success(vec![rmcp::model::Content::text("hello world")]);
let json = serde_json::to_value(&original).unwrap();
let result = parse_result(wrap_response(json));
assert!(matches!(result, ServerResult::CallToolResult(_)));
}
}

View file

@ -1458,20 +1458,15 @@ async fn test_peer_request_options_timeout() {
let timeout = Some(Duration::from_secs(15));
let options = PeerRequestOptions {
timeout,
meta: None,
};
let mut options = PeerRequestOptions::default();
options.timeout = timeout;
// Verify timeout is properly stored
assert_eq!(options.timeout, timeout);
assert!(options.meta.is_none());
// Test with no timeout
let options_no_timeout = PeerRequestOptions {
timeout: None,
meta: None,
};
let options_no_timeout = PeerRequestOptions::default();
assert!(options_no_timeout.timeout.is_none());
}

View file

@ -1,3 +1,4 @@
#![allow(clippy::exhaustive_structs)]
//cargo test --test test_json_schema_detection --features "client server macros"
use rmcp::{
Json, ServerHandler, handler::server::router::tool::ToolRouter, tool, tool_handler, tool_router,

View file

@ -8,7 +8,6 @@ use rmcp::{
model::*,
service::{RequestContext, Service},
};
use tokio_util::sync::CancellationToken;
// Tests start here
#[tokio::test]
@ -48,13 +47,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(1),
meta: Default::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(1), client.peer().clone()),
)
.await?;
@ -85,13 +78,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(2),
meta: Default::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(2), client.peer().clone()),
)
.await?;
@ -122,13 +109,7 @@ async fn test_context_inclusion_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(3),
meta: Default::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(3), client.peer().clone()),
)
.await?;
@ -179,13 +160,7 @@ async fn test_context_inclusion_ignored_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(1),
meta: Meta::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(1), client.peer().clone()),
)
.await?;
@ -241,13 +216,7 @@ async fn test_message_sequence_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(1),
meta: Meta::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(1), client.peer().clone()),
)
.await?;
@ -307,13 +276,7 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(1),
meta: Meta::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(1), client.peer().clone()),
)
.await?;
@ -330,13 +293,7 @@ async fn test_message_sequence_validation_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(2),
meta: Meta::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(2), client.peer().clone()),
)
.await;
@ -370,13 +327,7 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(1),
meta: Meta::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(1), client.peer().clone()),
)
.await?;
@ -405,13 +356,7 @@ async fn test_selective_context_handling_integration() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(2),
meta: Meta::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(2), client.peer().clone()),
)
.await?;
@ -457,13 +402,7 @@ async fn test_context_inclusion() -> anyhow::Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(1),
meta: Meta::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(1), client.peer().clone()),
)
.await?;

View file

@ -388,7 +388,6 @@
"content": {
"description": "The content returned by the tool (text, images, etc.)",
"type": "array",
"default": [],
"items": {
"$ref": "#/definitions/Annotated"
}
@ -403,7 +402,10 @@
"structuredContent": {
"description": "An optional JSON object that represents the structured result of the tool call"
}
}
},
"required": [
"content"
]
},
"CancelTaskResult": {
"description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.",

View file

@ -8,7 +8,6 @@ use rmcp::{
model::*,
service::{RequestContext, Service},
};
use tokio_util::sync::CancellationToken;
#[tokio::test]
async fn test_basic_sampling_message_creation() -> Result<()> {
@ -126,13 +125,7 @@ async fn test_sampling_integration_with_test_handlers() -> Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(1),
meta: Default::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(1), client.peer().clone()),
)
.await?;
@ -189,13 +182,7 @@ async fn test_sampling_no_context_inclusion() -> Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(2),
meta: Default::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(2), client.peer().clone()),
)
.await?;
@ -253,13 +240,7 @@ async fn test_sampling_error_invalid_message_sequence() -> Result<()> {
let result = handler
.handle_request(
request.clone(),
RequestContext {
peer: client.peer().clone(),
ct: CancellationToken::new(),
id: NumberOrString::Number(3),
meta: Default::default(),
extensions: Default::default(),
},
RequestContext::new(NumberOrString::Number(3), client.peer().clone()),
)
.await;

View file

@ -74,13 +74,7 @@ async fn start_test_server(ct: CancellationToken, trigger: Arc<Notify>) -> Strin
let service = StreamableHttpService::new(
move || Ok(server.clone()),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig {
stateful_mode: true,
sse_keep_alive: Some(Duration::from_secs(15)),
sse_retry: Some(Duration::from_secs(3)),
cancellation_token: ct.child_token(),
..Default::default()
},
StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);

View file

@ -37,13 +37,13 @@ async fn spawn_server(
#[tokio::test]
async fn stateless_json_response_returns_application_json() -> anyhow::Result<()> {
let ct = CancellationToken::new();
let (client, url, ct) = spawn_server(StreamableHttpServerConfig {
stateful_mode: false,
json_response: true,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
})
let (client, url, ct) = spawn_server(
StreamableHttpServerConfig::default()
.with_stateful_mode(false)
.with_json_response(true)
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
)
.await;
let response = client
@ -79,13 +79,12 @@ async fn stateless_json_response_returns_application_json() -> anyhow::Result<()
#[tokio::test]
async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> {
let ct = CancellationToken::new();
let (client, url, ct) = spawn_server(StreamableHttpServerConfig {
stateful_mode: false,
json_response: false,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
})
let (client, url, ct) = spawn_server(
StreamableHttpServerConfig::default()
.with_stateful_mode(false)
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
)
.await;
let response = client
@ -122,13 +121,12 @@ async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> {
async fn json_response_ignored_in_stateful_mode() -> anyhow::Result<()> {
let ct = CancellationToken::new();
// json_response: true has no effect when stateful_mode: true — server still uses SSE
let (client, url, ct) = spawn_server(StreamableHttpServerConfig {
stateful_mode: true,
json_response: true,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
})
let (client, url, ct) = spawn_server(
StreamableHttpServerConfig::default()
.with_json_response(true)
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
)
.await;
let response = client

View file

@ -18,12 +18,9 @@ async fn test_priming_on_stream_start() -> anyhow::Result<()> {
StreamableHttpService::new(
|| Ok(Calculator::new()),
Default::default(),
StreamableHttpServerConfig {
stateful_mode: true,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
},
StreamableHttpServerConfig::default()
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);
@ -87,12 +84,9 @@ async fn test_priming_on_stream_close() -> anyhow::Result<()> {
let service = StreamableHttpService::new(
|| Ok(Calculator::new()),
session_manager.clone(),
StreamableHttpServerConfig {
stateful_mode: true,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
},
StreamableHttpServerConfig::default()
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);

View file

@ -32,12 +32,9 @@ async fn test_stale_session_id_returns_status_aware_error() -> anyhow::Result<()
StreamableHttpService::new(
|| Ok(Calculator::new()),
Default::default(),
StreamableHttpServerConfig {
stateful_mode: true,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
},
StreamableHttpServerConfig::default()
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);
@ -103,12 +100,9 @@ async fn test_transparent_reinitialization_on_session_expiry() -> anyhow::Result
let service = StreamableHttpService::new(
|| Ok(Calculator::new()),
session_manager.clone(),
StreamableHttpServerConfig {
stateful_mode: true,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
},
StreamableHttpServerConfig::default()
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);
@ -183,12 +177,9 @@ async fn test_session_expired_error_when_reinit_disabled() -> anyhow::Result<()>
let service = StreamableHttpService::new(
|| Ok(Calculator::new()),
session_manager.clone(),
StreamableHttpServerConfig {
stateful_mode: true,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
},
StreamableHttpServerConfig::default()
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);

View file

@ -1,3 +1,4 @@
#![allow(clippy::exhaustive_structs)]
//cargo test --test test_structured_output --features "client server macros"
use rmcp::{
Json, ServerHandler,

View file

@ -1,3 +1,4 @@
#![allow(clippy::exhaustive_structs)]
//cargo test --test test_tool_builder_methods --features "client server macros"
use rmcp::model::{JsonObject, Tool};
use schemars::JsonSchema;

View file

@ -69,12 +69,9 @@ async fn test_with_js_streamable_http_client() -> anyhow::Result<()> {
StreamableHttpService::new(
|| Ok(Calculator::new()),
Default::default(),
StreamableHttpServerConfig {
stateful_mode: true,
sse_keep_alive: None,
cancellation_token: ct.child_token(),
..Default::default()
},
StreamableHttpServerConfig::default()
.with_sse_keep_alive(None)
.with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);
let tcp_listener = tokio::net::TcpListener::bind(STREAMABLE_HTTP_BIND_ADDRESS).await?;