Skip to content

Commit 6867154

Browse files
committed
feat: update macro to detect Json<T> wrapper for output schemas
- Add extract_json_inner_type() helper to detect Json<T> types - Update schema generation to only occur for Json<T> wrapped types - Remove generic Result<T, E> detection in favor of specific Json<T> detection - Add comprehensive tests to verify schema generation behavior
1 parent aeabef5 commit 6867154

File tree

3 files changed

+130
-22
lines changed

3 files changed

+130
-22
lines changed

TODO.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ Implement changes based on PR review feedback from https://github.com/modelconte
1818
- [x] Update other implementations to return `None` (default implementation handles this)
1919

2020
### Phase 3: Update macro to detect Json<T> wrapper
21-
- [ ] Modify `crates/rmcp-macros/src/tool.rs` to detect `Json<T>` in return types
22-
- [ ] Remove generic Result<T, E> detection (only detect Json<T> specifically)
23-
- [ ] Update schema generation to only occur for Json<T> wrapped types
24-
- [ ] Test macro with various return type configurations
21+
- [x] Modify `crates/rmcp-macros/src/tool.rs` to detect `Json<T>` in return types
22+
- [x] Remove generic Result<T, E> detection (only detect Json<T> specifically)
23+
- [x] Update schema generation to only occur for Json<T> wrapped types
24+
- [x] Test macro with various return type configurations
2525

2626
### Phase 4: Add builder methods to Tool struct
2727
- [ ] Add `with_output_schema<T: JsonSchema>()` method to Tool struct

crates/rmcp-macros/src/tool.rs

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ fn none_expr() -> Expr {
9999
syn::parse2::<Expr>(quote! { None }).unwrap()
100100
}
101101

102+
/// Check if a type is Json<T> and extract the inner type T
103+
fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
104+
if let syn::Type::Path(type_path) = ty {
105+
if let Some(last_segment) = type_path.path.segments.last() {
106+
if last_segment.ident == "Json" {
107+
if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments {
108+
if let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() {
109+
return Some(inner_type);
110+
}
111+
}
112+
}
113+
}
114+
}
115+
None
116+
}
117+
102118
// extract doc line from attribute
103119
fn extract_doc_line(existing_docs: Option<String>, attr: &syn::Attribute) -> Option<String> {
104120
if !attr.path().is_ident("doc") {
@@ -207,34 +223,26 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
207223
Some(output_schema)
208224
} else {
209225
// Try to generate schema from return type
210-
// Look for Result<T, E> where T is not CallToolResult
226+
// Look for Json<T> or Result<Json<T>, E>
211227
match &fn_item.sig.output {
212228
syn::ReturnType::Type(_, ret_type) => {
213-
if let syn::Type::Path(type_path) = &**ret_type {
229+
// Check if it's directly Json<T>
230+
if let Some(inner_type) = extract_json_inner_type(ret_type) {
231+
syn::parse2::<Expr>(quote! {
232+
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
233+
}).ok()
234+
} else if let syn::Type::Path(type_path) = &**ret_type {
214235
if let Some(last_segment) = type_path.path.segments.last() {
215236
if last_segment.ident == "Result" {
216237
if let syn::PathArguments::AngleBracketed(args) =
217238
&last_segment.arguments
218239
{
219240
if let Some(syn::GenericArgument::Type(ok_type)) = args.args.first()
220241
{
221-
// Check if the type is NOT CallToolResult
222-
let is_call_tool_result =
223-
if let syn::Type::Path(ok_path) = ok_type {
224-
ok_path
225-
.path
226-
.segments
227-
.last()
228-
.map(|seg| seg.ident == "CallToolResult")
229-
.unwrap_or(false)
230-
} else {
231-
false
232-
};
233-
234-
if !is_call_tool_result {
235-
// Generate schema for the Ok type
242+
// Check if the Ok type is Json<T>
243+
if let Some(inner_type) = extract_json_inner_type(ok_type) {
236244
syn::parse2::<Expr>(quote! {
237-
rmcp::handler::server::tool::cached_schema_for_type::<#ok_type>()
245+
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
238246
}).ok()
239247
} else {
240248
None
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//cargo test --test test_json_schema_detection --features "client server macros"
2+
use rmcp::{
3+
Json, ServerHandler,
4+
handler::server::router::tool::ToolRouter,
5+
tool, tool_handler, tool_router,
6+
};
7+
use schemars::JsonSchema;
8+
use serde::{Deserialize, Serialize};
9+
10+
#[derive(Serialize, Deserialize, JsonSchema)]
11+
pub struct TestData {
12+
pub value: String,
13+
}
14+
15+
#[tool_handler(router = self.tool_router)]
16+
impl ServerHandler for TestServer {}
17+
18+
#[derive(Debug, Clone)]
19+
pub struct TestServer {
20+
tool_router: ToolRouter<Self>,
21+
}
22+
23+
impl Default for TestServer {
24+
fn default() -> Self {
25+
Self::new()
26+
}
27+
}
28+
29+
#[tool_router(router = tool_router)]
30+
impl TestServer {
31+
pub fn new() -> Self {
32+
Self {
33+
tool_router: Self::tool_router(),
34+
}
35+
}
36+
37+
/// Tool that returns Json<T> - should have output schema
38+
#[tool(name = "with-json")]
39+
pub async fn with_json(&self) -> Result<Json<TestData>, String> {
40+
Ok(Json(TestData { value: "test".to_string() }))
41+
}
42+
43+
/// Tool that returns regular type - should NOT have output schema
44+
#[tool(name = "without-json")]
45+
pub async fn without_json(&self) -> Result<String, String> {
46+
Ok("test".to_string())
47+
}
48+
49+
/// Tool that returns Result with inner Json - should have output schema
50+
#[tool(name = "result-with-json")]
51+
pub async fn result_with_json(&self) -> Result<Json<TestData>, rmcp::ErrorData> {
52+
Ok(Json(TestData { value: "test".to_string() }))
53+
}
54+
55+
/// Tool with explicit output_schema attribute - should have output schema
56+
#[tool(name = "explicit-schema", output_schema = rmcp::handler::server::tool::cached_schema_for_type::<TestData>())]
57+
pub async fn explicit_schema(&self) -> Result<String, String> {
58+
Ok("test".to_string())
59+
}
60+
}
61+
62+
#[tokio::test]
63+
async fn test_json_type_generates_schema() {
64+
let server = TestServer::new();
65+
let tools = server.tool_router.list_all();
66+
67+
// Find the with-json tool
68+
let json_tool = tools.iter().find(|t| t.name == "with-json").unwrap();
69+
assert!(json_tool.output_schema.is_some(), "Json<T> return type should generate output schema");
70+
}
71+
72+
#[tokio::test]
73+
async fn test_non_json_type_no_schema() {
74+
let server = TestServer::new();
75+
let tools = server.tool_router.list_all();
76+
77+
// Find the without-json tool
78+
let non_json_tool = tools.iter().find(|t| t.name == "without-json").unwrap();
79+
assert!(non_json_tool.output_schema.is_none(), "Regular return type should NOT generate output schema");
80+
}
81+
82+
#[tokio::test]
83+
async fn test_result_with_json_generates_schema() {
84+
let server = TestServer::new();
85+
let tools = server.tool_router.list_all();
86+
87+
// Find the result-with-json tool
88+
let result_json_tool = tools.iter().find(|t| t.name == "result-with-json").unwrap();
89+
assert!(result_json_tool.output_schema.is_some(), "Result<Json<T>, E> return type should generate output schema");
90+
}
91+
92+
#[tokio::test]
93+
async fn test_explicit_schema_override() {
94+
let server = TestServer::new();
95+
let tools = server.tool_router.list_all();
96+
97+
// Find the explicit-schema tool
98+
let explicit_tool = tools.iter().find(|t| t.name == "explicit-schema").unwrap();
99+
assert!(explicit_tool.output_schema.is_some(), "Explicit output_schema attribute should work");
100+
}

0 commit comments

Comments
 (0)