feat(mcp): add toolCallTimeoutMs, timeout/reconnect/error handling

- Add toolCallTimeoutMs to stdio MCP config with 60s default
- tools/call runs under timeout with dedicated Timeout error
- Handle malformed JSON/broken protocol as InvalidResponse
- Reset/reconnect stdio state on child exit or transport drop
- Add tests: slow timeout, invalid JSON response, stdio reconnect
- Verified: cargo test -p runtime 113 passed, clippy clean
This commit is contained in:
YeonGyu-Kim
2026-04-02 18:24:30 +09:00
parent 6e4b0123a6
commit 3b18ce9f3f
4 changed files with 866 additions and 135 deletions

View File

@@ -106,6 +106,7 @@ pub struct McpStdioServerConfig {
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub tool_call_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -791,6 +792,7 @@ fn parse_mcp_server_config(
command: expect_string(object, "command", context)?.to_string(),
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
})),
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
object, context,
@@ -914,6 +916,27 @@ fn optional_u16(
}
}
fn optional_u64(
object: &BTreeMap<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<u64>, ConfigError> {
match object.get(key) {
Some(value) => {
let Some(number) = value.as_i64() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key} must be a non-negative integer"
)));
};
let number = u64::try_from(number).map_err(|_| {
ConfigError::Parse(format!("{context}: field {key} is out of range"))
})?;
Ok(Some(number))
}
None => Ok(None),
}
}
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
let Some(map) = value.as_object() else {
return Err(ConfigError::Parse(format!(