Skip to content

Commit 41abe75

Browse files
authored
feat: add $ref support to JSON Schema parser (#22)
1 parent f4c418d commit 41abe75

File tree

4 files changed

+113
-15
lines changed

4 files changed

+113
-15
lines changed

src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod templates;
99
mod types;
1010
mod utils;
1111

12+
use serde_json::{to_value, Map, Value};
1213
pub use templates::OutputTemplate;
1314
pub use types::{
1415
DiscoveryCommand, LogLevel, McpCapabilities, McpServerInfo, McpToolMeta, ParamTypes,
@@ -328,7 +329,9 @@ impl McpDiscovery {
328329
let mut tools: Vec<_> = tools_result
329330
.iter()
330331
.map(|tool| {
331-
let params = tool_params(&tool.input_schema.properties);
332+
let root_schema: serde_json::Value =
333+
to_value(&tool.input_schema).unwrap_or_else(|_| Value::Object(Map::new()));
334+
let params = tool_params(&tool.input_schema.properties, &root_schema);
332335

333336
Ok::<McpToolMeta, DiscoveryError>(McpToolMeta {
334337
name: tool.name.to_owned(),

src/schema.rs

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashMap;
1+
use std::collections::{HashMap, HashSet};
22

33
use serde_json::{Map, Value};
44

@@ -7,8 +7,71 @@ use crate::{
77
types::{McpToolSParams, ParamTypes},
88
};
99

10+
/// Resolves a $ref path to its target value in the schema.
11+
fn resolve_ref<'a>(
12+
ref_path: &str,
13+
root_schema: &'a Value,
14+
visited: &mut HashSet<String>,
15+
) -> DiscoveryResult<&'a Value> {
16+
if !ref_path.starts_with("#/") {
17+
return Err(DiscoveryError::InvalidSchema(format!(
18+
"$ref '{}' must start with '#/'",
19+
ref_path
20+
)));
21+
}
22+
23+
if !visited.insert(ref_path.to_string()) {
24+
return Err(DiscoveryError::InvalidSchema(format!(
25+
"Cycle detected in $ref path '{}'",
26+
ref_path
27+
)));
28+
}
29+
30+
let path = ref_path.trim_start_matches("#/").split('/');
31+
let mut current = root_schema;
32+
33+
for segment in path {
34+
if segment.is_empty() {
35+
return Err(DiscoveryError::InvalidSchema(format!(
36+
"Invalid $ref path '{}': empty segment",
37+
ref_path
38+
)));
39+
}
40+
current = match current {
41+
Value::Object(obj) => obj.get(segment).ok_or_else(|| {
42+
DiscoveryError::InvalidSchema(format!(
43+
"Invalid $ref path '{}': segment '{}' not found",
44+
ref_path, segment
45+
))
46+
})?,
47+
Value::Array(arr) => segment
48+
.parse::<usize>()
49+
.ok()
50+
.and_then(|i| arr.get(i))
51+
.ok_or_else(|| {
52+
DiscoveryError::InvalidSchema(format!(
53+
"Invalid $ref path '{}': segment '{}' not found in array",
54+
ref_path, segment
55+
))
56+
})?,
57+
_ => {
58+
return Err(DiscoveryError::InvalidSchema(format!(
59+
"Invalid $ref path '{}': cannot traverse into non-object/array",
60+
ref_path
61+
)))
62+
}
63+
};
64+
}
65+
66+
Ok(current)
67+
}
68+
1069
/// Parses an object schema into a vector of `McpToolSParams`.
11-
pub fn param_object(object_map: &Map<String, Value>) -> DiscoveryResult<Vec<McpToolSParams>> {
70+
pub fn param_object(
71+
object_map: &Map<String, Value>,
72+
root_schema: &Value,
73+
visited: &mut HashSet<String>,
74+
) -> DiscoveryResult<Vec<McpToolSParams>> {
1275
let properties = object_map
1376
.get("properties")
1477
.and_then(|v| v.as_object())
@@ -31,7 +94,7 @@ pub fn param_object(object_map: &Map<String, Value>) -> DiscoveryResult<Vec<McpT
3194
"Property '{}' is not an object",
3295
param_name
3396
)))?;
34-
let param_type = param_type(param_value)?;
97+
let param_type = param_type(param_value, root_schema, visited)?;
3598
let param_description = object_map
3699
.get("description")
37100
.and_then(|v| v.as_str())
@@ -50,7 +113,26 @@ pub fn param_object(object_map: &Map<String, Value>) -> DiscoveryResult<Vec<McpT
50113
}
51114

52115
/// Determines the parameter type from a schema definition.
53-
pub fn param_type(type_info: &Map<String, Value>) -> DiscoveryResult<ParamTypes> {
116+
pub fn param_type(
117+
type_info: &Map<String, Value>,
118+
root_schema: &Value,
119+
visited: &mut HashSet<String>,
120+
) -> DiscoveryResult<ParamTypes> {
121+
// Handle $ref
122+
if let Some(ref_path) = type_info.get("$ref") {
123+
let ref_path_str = ref_path.as_str().ok_or(DiscoveryError::InvalidSchema(
124+
"$ref must be a string".to_string(),
125+
))?;
126+
let ref_value = resolve_ref(ref_path_str, root_schema, visited)?;
127+
let ref_map = ref_value
128+
.as_object()
129+
.ok_or(DiscoveryError::InvalidSchema(format!(
130+
"$ref '{}' does not point to an object",
131+
ref_path_str
132+
)))?;
133+
return param_type(ref_map, root_schema, visited);
134+
}
135+
54136
// Check for 'enum' keyword
55137
if let Some(enum_values) = type_info.get("enum") {
56138
let values = enum_values.as_array().ok_or(DiscoveryError::InvalidSchema(
@@ -90,6 +172,7 @@ pub fn param_type(type_info: &Map<String, Value>) -> DiscoveryResult<ParamTypes>
90172
));
91173
}
92174

175+
// Check for 'anyOf'
93176
if let Some(any_of) = type_info.get("anyOf") {
94177
let any_of_array = any_of.as_array().ok_or(DiscoveryError::InvalidSchema(
95178
"'anyOf' field must be an array".to_string(),
@@ -104,7 +187,7 @@ pub fn param_type(type_info: &Map<String, Value>) -> DiscoveryResult<ParamTypes>
104187
let item_map = item.as_object().ok_or(DiscoveryError::InvalidSchema(
105188
"Items in 'anyOf' must be objects".to_string(),
106189
))?;
107-
enum_types.push(param_type(item_map)?);
190+
enum_types.push(param_type(item_map, root_schema, visited)?);
108191
}
109192
return Ok(ParamTypes::Anyof(enum_types));
110193
}
@@ -124,7 +207,7 @@ pub fn param_type(type_info: &Map<String, Value>) -> DiscoveryResult<ParamTypes>
124207
let item_map = item.as_object().ok_or(DiscoveryError::InvalidSchema(
125208
"Items in 'oneOf' must be objects".to_string(),
126209
))?;
127-
one_of_types.push(param_type(item_map)?);
210+
one_of_types.push(param_type(item_map, root_schema, visited)?);
128211
}
129212
return Ok(ParamTypes::OneOf(one_of_types));
130213
}
@@ -144,12 +227,12 @@ pub fn param_type(type_info: &Map<String, Value>) -> DiscoveryResult<ParamTypes>
144227
let item_map = item.as_object().ok_or(DiscoveryError::InvalidSchema(
145228
"Items in 'allOf' must be objects".to_string(),
146229
))?;
147-
all_of_types.push(param_type(item_map)?);
230+
all_of_types.push(param_type(item_map, root_schema, visited)?);
148231
}
149232
return Ok(ParamTypes::AllOf(all_of_types));
150233
}
151234

152-
// other types
235+
// Other types
153236
let type_name =
154237
type_info
155238
.get("type")
@@ -166,22 +249,34 @@ pub fn param_type(type_info: &Map<String, Value>) -> DiscoveryResult<ParamTypes>
166249
"Missing or invalid 'items' field in array type".to_string(),
167250
),
168251
)?;
169-
Ok(ParamTypes::Array(vec![param_type(items_map)?]))
252+
Ok(ParamTypes::Array(vec![param_type(
253+
items_map,
254+
root_schema,
255+
visited,
256+
)?]))
170257
}
171-
"object" => Ok(ParamTypes::Object(param_object(type_info)?)),
258+
"object" => Ok(ParamTypes::Object(param_object(
259+
type_info,
260+
root_schema,
261+
visited,
262+
)?)),
172263
_ => Ok(ParamTypes::Primitive(type_name.to_string())),
173264
}
174265
}
175266

267+
/// Processes tool parameters with a given properties map and root schema.
176268
pub fn tool_params(
177269
properties: &Option<HashMap<String, Map<String, Value>>>,
270+
root_schema: &Value,
178271
) -> Vec<McpToolSParams> {
272+
let mut visited = HashSet::new();
179273
let result = properties.clone().map(|props| {
180274
let mut params: Vec<_> = props
181275
.iter()
182276
.map(|(prop_name, prop_map)| {
183277
let param_name = prop_name.to_owned();
184-
let prop_type = param_type(prop_map).unwrap();
278+
let prop_type = param_type(prop_map, root_schema, &mut visited)
279+
.unwrap_or_else(|_| ParamTypes::Primitive("unknown".to_string()));
185280
let prop_description = prop_map
186281
.get("description")
187282
.and_then(|v| v.as_str())

templates/html/html_tools.hbs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<td>
2222
<ul>
2323
{{#each this.params}}
24-
<li style="white-space: nowrap;"> <code>{{{this.param_name}}}</code> : {{{tool_param_type
24+
<li style=""> <code>{{{this.param_name}}}</code> : {{{tool_param_type
2525
this.param_type}}}<br /></li>
2626
{{/each}}
2727
</ul>
@@ -31,4 +31,4 @@
3131
</tbody>
3232
</table>
3333

34-
{{/if}}
34+
{{/if}}

templates/markdown/md_tools.hbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<td>
2222
<ul>
2323
{{#each this.params}}
24-
<li style="white-space: nowrap;"> <code>{{{this.param_name}}}</code> : {{{tool_param_type
24+
<li> <code>{{{this.param_name}}}</code> : {{{tool_param_type
2525
this.param_type}}}<br /></li>
2626
{{/each}}
2727
</ul>

0 commit comments

Comments
 (0)