@@ -434,7 +434,7 @@ impl ToolCladExecutor {
434434 } ) ?;
435435
436436 // Map validated args to upstream tool's expected format
437- let upstream_args = map_upstream_args ( mcp, validated) ;
437+ let upstream_args = map_upstream_args ( mcp, & manifest . args , validated) ;
438438
439439 let result = crate :: integrations:: mcp:: stdio_client:: RmcpStdioClient :: verified_invoke (
440440 spec,
@@ -507,10 +507,19 @@ impl ToolCladExecutor {
507507
508508/// Map validated local argument names to the upstream MCP tool's expected
509509/// argument names via the manifest's `[mcp.field_map]` (identity when a local
510- /// name has no mapping entry).
510+ /// name has no mapping entry), coercing each value to the JSON type the upstream
511+ /// tool's schema expects based on the manifest arg's declared `type`.
512+ ///
513+ /// `parse_and_validate` stringifies every argument (numbers, booleans, arrays,
514+ /// and objects arrive here as their JSON text), so without this an upstream tool
515+ /// whose schema wants a number/boolean/array/object would receive a quoted
516+ /// string and its JSON-Schema validation would reject it. Coercion is by
517+ /// declared `type_name`; a value that fails to parse falls back to a string
518+ /// rather than erroring (the validator already gate-kept the value).
511519#[ cfg( feature = "mcp-client" ) ]
512520fn map_upstream_args (
513521 mcp : & super :: manifest:: McpProxyDef ,
522+ arg_defs : & HashMap < String , super :: manifest:: ArgDef > ,
514523 validated : & HashMap < String , String > ,
515524) -> serde_json:: Map < String , serde_json:: Value > {
516525 let mut upstream_args = serde_json:: Map :: new ( ) ;
@@ -520,17 +529,47 @@ fn map_upstream_args(
520529 . get ( local_name)
521530 . cloned ( )
522531 . unwrap_or_else ( || local_name. clone ( ) ) ;
523- upstream_args. insert ( upstream_name, serde_json:: json!( value) ) ;
532+ let type_name = arg_defs
533+ . get ( local_name)
534+ . map ( |d| d. type_name . as_str ( ) )
535+ . unwrap_or ( "string" ) ;
536+ upstream_args. insert ( upstream_name, coerce_arg_value ( type_name, value) ) ;
524537 }
525538 upstream_args
526539}
527540
541+ /// Coerce a validated argument's string form to the JSON type its declared
542+ /// ToolClad `type` implies, for MCP upstream dispatch. Unknown/string-like types
543+ /// (`string`, `enum`, `url`, `scope_target`, …) stay strings; a value that
544+ /// doesn't parse falls back to a string so a mislabeled arg can't panic a call.
545+ #[ cfg( feature = "mcp-client" ) ]
546+ fn coerce_arg_value ( type_name : & str , value : & str ) -> serde_json:: Value {
547+ use serde_json:: Value ;
548+ match type_name {
549+ "integer" => value
550+ . parse :: < i64 > ( )
551+ . map ( Value :: from)
552+ . unwrap_or_else ( |_| Value :: String ( value. to_string ( ) ) ) ,
553+ "number" | "float" => value
554+ . parse :: < f64 > ( )
555+ . map ( Value :: from)
556+ . unwrap_or_else ( |_| Value :: String ( value. to_string ( ) ) ) ,
557+ "boolean" => value
558+ . parse :: < bool > ( )
559+ . map ( Value :: from)
560+ . unwrap_or_else ( |_| Value :: String ( value. to_string ( ) ) ) ,
561+ "array" | "object" => serde_json:: from_str :: < Value > ( value)
562+ . unwrap_or_else ( |_| Value :: String ( value. to_string ( ) ) ) ,
563+ _ => Value :: String ( value. to_string ( ) ) ,
564+ }
565+ }
566+
528567#[ async_trait]
529568impl ActionExecutor for ToolCladExecutor {
530569 async fn execute_actions (
531570 & self ,
532571 actions : & [ ProposedAction ] ,
533- _config : & LoopConfig ,
572+ config : & LoopConfig ,
534573 _circuit_breakers : & CircuitBreakerRegistry ,
535574 ) -> Vec < Observation > {
536575 let mut observations = Vec :: new ( ) ;
@@ -565,8 +604,31 @@ impl ActionExecutor for ToolCladExecutor {
565604 } else if is_mcp_tool {
566605 match self . parse_and_validate ( name, arguments) {
567606 Ok ( ( manifest, validated) ) => {
568- self . execute_mcp_backend_async ( name, manifest, & validated)
569- . await
607+ // Bound the MCP call: it spawns a subprocess and does
608+ // a stdio handshake with no inherent deadline, so a
609+ // hung/slow server would otherwise hang the caller
610+ // indefinitely (the DSL `tool_call()` path has no
611+ // outer timeout). Use the tool's declared
612+ // `timeout_seconds`, capped by `config.tool_timeout`;
613+ // treat 0 as "no per-tool limit" and defer to config.
614+ let manifest_secs = manifest. tool . timeout_seconds ;
615+ let call_timeout = if manifest_secs == 0 {
616+ config. tool_timeout
617+ } else {
618+ Duration :: from_secs ( manifest_secs) . min ( config. tool_timeout )
619+ } ;
620+ match tokio:: time:: timeout (
621+ call_timeout,
622+ self . execute_mcp_backend_async ( name, manifest, & validated) ,
623+ )
624+ . await
625+ {
626+ Ok ( r) => r,
627+ Err ( _) => Err ( format ! (
628+ "MCP tool '{}' timed out after {:?}" ,
629+ name, call_timeout
630+ ) ) ,
631+ }
570632 }
571633 Err ( e) => Err ( e) ,
572634 }
@@ -1671,7 +1733,7 @@ query = "q"
16711733 let mut args = HashMap :: new ( ) ;
16721734 args. insert ( "query" . to_string ( ) , "rust async" . to_string ( ) ) ;
16731735
1674- let upstream = map_upstream_args ( & mcp, & args) ;
1736+ let upstream = map_upstream_args ( & mcp, & HashMap :: new ( ) , & args) ;
16751737 assert_eq ! ( upstream[ "q" ] , "rust async" ) ;
16761738 }
16771739
@@ -1688,10 +1750,65 @@ tool = "upstream_tool"
16881750 args. insert ( "input" . to_string ( ) , "hello" . to_string ( ) ) ;
16891751
16901752 // No field_map entry for "input", so it passes through unchanged.
1691- let upstream = map_upstream_args ( & mcp, & args) ;
1753+ let upstream = map_upstream_args ( & mcp, & HashMap :: new ( ) , & args) ;
16921754 assert_eq ! ( upstream[ "input" ] , "hello" ) ;
16931755 }
16941756
1757+ #[ cfg( feature = "mcp-client" ) ]
1758+ #[ test]
1759+ fn test_mcp_args_coerced_to_declared_json_types ( ) {
1760+ use crate :: toolclad:: manifest:: ArgDef ;
1761+ let mcp: crate :: toolclad:: manifest:: McpProxyDef =
1762+ toml:: from_str ( "server = \" s\" \n tool = \" t\" \n " ) . unwrap ( ) ;
1763+
1764+ let mut arg_defs = HashMap :: new ( ) ;
1765+ for ( n, t) in [
1766+ ( "count" , "integer" ) ,
1767+ ( "ratio" , "number" ) ,
1768+ ( "flag" , "boolean" ) ,
1769+ ( "items" , "array" ) ,
1770+ ( "opts" , "object" ) ,
1771+ ( "label" , "string" ) ,
1772+ ( "mode" , "enum" ) ,
1773+ ] {
1774+ arg_defs. insert (
1775+ n. to_string ( ) ,
1776+ ArgDef {
1777+ type_name : t. to_string ( ) ,
1778+ ..Default :: default ( )
1779+ } ,
1780+ ) ;
1781+ }
1782+
1783+ // Values as parse_and_validate would produce them (everything stringified).
1784+ let mut validated = HashMap :: new ( ) ;
1785+ validated. insert ( "count" . to_string ( ) , "5" . to_string ( ) ) ;
1786+ validated. insert ( "ratio" . to_string ( ) , "1.5" . to_string ( ) ) ;
1787+ validated. insert ( "flag" . to_string ( ) , "true" . to_string ( ) ) ;
1788+ validated. insert ( "items" . to_string ( ) , "[1,2,3]" . to_string ( ) ) ;
1789+ validated. insert ( "opts" . to_string ( ) , r#"{"k":1}"# . to_string ( ) ) ;
1790+ validated. insert ( "label" . to_string ( ) , "hello" . to_string ( ) ) ;
1791+ validated. insert ( "mode" . to_string ( ) , "fast" . to_string ( ) ) ;
1792+
1793+ let up = map_upstream_args ( & mcp, & arg_defs, & validated) ;
1794+ // Numbers/booleans become real JSON scalars, not quoted strings.
1795+ assert_eq ! ( up[ "count" ] , serde_json:: json!( 5 ) ) ;
1796+ assert_eq ! ( up[ "ratio" ] , serde_json:: json!( 1.5 ) ) ;
1797+ assert_eq ! ( up[ "flag" ] , serde_json:: json!( true ) ) ;
1798+ // Arrays/objects are parsed back into structured JSON.
1799+ assert_eq ! ( up[ "items" ] , serde_json:: json!( [ 1 , 2 , 3 ] ) ) ;
1800+ assert_eq ! ( up[ "opts" ] , serde_json:: json!( { "k" : 1 } ) ) ;
1801+ // String-like types stay strings.
1802+ assert_eq ! ( up[ "label" ] , serde_json:: json!( "hello" ) ) ;
1803+ assert_eq ! ( up[ "mode" ] , serde_json:: json!( "fast" ) ) ;
1804+
1805+ // A malformed value for a typed arg falls back to a string, never panics.
1806+ let mut bad = HashMap :: new ( ) ;
1807+ bad. insert ( "count" . to_string ( ) , "not-a-number" . to_string ( ) ) ;
1808+ let up2 = map_upstream_args ( & mcp, & arg_defs, & bad) ;
1809+ assert_eq ! ( up2[ "count" ] , serde_json:: json!( "not-a-number" ) ) ;
1810+ }
1811+
16951812 #[ test]
16961813 fn test_mcp_proxy_dispatch_fails_closed_without_async_runtime ( ) {
16971814 // `execute_tool` (the sync `symbi tools` CLI path) dispatches
0 commit comments