use std::sync::Arc;
use rmcp::RoleServer;
use rmcp::model::{CallToolResult, Content, ErrorData};
use rmcp::service::RequestContext;
use tracing::instrument;
use crate::filters::{CompiledRule, maybe_inject_no_stat};
use crate::metrics::MetricsSender;
use crate::otel::{ClientMetadata, extract_and_set_trace_context};
use crate::shell_write;
use crate::tools::common::{err_to_tool_result, error_meta, no_cache_meta};
use crate::tools::exec_runtime::{DEFAULT_DRAIN_TIMEOUT_MS, run_exec_impl};
use crate::{ExecCommandParams, SIZE_LIMIT, STDIN_MAX_BYTES, ShellOutput, validate_path};
pub(crate) struct ExecContext {
pub(crate) seq: u32,
pub(crate) sid: Option<String>,
pub(crate) session_id: Option<String>,
pub(crate) client_name: Option<String>,
pub(crate) client_version: Option<String>,
pub(crate) resolved_path: Option<String>,
pub(crate) filter_table: std::sync::Arc<Vec<CompiledRule>>,
pub(crate) metrics_tx: MetricsSender,
pub(crate) t_start: std::time::Instant,
}
#[allow(clippy::result_large_err)]
fn validate_working_dir_phase(
params: &ExecCommandParams,
span: &tracing::Span,
) -> Result<(String, Option<std::path::PathBuf>), CallToolResult> {
let working_dir_path = if let Some(ref wd) = params.working_dir {
match std::fs::canonicalize(wd) {
Ok(p) => {
if !p.is_dir() {
span.record("error", true);
span.record("error.type", "invalid_params");
let mut result = CallToolResult::error(vec![Content::text(
"working_dir is not a directory; provide an existing directory path"
.to_string(),
)])
.with_meta(Some(no_cache_meta()));
result.structured_content = Some(serde_json::json!({
"workingDir": wd,
}));
return Err(result);
}
Some(p)
}
Err(e) => {
span.record("error", true);
span.record("error.type", "invalid_params");
let mut result = CallToolResult::error(vec![Content::text(
"working_dir is not valid; provide an existing directory path".to_string(),
)])
.with_meta(Some(no_cache_meta()));
result.structured_content = Some(serde_json::json!({
"workingDir": wd,
"error": e.to_string(),
}));
return Err(result);
}
}
} else {
None
};
let (effective_command, cd_extracted_path) = strip_cd_prefix(¶ms.command);
let (command, working_dir_path) = if let Some(cd_path) = cd_extracted_path {
if working_dir_path.is_none() {
let is_plain_absolute = cd_path.starts_with('/')
&& !cd_path.contains('$')
&& !cd_path.contains('~')
&& cd_path != "-";
if !is_plain_absolute {
(params.command.clone(), working_dir_path)
} else {
match validate_path(cd_path, true) {
Ok(p) if p.is_dir() => {
tracing::debug!(
"exec_command: promoting cd prefix path as working_dir: {}",
p.display()
);
(effective_command.to_owned(), Some(p))
}
Ok(_) => {
span.record("error", true);
span.record("error.type", "invalid_params");
let mut result = CallToolResult::error(vec![Content::text(
"cd prefix path is not a directory; set working_dir explicitly or use a valid directory path".to_string(),
)])
.with_meta(Some(no_cache_meta()));
result.structured_content = Some(serde_json::json!({
"cdPath": cd_path,
}));
return Err(result);
}
Err(_) => {
span.record("error", true);
span.record("error.type", "invalid_params");
let mut result = CallToolResult::error(vec![Content::text(
"cd prefix path does not exist or is outside CWD; set working_dir explicitly".to_string(),
)])
.with_meta(Some(no_cache_meta()));
result.structured_content = Some(serde_json::json!({
"cdPath": cd_path,
}));
return Err(result);
}
}
}
} else {
let cd_resolves_to_same = validate_path(cd_path, true)
.ok()
.map(|p| Some(&p) == working_dir_path.as_ref())
.unwrap_or(false);
if cd_resolves_to_same {
tracing::debug!(
"exec_command: stripped redundant cd prefix; matches explicit working_dir"
);
(effective_command.to_owned(), working_dir_path)
} else {
(params.command.clone(), working_dir_path)
}
}
} else {
(params.command.clone(), working_dir_path)
};
let command = maybe_inject_no_stat(&command);
Ok((command, working_dir_path))
}
#[allow(clippy::result_large_err)]
fn validate_pre_spawn_phase(
params: &ExecCommandParams,
command: &str,
span: &tracing::Span,
) -> Result<std::time::Duration, CallToolResult> {
if let Some(ref stdin_content) = params.stdin
&& stdin_content.len() > STDIN_MAX_BYTES
{
span.record("error", true);
span.record("error.type", "invalid_params");
let result = CallToolResult::error(vec![Content::text(
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"stdin exceeds 1 MB limit".to_string(),
Some(error_meta("validation", false, "reduce stdin content size")),
)
.message,
)])
.with_meta(Some(no_cache_meta()));
return Err(result);
}
if let Err(e) = shell_write::validate_heredocs(command, params.stdin.is_some()) {
span.record("error", true);
span.record("error.type", "invalid_params");
return Err(err_to_tool_result(e));
}
if let Some(n) = params.drain_timeout_secs
&& n < 0
{
span.record("error", true);
span.record("error.type", "invalid_params");
let result = CallToolResult::error(vec![Content::text(
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"drain_timeout_secs must be >= 0".to_string(),
Some(error_meta(
"validation",
false,
"use a non-negative value or omit it",
)),
)
.message,
)])
.with_meta(Some(no_cache_meta()));
return Err(result);
}
let drain_dur = match params.drain_timeout_secs {
Some(n) if n > 0 => std::time::Duration::from_millis(n as u64),
_ => std::time::Duration::from_millis(DEFAULT_DRAIN_TIMEOUT_MS),
};
Ok(drain_dur)
}
#[allow(clippy::too_many_arguments)]
async fn spawn_and_collect_phase(
command: String,
working_dir_path: Option<std::path::PathBuf>,
params: &ExecCommandParams,
seq: u32,
resolved_path_str: Option<&str>,
filter_table: &Arc<Vec<CompiledRule>>,
drain_dur: std::time::Duration,
span: &tracing::Span,
) -> Result<(ShellOutput, u64, u64), CallToolResult> {
let (output, raw_stdout_bytes, raw_stderr_bytes) = run_exec_impl(
command,
working_dir_path,
params.stdin.clone(),
seq,
resolved_path_str,
filter_table,
params.timeout_secs,
drain_dur,
)
.await;
if output.timed_out {
span.record("error", true);
span.record("error.type", "timeout");
let mut result = CallToolResult::error(vec![Content::text(
"Command execution timed out; the process was killed.".to_string(),
)])
.with_meta(Some(no_cache_meta()));
result.structured_content = Some(serde_json::json!({
"timed_out": true,
"timeout_secs": params.timeout_secs,
}));
return Err(result);
}
Ok((output, raw_stdout_bytes, raw_stderr_bytes))
}
fn format_shell_output_phase(output: &ShellOutput, params: &ExecCommandParams) -> (String, bool) {
let output_text = if output.interleaved.is_empty() {
format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
} else {
format!("Output:\n{}", output.interleaved)
};
let mut combined_truncated = false;
let truncated_output_text = if output_text.len() > SIZE_LIMIT {
combined_truncated = true;
let tail_start = output_text.len().saturating_sub(SIZE_LIMIT);
let safe_start = output_text.floor_char_boundary(tail_start);
output_text[safe_start..].to_string()
} else {
output_text
};
let mut truncation_notice = String::new();
if output.stdout_path.is_some()
|| output.stderr_path.is_some()
|| output.interleaved_path.is_some()
{
truncation_notice.push_str("(Full output persisted to: ");
let mut paths = Vec::new();
if let Some(ref p) = output.stdout_path {
paths.push(format!("stdout={}", p));
}
if let Some(ref p) = output.stderr_path {
paths.push(format!("stderr={}", p));
}
if let Some(ref p) = output.interleaved_path {
paths.push(format!("interleaved={}", p));
}
truncation_notice.push_str(&paths.join(", "));
truncation_notice.push_str(")\n");
}
let text = format!(
"Command: {}\nExit code: {}\nOutput truncated: {}\n{}{}",
params.command,
output
.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "null".to_string()),
output.output_truncated || combined_truncated,
truncation_notice,
truncated_output_text,
);
(text, combined_truncated)
}
#[instrument(
name = "exec_command_impl",
skip(params, context, ctx),
fields(
gen_ai.system = tracing::field::Empty,
gen_ai.operation.name = tracing::field::Empty,
gen_ai.tool.name = tracing::field::Empty,
error = tracing::field::Empty,
error.type = tracing::field::Empty,
command = tracing::field::Empty,
exit_code = tracing::field::Empty,
output_truncated = tracing::field::Empty,
mcp.session.id = tracing::field::Empty,
client.name = tracing::field::Empty,
client.version = tracing::field::Empty,
mcp.client.session.id = tracing::field::Empty
)
)]
pub(crate) async fn exec_command_impl(
params: ExecCommandParams,
context: RequestContext<RoleServer>,
ctx: ExecContext,
) -> Result<CallToolResult, ErrorData> {
let ExecContext {
seq,
sid,
session_id,
client_name,
client_version,
resolved_path,
filter_table,
metrics_tx,
t_start,
} = ctx;
extract_and_set_trace_context(
Some(&context.meta),
ClientMetadata {
session_id,
client_name,
client_version,
},
);
let span = tracing::Span::current();
span.record("gen_ai.system", "mcp");
span.record("gen_ai.operation.name", "execute_tool");
span.record("gen_ai.tool.name", "exec_command");
span.record("command", ¶ms.command);
let param_path = params.working_dir.clone();
let working_dir_used = params.working_dir.is_some();
let stdin_provided = params.stdin.is_some();
let timeout_configured_ms = params.timeout_secs.map(|s| s * 1000);
let drain_timeout_ms = params.drain_timeout_secs;
let (command, working_dir_path) = match validate_working_dir_phase(¶ms, &span) {
Ok((cmd, wd)) => {
span.record("command", &cmd);
(cmd, wd)
}
Err(result) => {
let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
metrics_tx.send(
crate::metrics::MetricEventBuilder::new("exec_command", "error", dur)
.param_path_depth(crate::metrics::path_component_count(
param_path.as_deref().unwrap_or(""),
))
.error_type(Some("invalid_params".to_string()))
.session_id(sid)
.seq(Some(seq))
.output_truncated(Some(false))
.stdin_provided(stdin_provided)
.timeout_configured_ms(timeout_configured_ms)
.drain_timeout_ms(drain_timeout_ms)
.working_dir_used(working_dir_used)
.build(),
);
return Ok(result);
}
};
let drain_dur = match validate_pre_spawn_phase(¶ms, &command, &span) {
Ok(dur) => dur,
Err(result) => {
let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
metrics_tx.send(
crate::metrics::MetricEventBuilder::new("exec_command", "error", dur)
.param_path_depth(crate::metrics::path_component_count(
param_path.as_deref().unwrap_or(""),
))
.error_type(Some("invalid_params".to_string()))
.session_id(sid)
.seq(Some(seq))
.output_truncated(Some(false))
.stdin_provided(stdin_provided)
.timeout_configured_ms(timeout_configured_ms)
.drain_timeout_ms(drain_timeout_ms)
.working_dir_used(working_dir_used)
.build(),
);
return Ok(result);
}
};
let resolved_path_str = resolved_path.as_deref();
let (mut output, raw_stdout_bytes, raw_stderr_bytes) = match spawn_and_collect_phase(
command.clone(),
working_dir_path.clone(),
¶ms,
seq,
resolved_path_str,
&filter_table,
drain_dur,
&span,
)
.await
{
Ok(o) => o,
Err(result) => {
let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
metrics_tx.send(
crate::metrics::MetricEventBuilder::new("exec_command", "error", dur)
.param_path_depth(crate::metrics::path_component_count(
param_path.as_deref().unwrap_or(""),
))
.error_type(Some("timeout".to_string()))
.session_id(sid)
.seq(Some(seq))
.timed_out(true)
.output_truncated(Some(false))
.stdin_provided(stdin_provided)
.timeout_configured_ms(timeout_configured_ms)
.drain_timeout_ms(drain_timeout_ms)
.working_dir_used(working_dir_used)
.build(),
);
return Ok(result);
}
};
let exit_code = output.exit_code;
let mut output_truncated = output.output_truncated;
if let Some(code) = exit_code {
span.record("exit_code", code);
}
let (text, combined_truncated) = format_shell_output_phase(&output, ¶ms);
output_truncated = output_truncated || combined_truncated;
output.output_truncated = output_truncated;
span.record("output_truncated", output_truncated);
if output_truncated {
tracing::debug!(truncated = true, message = "output truncated");
}
let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
let command_failed = exit_code.map(|c| c != 0).unwrap_or(false);
let mut result = if command_failed {
CallToolResult::error(content_blocks)
} else {
CallToolResult::success(content_blocks)
}
.with_meta(Some(no_cache_meta()));
let structured = match serde_json::to_value(&output).map_err(|e| {
ErrorData::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
format!("serialization failed: {e}"),
Some(error_meta("internal", false, "report this as a bug")),
)
}) {
Ok(v) => v,
Err(e) => {
span.record("error", true);
span.record("error.type", "internal_error");
let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
metrics_tx.send(
crate::metrics::MetricEventBuilder::new("exec_command", "error", dur)
.param_path_depth(crate::metrics::path_component_count(
param_path.as_deref().unwrap_or(""),
))
.error_type(Some("internal_error".to_string()))
.session_id(sid.clone())
.seq(Some(seq))
.exit_code(exit_code)
.timed_out(output.timed_out)
.output_truncated(Some(output_truncated))
.stdin_provided(stdin_provided)
.timeout_configured_ms(timeout_configured_ms)
.drain_timeout_ms(drain_timeout_ms)
.working_dir_used(working_dir_used)
.build(),
);
return Ok(err_to_tool_result(e));
}
};
result.structured_content = Some(structured);
let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
let mut metric_builder = crate::metrics::MetricEventBuilder::new("exec_command", "ok", dur)
.output_chars(text.len())
.param_path_depth(crate::metrics::path_component_count(
param_path.as_deref().unwrap_or(""),
))
.session_id(sid)
.seq(Some(seq))
.exit_code(exit_code)
.timed_out(output.timed_out)
.output_truncated(Some(output_truncated))
.chars_threshold_breach(text.len() > 30_000)
.filter_applied(output.filter_applied.clone())
.stdin_provided(stdin_provided)
.timeout_configured_ms(timeout_configured_ms)
.drain_timeout_ms(drain_timeout_ms)
.working_dir_used(working_dir_used);
if output_truncated && !output.timed_out && output.output_collection_error.is_none() {
metric_builder = metric_builder
.stdout_bytes_raw(raw_stdout_bytes)
.stderr_bytes_raw(raw_stderr_bytes);
}
metrics_tx.send(metric_builder.build());
Ok(result)
}
pub(crate) fn strip_cd_prefix(cmd: &str) -> (&str, Option<&str>) {
let trimmed = cmd.trim_start();
let Some(rest) = trimmed.strip_prefix("cd ") else {
return (cmd, None);
};
let Some((path_part, rest_part)) = rest.split_once("&&") else {
return (cmd, None);
};
let path = path_part.trim();
let stripped = rest_part.trim();
(stripped, Some(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ShellOutput;
#[test]
fn test_format_shell_output_mid_char_boundary() {
let mut interleaved = String::new();
interleaved.push('\u{4E2D}'); interleaved.push_str(&"a".repeat(4998)); assert_eq!(interleaved.len(), 5001);
let output = ShellOutput {
stdout: String::new(),
stderr: String::new(),
interleaved,
exit_code: Some(0),
output_truncated: false,
output_collection_error: None,
stdout_path: None,
stderr_path: None,
interleaved_path: None,
filter_applied: None,
timed_out: false,
};
let params = ExecCommandParams {
command: "echo test".to_string(),
working_dir: None,
stdin: None,
timeout_secs: None,
drain_timeout_secs: None,
};
let (result, truncated) = format_shell_output_phase(&output, ¶ms);
assert!(truncated, "should be truncated");
assert!(result.is_char_boundary(0), "start should be char boundary");
assert!(
result.is_char_boundary(result.len()),
"end should be char boundary"
);
let _char_count = result.chars().count();
}
}