diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1d306..08cbeb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## 0.2.9 + +### Fixed +* Fixed a bug when optional `params` field in `Request` was expected as required. + ## 0.2.8 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index ab7f67e..591e8f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ exclude = [ ] [workspace.package] -version = "0.2.8" +version = "0.2.9" license = "MIT" edition = "2024" rust-version = "1.90.0" @@ -20,7 +20,7 @@ repository = "https://github.com/RomanEmreis/neva" documentation = "https://docs.rs/neva" [workspace.dependencies] -neva_macros = { path = "neva_macros", version = "0.2.8" } +neva_macros = { path = "neva_macros", version = "0.2.9" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index d25ed36..056763d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Blazingly fast and easily configurable [Model Context Protocol (MCP)](https://mo With simple configuration and ergonomic APIs, it provides everything you need to quickly build MCP clients and servers, fully aligned with the latest MCP specification. -[![latest](https://img.shields.io/badge/latest-0.2.8-d8eb34)](https://crates.io/crates/neva) +[![latest](https://img.shields.io/badge/latest-0.2.9-d8eb34)](https://crates.io/crates/neva) [![latest](https://img.shields.io/badge/rustc-1.90+-964B00)](https://releases.rs/docs/1.90.0/) [![License: MIT](https://img.shields.io/badge/License-MIT-624bd1.svg)](https://github.com/RomanEmreis/neva/blob/main/LICENSE) [![CI](https://github.com/RomanEmreis/neva/actions/workflows/rust.yml/badge.svg)](https://github.com/RomanEmreis/neva/actions/workflows/rust.yml) @@ -28,7 +28,7 @@ fully aligned with the latest MCP specification. #### Dependencies ```toml [dependencies] -neva = { version = "0.2.8", features = ["client-full"] } +neva = { version = "0.2.9", features = ["client-full"] } tokio = { version = "1", features = ["full"] } ``` @@ -66,7 +66,7 @@ async fn main() -> Result<(), Error> { #### Dependencies ```toml [dependencies] -neva = { version = "0.2.8", features = ["server-full"] } +neva = { version = "0.2.9", features = ["server-full"] } tokio = { version = "1", features = ["full"] } ``` #### Code diff --git a/neva/Cargo.toml b/neva/Cargo.toml index 54c8a00..d97b00d 100644 --- a/neva/Cargo.toml +++ b/neva/Cargo.toml @@ -31,7 +31,7 @@ uuid = { version = "1.22.0", features = ["v4", "serde"] } # optional inventory = { version = "0.3.22", optional = true } -jsonschema = { version = "0.44.1", optional = true } +jsonschema = { version = "0.45.0", optional = true } once_cell = { version = "1.21.3", features = ["std"], optional = true } reqwest = { version = "0.13.2", features = ["stream", "json"], optional = true } sse-stream = { version = "0.2.1", optional = true } diff --git a/neva/src/client/handler.rs b/neva/src/client/handler.rs index 4726693..429b4e6 100644 --- a/neva/src/client/handler.rs +++ b/neva/src/client/handler.rs @@ -555,16 +555,14 @@ async fn handle_elicitation( #[cfg(feature = "tasks")] fn handle_list_tasks(req: Request, tasks: &Arc) -> Response { let id = req.id(); - let Some(params) = req.params else { - return Response::error(id, Error::from(ErrorCode::InvalidParams)); + let cursor = match req.params { + None => None, + Some(p) => match serde_json::from_value::(p) { + Ok(params) => params.cursor, + Err(e) => return Response::error(id, Error::new(ErrorCode::InvalidParams, e)), + }, }; - let params: Option = serde_json::from_value(params).ok(); - ListTasksResult::from( - tasks - .tasks() - .paginate(params.and_then(|p| p.cursor), DEFAULT_PAGE_SIZE), - ) - .into_response(id) + ListTasksResult::from(tasks.tasks().paginate(cursor, DEFAULT_PAGE_SIZE)).into_response(id) } #[inline] @@ -715,6 +713,55 @@ mod tests { assert!(validate_batch_ids(&[notif.clone(), req, notif]).is_ok()); } + // --- tasks/list omitted-vs-malformed params --- + + #[cfg(feature = "tasks")] + fn make_tasks_request(params: Option) -> Request { + Request::new(Some(RequestId::Number(1)), "tasks/list", params) + } + + #[test] + #[cfg(feature = "tasks")] + fn tasks_list_omitted_params_returns_ok() { + let tasks = Arc::new(crate::shared::TaskTracker::default()); + let req = make_tasks_request(None); + let resp = handle_list_tasks(req, &tasks); + assert!(matches!(resp, Response::Ok(_))); + } + + #[test] + #[cfg(feature = "tasks")] + fn tasks_list_empty_object_params_returns_ok() { + let tasks = Arc::new(crate::shared::TaskTracker::default()); + let req = make_tasks_request(Some(serde_json::json!({}))); + let resp = handle_list_tasks(req, &tasks); + assert!(matches!(resp, Response::Ok(_))); + } + + #[test] + #[cfg(feature = "tasks")] + fn tasks_list_malformed_cursor_returns_invalid_params() { + let tasks = Arc::new(crate::shared::TaskTracker::default()); + let req = make_tasks_request(Some(serde_json::json!({"cursor": {"bad": "shape"}}))); + let resp = handle_list_tasks(req, &tasks); + let Response::Err(err) = resp else { + panic!("expected error response") + }; + assert_eq!(err.error.code, ErrorCode::InvalidParams); + } + + #[test] + #[cfg(feature = "tasks")] + fn tasks_list_non_object_params_returns_invalid_params() { + let tasks = Arc::new(crate::shared::TaskTracker::default()); + let req = make_tasks_request(Some(serde_json::json!("not_an_object"))); + let resp = handle_list_tasks(req, &tasks); + let Response::Err(err) = resp else { + panic!("expected error response") + }; + assert_eq!(err.error.code, ErrorCode::InvalidParams); + } + #[test] fn send_batch_returns_receiver_per_request_not_notification() { // Verifies the queue-registration logic: only Request envelopes get a receiver slot. diff --git a/neva/src/types/request/from_request.rs b/neva/src/types/request/from_request.rs index e413278..7eb36ec 100644 --- a/neva/src/types/request/from_request.rs +++ b/neva/src/types/request/from_request.rs @@ -12,11 +12,71 @@ pub trait FromRequest: Sized { impl FromRequest for T { fn from_request(req: Request) -> Result { - let params = req - .params - .ok_or_else(|| Error::new(ErrorCode::InvalidParams, "missing required parameters"))?; + let params = req.params.unwrap_or_else(|| serde_json::json!({})); + serde_json::from_value(params).map_err(|e| Error::new(ErrorCode::InvalidParams, e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ErrorCode; + use crate::types::{ + Cursor, + tool::{CallToolRequestParams, ListToolsRequestParams}, + }; + + fn make_request(params: Option) -> Request { + Request::new(None::, "test/method", params) + } + + // --- ListToolsRequestParams (all-optional params) --- + + #[test] + fn it_returns_defaults_when_params_absent_for_optional_params_type() { + let req = make_request(None); + let result = ListToolsRequestParams::from_request(req).unwrap(); + assert!(result.cursor.is_none()); + } + + #[test] + fn it_returns_defaults_when_params_empty_object_for_optional_params_type() { + let req = make_request(Some(serde_json::json!({}))); + let result = ListToolsRequestParams::from_request(req).unwrap(); + assert!(result.cursor.is_none()); + } + + #[test] + fn it_deserializes_optional_params_with_cursor_present() { + let cursor = Cursor(5); + let req = make_request(Some(serde_json::json!({ + "cursor": serde_json::to_value(cursor).unwrap() + }))); + let result = ListToolsRequestParams::from_request(req).unwrap(); + assert_eq!(result.cursor, Some(cursor)); + } + + // --- CallToolRequestParams (has required fields) --- + + #[test] + fn it_errors_when_params_absent_for_required_params_type() { + let req = make_request(None); + let err = CallToolRequestParams::from_request(req).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidParams); + } + + #[test] + fn it_errors_when_required_field_missing_in_params() { + let req = make_request(Some(serde_json::json!({}))); + let err = CallToolRequestParams::from_request(req).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidParams); + } - let params = serde_json::from_value(params)?; - Ok(params) + #[test] + fn it_deserializes_required_params_when_present() { + let req = make_request(Some(serde_json::json!({"name": "my_tool"}))); + let result = CallToolRequestParams::from_request(req).unwrap(); + assert_eq!(result.name, "my_tool"); + assert!(result.args.is_none()); } }