Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ exclude = [
]

[workspace.package]
version = "0.2.8"
version = "0.2.9"
license = "MIT"
edition = "2024"
rust-version = "1.90.0"
Expand All @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"] }
```

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion neva/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
65 changes: 56 additions & 9 deletions neva/src/client/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,16 +555,14 @@ async fn handle_elicitation(
#[cfg(feature = "tasks")]
fn handle_list_tasks(req: Request, tasks: &Arc<TaskTracker>) -> 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::<ListTasksRequestParams>(p) {
Ok(params) => params.cursor,
Err(e) => return Response::error(id, Error::new(ErrorCode::InvalidParams, e)),
},
};
let params: Option<ListTasksRequestParams> = 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]
Expand Down Expand Up @@ -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<serde_json::Value>) -> 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.
Expand Down
70 changes: 65 additions & 5 deletions neva/src/types/request/from_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,71 @@ pub trait FromRequest: Sized {

impl<T: DeserializeOwned> FromRequest for T {
fn from_request(req: Request) -> Result<Self, Error> {
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<serde_json::Value>) -> Request {
Request::new(None::<crate::types::RequestId>, "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());
}
}
Loading