Skip to content

Commit 116bc30

Browse files
committed
MCP hardening: coerce upstream args to declared JSON types, bound calls with per-call timeout; serde_with 3.21.0 (GHSA-7gcf-g7xr-8hxj)
1 parent 7b22630 commit 116bc30

6 files changed

Lines changed: 225 additions & 59 deletions

File tree

Cargo.lock

Lines changed: 16 additions & 42 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/runtime/src/toolclad/executor.rs

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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")]
512520
fn 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]
529568
impl 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\"\ntool = \"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

crates/runtime/src/toolclad/manifest.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ fn default_hash() -> String {
108108
}
109109

110110
/// Argument definition.
111-
#[derive(Debug, Clone, Serialize, Deserialize)]
111+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112112
pub struct ArgDef {
113113
pub position: u32,
114114
#[serde(default)]

crates/runtime/tests/mcp_e2e.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,68 @@ async fn execute_actions_fails_closed_when_verification_enforced() {
161161
observations[0]
162162
);
163163
}
164+
165+
#[tokio::test(flavor = "multi_thread")]
166+
#[serial]
167+
async fn execute_actions_times_out_a_hung_mcp_server() {
168+
// A registry entry pointing at `sleep` never completes the MCP handshake, so
169+
// the call would hang forever without a per-call timeout. execute_actions
170+
// must bound it (config.tool_timeout here) and surface an is_error
171+
// observation quickly — this is what protects the DSL tool_call() path,
172+
// which has no outer timeout of its own.
173+
let dir = tempfile::tempdir().expect("tempdir");
174+
std::fs::write(
175+
dir.path().join("mcp-config.toml"),
176+
"[servers.hang]\ncommand = \"sleep\"\nargs = [\"30\"]\n",
177+
)
178+
.expect("write mcp-config.toml");
179+
180+
// Manifest routes to the "hang" server; verification off so we exercise the
181+
// invoke path, not the schemapin gate.
182+
let mut manifest = build_echo_manifest();
183+
manifest.mcp = Some(McpProxyDef {
184+
server: "hang".to_string(),
185+
tool: "noop".to_string(),
186+
field_map: HashMap::new(),
187+
});
188+
189+
let original = std::env::current_dir().expect("cwd");
190+
std::env::set_current_dir(dir.path()).expect("set cwd");
191+
192+
let executor =
193+
ToolCladExecutor::new(vec![("echo".to_string(), manifest)]).with_mcp_verification(false);
194+
let action = ProposedAction::ToolCall {
195+
call_id: "c1".to_string(),
196+
name: "echo".to_string(),
197+
arguments: r#"{"text":"hi"}"#.to_string(),
198+
};
199+
let config = LoopConfig {
200+
tool_timeout: std::time::Duration::from_millis(300),
201+
..Default::default()
202+
};
203+
204+
let start = std::time::Instant::now();
205+
let observations = executor
206+
.execute_actions(&[action], &config, &CircuitBreakerRegistry::default())
207+
.await;
208+
let elapsed = start.elapsed();
209+
210+
std::env::set_current_dir(&original).expect("restore cwd");
211+
212+
assert_eq!(observations.len(), 1);
213+
assert!(
214+
observations[0].is_error,
215+
"a hung MCP server must surface as an error, got: {:?}",
216+
observations[0]
217+
);
218+
assert!(
219+
observations[0].content.contains("timed out"),
220+
"observation should report a timeout, got: {}",
221+
observations[0].content
222+
);
223+
// Must return near the 300ms bound, not the 30s sleep.
224+
assert!(
225+
elapsed < std::time::Duration::from_secs(5),
226+
"timeout should fire promptly, took {elapsed:?}"
227+
);
228+
}

0 commit comments

Comments
 (0)