From ae03195b4b9134600dac750cfc1a911e4b77ebe5 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 5 Apr 2025 10:09:27 -0300 Subject: [PATCH 1/3] chore: house keeping --- CONTRIBUTING.md | 57 +++++++++++++++++++++++++++++++++++++++ README.md | 15 +++++++++++ development.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 development.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..91033a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# **Contributing to rust-mcp-sdk** + +🎉 Thank you for your interest in improving **rust-mcp-sdk**! Every contribution, big or small, is valuable and appreciated. + +## **Code of Conduct** + +We follow the [Rust Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct). Please be respectful and inclusive when contributing. + +## **How to Contribute** + +### Participating in Tests, Documentation, and Examples + +We highly encourage contributors to improve test coverage, enhance documentation, and introduce new examples to ensure the reliability and usability of the project. If you notice untested code paths, missing documentation, or areas where examples could help, consider adding tests, clarifying explanations, or providing real-world usage examples. Every improvement helps make the project more robust, well-documented, and accessible to others! + +### Participating in Issues + +You can contribute in three key ways: + +1. **Report Issues** – If you find a bug or have an idea, open an issue for discussion. +2. **Help Triage** – Provide details, test cases, or suggestions to clarify issues. +3. **Resolve Issues** – Investigate problems and submit fixes via Pull Requests (PRs). + +Anyone can participate at any stage, whether it's discussing, triaging, or reviewing PRs. + +### **Filing a Bug Report** + +When reporting a bug, use the provided issue template and fill in as many details as possible. Don’t worry if you can’t answer everything—just provide what you can. + +### **Fixing Issues** + +Most issues are resolved through a Pull Request. PRs go through a review process to ensure quality and correctness. + +## **Pull Requests (PRs)** + +We welcome PRs! Before submitting, please: + +1. **Discuss major changes** – Open an issue before adding a new feature and opening a PR. +2. **Create a feature branch** – Fork the repo and branch from `main`. +3. **Write tests** – If your change affects functionality, add relevant tests. +4. **Update documentation** – If you modify APIs, update the docs. +5. **Run tests** – Make sure all tests succeed by running: + +```sh +cargo make test +``` + +### **Commit Best Practices** + +- **Relate PR changes to the issue** – Changes in a pull request (PR) should directly address the specific issue it’s tied to. Unrelated changes should be split into separate issues and PRs to maintain focus and simplify review. +- **Logically separate commits** – Keep changes atomic and easy to review. +- **Maintain a bisect-able history** – Each commit should compile and pass all tests to enable easy debugging with `git bisect` in case of regression. + +## License + +By contributing to rust-mcp-sdk, you acknowledge and agree that your contributions will be licensed under the terms specified in the LICENSE file located in the root directory of this repository. + +--- diff --git a/README.md b/README.md index 591d57d..cd575cb 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,21 @@ The same principles outlined above apply to the client-side handlers, `mcp_clien Use `client_runtime::create_client()` or `client_runtime_core::create_client()` , respectively. Check out the corresponding examples at: [examples/simple-mcp-client](https://github.com/rust-mcp-stack/rust-mcp-sdk/tree/main/examples/simple-mcp-client) and [examples/simple-mcp-client-core](https://github.com/rust-mcp-stack/rust-mcp-sdk/tree/main/examples/simple-mcp-client-core). +## Contributing + +We welcome everyone who wishes to contribute! Please refer to the [contributing](CONTRIBUTING.md) guidelines for more details. + +Check out our [development guide](development.md) for instructions on setting up, building, testing, formatting, and trying out example projects. + +All contributions, including issues and pull requests, must follow +Rust's Code of Conduct. + +Unless explicitly stated otherwise, any contribution you submit for inclusion in rust-mcp-sdk is provided under the terms of the MIT License, without any additional conditions or restrictions. + +## Development + +Check out our [development guide](development.md) for instructions on setting up, building, testing, formatting, and trying out example projects. + ## License This project is licensed under the MIT License. see the [LICENSE](LICENSE) file for details. diff --git a/development.md b/development.md new file mode 100644 index 0000000..e3673cc --- /dev/null +++ b/development.md @@ -0,0 +1,72 @@ +# Development + +This document outlines the process for compiling this crate's source code on your local machine. + +## Prerequisites + +Ensure you have the following installed: + +- The latest stable version of **Rust** +- [`cargo-nextest`](https://crates.io/crates/cargo-nextest) for running tests +- [`cargo-make`](https://crates.io/crates/cargo-make/0.3.54) for running tasks like tests + +## Setting Up the Development Environment + +1- Clone the repository: + +```sh +git clone https://github.com/rust-mcp-stack/rust-mcp-sdk +cd rust-mcp-sdk +``` + +2- Install dependencies: The Rust project uses Cargo for dependency management. To install dependencies, run: + +```sh +cargo build +``` + +## Running Examples + +Example projects can be found in the [/examples](/examples) folder of the repository. +Build and run instructions are available in their respective README.md files. + +You can run examples by passing the example project name to Cargo using the `-p` argument, like this: + +```sh +cargo run -p simple-mcp-client +``` + +You can build the examples in a similar way. The following command builds the project and generates the binary at `target/release/hello-world-mcp-server`: + +```sh + +cargo build -p hello-world-mcp-server --release +``` + +## Code Formatting + +We follow the default Rust formatting style enforced by `rustfmt`. To format your code, run: + +```sh +cargo fmt +``` + +Additionally, we use **Clippy** for linting Rust code. You can check for linting issues by running: + +```sh +cargo make clippy +``` + +Please ensure your code is formatted and free of Clippy warnings before submitting any changes. + +## Testing + +We use [`cargo-nextest`](https://crates.io/crates/cargo-nextest) to run our test suite. + +### Running Tests + +To run the tests, use: + +```sh +cargo make test +``` From 217c1695bd1ac9c04f5b6312abf1dc644afd2699 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 5 Apr 2025 10:32:49 -0300 Subject: [PATCH 2/3] add more tests --- crates/rust-mcp-macros/src/utils.rs | 323 +++++++++++++++++++++ crates/rust-mcp-macros/tests/macro_test.rs | 2 - 2 files changed, 323 insertions(+), 2 deletions(-) diff --git a/crates/rust-mcp-macros/src/utils.rs b/crates/rust-mcp-macros/src/utils.rs index 705f487..f13db60 100644 --- a/crates/rust-mcp-macros/src/utils.rs +++ b/crates/rust-mcp-macros/src/utils.rs @@ -236,3 +236,326 @@ pub fn renamed_field(attrs: &[Attribute]) -> Option { renamed } + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + use syn::parse_quote; + + fn render(ts: proc_macro2::TokenStream) -> String { + ts.to_string().replace(char::is_whitespace, "") + } + + #[test] + fn test_is_option() { + let ty: Type = parse_quote!(Option); + assert!(is_option(&ty)); + + let ty: Type = parse_quote!(Vec); + assert!(!is_option(&ty)); + } + + #[test] + fn test_is_vec() { + let ty: Type = parse_quote!(Vec); + assert!(is_vec(&ty)); + + let ty: Type = parse_quote!(Option); + assert!(!is_vec(&ty)); + } + + #[test] + fn test_get_inner_type() { + let ty: Type = parse_quote!(Option); + let inner = get_inner_type(&ty); + assert!(inner.is_some()); + let inner = inner.unwrap(); + assert_eq!(quote!(#inner).to_string(), quote!(String).to_string()); + + let ty: Type = parse_quote!(Vec); + let inner = get_inner_type(&ty); + assert!(inner.is_some()); + let inner = inner.unwrap(); + assert_eq!(quote!(#inner).to_string(), quote!(i32).to_string()); + + let ty: Type = parse_quote!(i32); + assert!(get_inner_type(&ty).is_none()); + } + + #[test] + fn test_might_be_struct() { + let ty: Type = parse_quote!(MyStruct); + assert!(might_be_struct(&ty)); + + let ty: Type = parse_quote!(String); + assert!(!might_be_struct(&ty)); + } + + #[test] + fn test_type_to_json_schema_string() { + let ty: Type = parse_quote!(String); + let attrs: Vec = vec![]; + let tokens = type_to_json_schema(&ty, &attrs); + let output = tokens.to_string(); + assert!(output.contains("\"string\"")); + } + + #[test] + fn test_type_to_json_schema_option() { + let ty: Type = parse_quote!(Option); + let attrs: Vec = vec![]; + let tokens = type_to_json_schema(&ty, &attrs); + let output = tokens.to_string(); + assert!(output.contains("\"nullable\"")); + } + + #[test] + fn test_type_to_json_schema_vec() { + let ty: Type = parse_quote!(Vec); + let attrs: Vec = vec![]; + let tokens = type_to_json_schema(&ty, &attrs); + let output = tokens.to_string(); + assert!(output.contains("\"array\"")); + } + + #[test] + fn test_has_derive() { + let attr: Attribute = parse_quote!(#[derive(Clone, Debug)]); + assert!(has_derive(&[attr.clone()], "Debug")); + assert!(!has_derive(&[attr], "Serialize")); + } + + #[test] + fn test_renamed_field() { + let attr: Attribute = parse_quote!(#[serde(rename = "renamed")]); + assert_eq!(renamed_field(&[attr]), Some("renamed".to_string())); + + let attr: Attribute = parse_quote!(#[serde(skip_serializing_if = "Option::is_none")]); + assert_eq!(renamed_field(&[attr]), None); + } + + #[test] + fn test_get_doc_comment_single_line() { + let attrs: Vec = vec![parse_quote!(#[doc = "This is a test comment."])]; + let result = super::get_doc_comment(&attrs); + assert_eq!(result, Some("This is a test comment.".to_string())); + } + + #[test] + fn test_get_doc_comment_multi_line() { + let attrs: Vec = vec![ + parse_quote!(#[doc = "Line one."]), + parse_quote!(#[doc = "Line two."]), + parse_quote!(#[doc = "Line three."]), + ]; + let result = super::get_doc_comment(&attrs); + assert_eq!( + result, + Some("Line one.\nLine two.\nLine three.".to_string()) + ); + } + + #[test] + fn test_get_doc_comment_no_doc() { + let attrs: Vec = vec![parse_quote!(#[allow(dead_code)])]; + let result = super::get_doc_comment(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_get_doc_comment_trim_whitespace() { + let attrs: Vec = vec![parse_quote!(#[doc = " Trimmed line. "])]; + let result = super::get_doc_comment(&attrs); + assert_eq!(result, Some("Trimmed line.".to_string())); + } + + #[test] + fn test_renamed_field_basic() { + let attrs = vec![parse_quote!(#[serde(rename = "new_name")])]; + let result = renamed_field(&attrs); + assert_eq!(result, Some("new_name".to_string())); + } + + #[test] + fn test_renamed_field_without_rename() { + let attrs = vec![parse_quote!(#[serde(default)])]; + let result = renamed_field(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_renamed_field_with_multiple_attrs() { + let attrs = vec![ + parse_quote!(#[serde(default)]), + parse_quote!(#[serde(rename = "actual_name")]), + ]; + let result = renamed_field(&attrs); + assert_eq!(result, Some("actual_name".to_string())); + } + + #[test] + fn test_renamed_field_irrelevant_attribute() { + let attrs = vec![parse_quote!(#[some_other_attr(value = "irrelevant")])]; + let result = renamed_field(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_renamed_field_ignores_other_serde_keys() { + let attrs = vec![parse_quote!(#[serde(skip_serializing_if = "Option::is_none")])]; + let result = renamed_field(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_has_derive_positive() { + let attrs: Vec = vec![parse_quote!(#[derive(Debug, Clone)])]; + assert!(has_derive(&attrs, "Debug")); + assert!(has_derive(&attrs, "Clone")); + } + + #[test] + fn test_has_derive_negative() { + let attrs: Vec = vec![parse_quote!(#[derive(Serialize, Deserialize)])]; + assert!(!has_derive(&attrs, "Debug")); + } + + #[test] + fn test_has_derive_no_derive_attr() { + let attrs: Vec = vec![parse_quote!(#[allow(dead_code)])]; + assert!(!has_derive(&attrs, "Debug")); + } + + #[test] + fn test_has_derive_multiple_attrs() { + let attrs: Vec = vec![ + parse_quote!(#[allow(unused)]), + parse_quote!(#[derive(PartialEq)]), + parse_quote!(#[derive(Eq)]), + ]; + assert!(has_derive(&attrs, "PartialEq")); + assert!(has_derive(&attrs, "Eq")); + assert!(!has_derive(&attrs, "Clone")); + } + + #[test] + fn test_has_derive_empty_attrs() { + let attrs: Vec = vec![]; + assert!(!has_derive(&attrs, "Debug")); + } + + #[test] + fn test_might_be_struct_with_custom_type() { + let ty: syn::Type = parse_quote!(MyStruct); + assert!(might_be_struct(&ty)); + } + + #[test] + fn test_might_be_struct_with_primitive_type() { + let primitives = [ + "i32", "u64", "bool", "f32", "String", "Option", "Vec", "char", "str", + ]; + for ty_str in &primitives { + let ty: syn::Type = syn::parse_str(ty_str).unwrap(); + assert!( + !might_be_struct(&ty), + "Expected '{}' to be not a struct", + ty_str + ); + } + } + + #[test] + fn test_might_be_struct_with_namespaced_type() { + let ty: syn::Type = parse_quote!(std::collections::HashMap); + assert!(!might_be_struct(&ty)); // segments.len() > 1 + } + + #[test] + fn test_might_be_struct_with_generic_arguments() { + let ty: syn::Type = parse_quote!(MyStruct); + assert!(!might_be_struct(&ty)); // has type arguments + } + + #[test] + fn test_might_be_struct_with_empty_type_path() { + let ty: syn::Type = parse_quote!(()); + assert!(!might_be_struct(&ty)); + } + + #[test] + fn test_json_schema_string() { + let ty: syn::Type = parse_quote!(String); + let tokens = type_to_json_schema(&ty, &[]); + let output = render(tokens); + assert!(output + .contains("\"type\".to_string(),serde_json::Value::String(\"string\".to_string())")); + } + + #[test] + fn test_json_schema_number() { + let ty: syn::Type = parse_quote!(i32); + let tokens = type_to_json_schema(&ty, &[]); + let output = render(tokens); + assert!(output + .contains("\"type\".to_string(),serde_json::Value::String(\"number\".to_string())")); + } + + #[test] + fn test_json_schema_boolean() { + let ty: syn::Type = parse_quote!(bool); + let tokens = type_to_json_schema(&ty, &[]); + let output = render(tokens); + assert!(output + .contains("\"type\".to_string(),serde_json::Value::String(\"boolean\".to_string())")); + } + + #[test] + fn test_json_schema_vec_of_string() { + let ty: syn::Type = parse_quote!(Vec); + let tokens = type_to_json_schema(&ty, &[]); + let output = render(tokens); + assert!(output + .contains("\"type\".to_string(),serde_json::Value::String(\"array\".to_string())")); + assert!(output.contains("\"items\".to_string(),serde_json::Value::Object")); + } + + #[test] + fn test_json_schema_option_of_number() { + let ty: syn::Type = parse_quote!(Option); + let tokens = type_to_json_schema(&ty, &[]); + let output = render(tokens); + assert!(output.contains("\"nullable\".to_string(),serde_json::Value::Bool(true)")); + assert!(output + .contains("\"type\".to_string(),serde_json::Value::String(\"number\".to_string())")); + } + + #[test] + fn test_json_schema_custom_struct() { + let ty: syn::Type = parse_quote!(MyStruct); + let tokens = type_to_json_schema(&ty, &[]); + let output = render(tokens); + assert!(output.contains("MyStruct::json_schema()")); + } + + #[test] + fn test_json_schema_with_doc_comment() { + let ty: syn::Type = parse_quote!(String); + let attrs: Vec = vec![parse_quote!(#[doc = "A user name."])]; + let tokens = type_to_json_schema(&ty, &attrs); + let output = render(tokens); + assert!(output.contains( + "\"description\".to_string(),serde_json::Value::String(\"Ausername.\".to_string())" + )); + } + + #[test] + fn test_json_schema_fallback_unknown() { + let ty: syn::Type = parse_quote!((i32, i32)); + let tokens = type_to_json_schema(&ty, &[]); + let output = render(tokens); + assert!(output + .contains("\"type\".to_string(),serde_json::Value::String(\"unknown\".to_string())")); + } +} diff --git a/crates/rust-mcp-macros/tests/macro_test.rs b/crates/rust-mcp-macros/tests/macro_test.rs index a3043e9..3a23c87 100644 --- a/crates/rust-mcp-macros/tests/macro_test.rs +++ b/crates/rust-mcp-macros/tests/macro_test.rs @@ -7,8 +7,6 @@ pub mod common; fn test_rename() { let schema = EditOperation::json_schema(); - println!(">>> schema {:?} ", schema); - assert_eq!(schema.len(), 3); assert!(schema.contains_key("properties")); From c2abcb731189779570a7e88daea57032a7922f41 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 5 Apr 2025 10:55:30 -0300 Subject: [PATCH 3/3] test: add more macro tests --- crates/rust-mcp-macros/src/lib.rs | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/rust-mcp-macros/src/lib.rs b/crates/rust-mcp-macros/src/lib.rs index d950e8d..b5a9aac 100644 --- a/crates/rust-mcp-macros/src/lib.rs +++ b/crates/rust-mcp-macros/src/lib.rs @@ -296,3 +296,60 @@ pub fn derive_json_schema(input: TokenStream) -> TokenStream { }; TokenStream::from(expanded) } + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_str; + #[test] + fn test_valid_macro_attributes() { + let input = r#"name = "test_tool", description = "A test tool.""#; + let parsed: MCPToolMacroAttributes = parse_str(input).unwrap(); + + assert_eq!(parsed.name.unwrap(), "test_tool"); + assert_eq!(parsed.description.unwrap(), "A test tool."); + } + + #[test] + fn test_missing_name() { + let input = r#"description = "Only description""#; + let result: Result = parse_str(input); + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().to_string(), + "The 'name' attribute is required." + ) + } + + #[test] + fn test_missing_description() { + let input = r#"name = "OnlyName""#; + let result: Result = parse_str(input); + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().to_string(), + "The 'description' attribute is required." + ) + } + + #[test] + fn test_empty_name_field() { + let input = r#"name = "", description = "something""#; + let result: Result = parse_str(input); + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().to_string(), + "The 'name' attribute should not be an empty string." + ); + } + #[test] + fn test_empty_description_field() { + let input = r#"name = "my-tool", description = """#; + let result: Result = parse_str(input); + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().to_string(), + "The 'description' attribute should not be an empty string." + ); + } +}