Skip to content

Commit 243e703

Browse files
authored
Merge pull request #59 from crossi-dev/fix/numeric-params-string-coercion
fix: accept numeric MCP params sent as strings (#58)
2 parents 28cd53e + 24ae3a7 commit 243e703

1 file changed

Lines changed: 237 additions & 0 deletions

File tree

crates/webclaw-mcp/src/tools.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,61 @@
44
use schemars::JsonSchema;
55
use serde::Deserialize;
66

7+
// ── Coercion helpers ────────────────────────────────────────────────────────
8+
//
9+
// MCP clients (Claude Desktop, VS Code extension, etc.) sometimes pass numeric
10+
// parameters as JSON strings (e.g. `"depth": "3"` instead of `"depth": 3`).
11+
// serde's default u32/usize deserialisers reject strings with:
12+
//
13+
// "invalid type: string \"3\", expected u32"
14+
//
15+
// These two helpers accept both forms transparently so callers never see that
16+
// error regardless of which representation their client sends.
17+
18+
fn deser_opt_u32_or_str<'de, D>(d: D) -> Result<Option<u32>, D::Error>
19+
where
20+
D: serde::Deserializer<'de>,
21+
{
22+
#[derive(serde::Deserialize)]
23+
#[serde(untagged)]
24+
enum NumOrStr {
25+
Num(u32),
26+
Str(String),
27+
}
28+
match Option::<NumOrStr>::deserialize(d)? {
29+
None => Ok(None),
30+
Some(NumOrStr::Num(n)) => Ok(Some(n)),
31+
Some(NumOrStr::Str(s)) => {
32+
s.trim().parse::<u32>().map(Some).map_err(|_| {
33+
serde::de::Error::custom(format!("expected a u32, got string \"{s}\""))
34+
})
35+
}
36+
}
37+
}
38+
39+
fn deser_opt_usize_or_str<'de, D>(d: D) -> Result<Option<usize>, D::Error>
40+
where
41+
D: serde::Deserializer<'de>,
42+
{
43+
#[derive(serde::Deserialize)]
44+
#[serde(untagged)]
45+
enum NumOrStr {
46+
Num(usize),
47+
Str(String),
48+
}
49+
match Option::<NumOrStr>::deserialize(d)? {
50+
None => Ok(None),
51+
Some(NumOrStr::Num(n)) => Ok(Some(n)),
52+
Some(NumOrStr::Str(s)) => {
53+
s.trim().parse::<usize>().map(Some).map_err(|_| {
54+
serde::de::Error::custom(format!("expected a usize, got string \"{s}\""))
55+
})
56+
}
57+
}
58+
}
59+
60+
// ── Parameter structs ───────────────────────────────────────────────────────
61+
762
#[derive(Debug, Deserialize, JsonSchema)]
863
pub struct ScrapeParams {
964
/// URL to scrape
@@ -27,10 +82,13 @@ pub struct CrawlParams {
2782
/// Seed URL to start crawling from
2883
pub url: String,
2984
/// Maximum link depth to follow (default: 2)
85+
#[serde(default, deserialize_with = "deser_opt_u32_or_str")]
3086
pub depth: Option<u32>,
3187
/// Maximum number of pages to crawl (default: 50)
88+
#[serde(default, deserialize_with = "deser_opt_usize_or_str")]
3289
pub max_pages: Option<usize>,
3390
/// Number of concurrent requests (default: 5)
91+
#[serde(default, deserialize_with = "deser_opt_usize_or_str")]
3492
pub concurrency: Option<usize>,
3593
/// Seed the frontier from sitemap discovery before crawling
3694
pub use_sitemap: Option<bool>,
@@ -51,6 +109,7 @@ pub struct BatchParams {
51109
/// Output format: "markdown" (default), "llm", "text"
52110
pub format: Option<String>,
53111
/// Number of concurrent requests (default: 5)
112+
#[serde(default, deserialize_with = "deser_opt_usize_or_str")]
54113
pub concurrency: Option<usize>,
55114
}
56115

@@ -69,6 +128,7 @@ pub struct SummarizeParams {
69128
/// URL to fetch and summarize
70129
pub url: String,
71130
/// Number of sentences in the summary (default: 3)
131+
#[serde(default, deserialize_with = "deser_opt_usize_or_str")]
72132
pub max_sentences: Option<usize>,
73133
}
74134

@@ -101,6 +161,7 @@ pub struct SearchParams {
101161
/// Search query
102162
pub query: String,
103163
/// Number of results to return (default: 10)
164+
#[serde(default, deserialize_with = "deser_opt_u32_or_str")]
104165
pub num_results: Option<u32>,
105166
}
106167

@@ -120,3 +181,179 @@ pub struct VerticalParams {
120181
/// so rmcp can generate a schema and parse the (empty) JSON-RPC params.
121182
#[derive(Debug, Deserialize, JsonSchema)]
122183
pub struct ListExtractorsParams {}
184+
185+
#[cfg(test)]
186+
mod tests {
187+
use super::*;
188+
189+
// ── CrawlParams.depth (u32) ──────────────────────────────────────────────
190+
191+
#[test]
192+
fn crawl_depth_from_numeric_string() {
193+
let v: CrawlParams =
194+
serde_json::from_str(r#"{"url":"https://x.com","depth":"3"}"#).unwrap();
195+
assert_eq!(v.depth, Some(3));
196+
}
197+
198+
#[test]
199+
fn crawl_depth_from_number() {
200+
let v: CrawlParams = serde_json::from_str(r#"{"url":"https://x.com","depth":3}"#).unwrap();
201+
assert_eq!(v.depth, Some(3));
202+
}
203+
204+
#[test]
205+
fn crawl_depth_absent_is_none() {
206+
let v: CrawlParams = serde_json::from_str(r#"{"url":"https://x.com"}"#).unwrap();
207+
assert_eq!(v.depth, None);
208+
}
209+
210+
#[test]
211+
fn crawl_depth_non_numeric_string_errors() {
212+
let e = serde_json::from_str::<CrawlParams>(r#"{"url":"https://x.com","depth":"abc"}"#);
213+
assert!(e.is_err(), "expected Err, got {e:?}");
214+
}
215+
216+
// ── CrawlParams.max_pages (usize) ────────────────────────────────────────
217+
218+
#[test]
219+
fn crawl_max_pages_from_numeric_string() {
220+
let v: CrawlParams =
221+
serde_json::from_str(r#"{"url":"https://x.com","max_pages":"50"}"#).unwrap();
222+
assert_eq!(v.max_pages, Some(50));
223+
}
224+
225+
#[test]
226+
fn crawl_max_pages_from_number() {
227+
let v: CrawlParams =
228+
serde_json::from_str(r#"{"url":"https://x.com","max_pages":50}"#).unwrap();
229+
assert_eq!(v.max_pages, Some(50));
230+
}
231+
232+
#[test]
233+
fn crawl_max_pages_absent_is_none() {
234+
let v: CrawlParams = serde_json::from_str(r#"{"url":"https://x.com"}"#).unwrap();
235+
assert_eq!(v.max_pages, None);
236+
}
237+
238+
#[test]
239+
fn crawl_max_pages_non_numeric_string_errors() {
240+
let e = serde_json::from_str::<CrawlParams>(r#"{"url":"https://x.com","max_pages":"abc"}"#);
241+
assert!(e.is_err(), "expected Err, got {e:?}");
242+
}
243+
244+
// ── CrawlParams.concurrency (usize) ──────────────────────────────────────
245+
246+
#[test]
247+
fn crawl_concurrency_from_numeric_string() {
248+
let v: CrawlParams =
249+
serde_json::from_str(r#"{"url":"https://x.com","concurrency":"5"}"#).unwrap();
250+
assert_eq!(v.concurrency, Some(5));
251+
}
252+
253+
#[test]
254+
fn crawl_concurrency_from_number() {
255+
let v: CrawlParams =
256+
serde_json::from_str(r#"{"url":"https://x.com","concurrency":5}"#).unwrap();
257+
assert_eq!(v.concurrency, Some(5));
258+
}
259+
260+
#[test]
261+
fn crawl_concurrency_absent_is_none() {
262+
let v: CrawlParams = serde_json::from_str(r#"{"url":"https://x.com"}"#).unwrap();
263+
assert_eq!(v.concurrency, None);
264+
}
265+
266+
#[test]
267+
fn crawl_concurrency_non_numeric_string_errors() {
268+
let e =
269+
serde_json::from_str::<CrawlParams>(r#"{"url":"https://x.com","concurrency":"abc"}"#);
270+
assert!(e.is_err(), "expected Err, got {e:?}");
271+
}
272+
273+
// ── BatchParams.concurrency (usize) ──────────────────────────────────────
274+
275+
#[test]
276+
fn batch_concurrency_from_numeric_string() {
277+
let v: BatchParams =
278+
serde_json::from_str(r#"{"urls":["https://x.com"],"concurrency":"5"}"#).unwrap();
279+
assert_eq!(v.concurrency, Some(5));
280+
}
281+
282+
#[test]
283+
fn batch_concurrency_from_number() {
284+
let v: BatchParams =
285+
serde_json::from_str(r#"{"urls":["https://x.com"],"concurrency":5}"#).unwrap();
286+
assert_eq!(v.concurrency, Some(5));
287+
}
288+
289+
#[test]
290+
fn batch_concurrency_absent_is_none() {
291+
let v: BatchParams = serde_json::from_str(r#"{"urls":["https://x.com"]}"#).unwrap();
292+
assert_eq!(v.concurrency, None);
293+
}
294+
295+
#[test]
296+
fn batch_concurrency_non_numeric_string_errors() {
297+
let e = serde_json::from_str::<BatchParams>(
298+
r#"{"urls":["https://x.com"],"concurrency":"abc"}"#,
299+
);
300+
assert!(e.is_err(), "expected Err, got {e:?}");
301+
}
302+
303+
// ── SearchParams.num_results (u32) ───────────────────────────────────────
304+
305+
#[test]
306+
fn search_num_results_from_numeric_string() {
307+
let v: SearchParams =
308+
serde_json::from_str(r#"{"query":"rust","num_results":"10"}"#).unwrap();
309+
assert_eq!(v.num_results, Some(10));
310+
}
311+
312+
#[test]
313+
fn search_num_results_from_number() {
314+
let v: SearchParams = serde_json::from_str(r#"{"query":"rust","num_results":10}"#).unwrap();
315+
assert_eq!(v.num_results, Some(10));
316+
}
317+
318+
#[test]
319+
fn search_num_results_absent_is_none() {
320+
let v: SearchParams = serde_json::from_str(r#"{"query":"rust"}"#).unwrap();
321+
assert_eq!(v.num_results, None);
322+
}
323+
324+
#[test]
325+
fn search_num_results_non_numeric_string_errors() {
326+
let e = serde_json::from_str::<SearchParams>(r#"{"query":"rust","num_results":"abc"}"#);
327+
assert!(e.is_err(), "expected Err, got {e:?}");
328+
}
329+
330+
// ── SummarizeParams.max_sentences (usize) ────────────────────────────────
331+
332+
#[test]
333+
fn summarize_max_sentences_from_numeric_string() {
334+
let v: SummarizeParams =
335+
serde_json::from_str(r#"{"url":"https://x.com","max_sentences":"3"}"#).unwrap();
336+
assert_eq!(v.max_sentences, Some(3));
337+
}
338+
339+
#[test]
340+
fn summarize_max_sentences_from_number() {
341+
let v: SummarizeParams =
342+
serde_json::from_str(r#"{"url":"https://x.com","max_sentences":3}"#).unwrap();
343+
assert_eq!(v.max_sentences, Some(3));
344+
}
345+
346+
#[test]
347+
fn summarize_max_sentences_absent_is_none() {
348+
let v: SummarizeParams = serde_json::from_str(r#"{"url":"https://x.com"}"#).unwrap();
349+
assert_eq!(v.max_sentences, None);
350+
}
351+
352+
#[test]
353+
fn summarize_max_sentences_non_numeric_string_errors() {
354+
let e = serde_json::from_str::<SummarizeParams>(
355+
r#"{"url":"https://x.com","max_sentences":"abc"}"#,
356+
);
357+
assert!(e.is_err(), "expected Err, got {e:?}");
358+
}
359+
}

0 commit comments

Comments
 (0)