44use schemars:: JsonSchema ;
55use 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 ) ]
863pub 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 ) ]
122183pub 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