use std::ffi::OsString;
use std::path::{Path, PathBuf};
use thiserror::Error;
const ARG_MAX_SAFETY_MARGIN_BYTES: usize = 4_096;
const DEFAULT_ARG_MAX_BYTES: usize = 32_768;
const DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES: usize = 65_536;
const WALKUP_MAX_DEPTH: usize = 16;
pub fn is_skipped() -> bool {
crate::config::get_setting("spawn.skip_preflight")
.ok()
.flatten()
.is_some_and(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes"
)
})
}
#[derive(Debug)]
pub struct PreFlightArgs<'a> {
pub binary_path: &'a Path,
pub argv: &'a [OsString],
pub workspace_root: &'a Path,
pub mcp_config_inline_json: Option<&'a str>,
pub expected_output_bytes: usize,
pub spawner_name: &'static str,
}
#[derive(Debug, Error)]
pub enum PreFlightError {
#[error("binary not found: {path}")]
BinaryNotFound {
path: PathBuf,
},
#[error("argv exceeds ARG_MAX: total_bytes={total_bytes}, arg_max={arg_max}, safety_margin_bytes={ARG_MAX_SAFETY_MARGIN_BYTES}")]
ArgvExceedsArgMax {
total_bytes: usize,
arg_max: usize,
},
#[error("--mcp-config expects filepath, got inline JSON '{0}'; Claude Code 2.1.177 rejects this form; substitute suggested tempfile")]
McpConfigInlineJsonRejected(String),
#[error("--mcp-config path missing: {path}")]
McpConfigPathMissing {
path: PathBuf,
},
#[error("--mcp-config path invalid JSON at {path}: {error}")]
McpConfigPathInvalidJson {
path: PathBuf,
error: String,
},
#[error(".mcp.json walk-up found invalid file at {path}: {error}; set CLAUDE_CONFIG_DIR to an empty directory or move the workspace to a parent without .mcp.json")]
WalkUpMcpJsonInvalid {
path: PathBuf,
error: String,
},
#[error("output buffer too small: expected={expected} bytes, configured_limit={configured} bytes; chunk the request or increase the buffer cap")]
OutputBufferTooSmall {
expected: usize,
configured: usize,
},
#[error("CLAUDE_CONFIG_DIR={path} contains settings.json with active MCP servers ({reason}); unset the env var or remove the offending entries")]
ClaudeConfigDirNotEmpty {
path: PathBuf,
reason: &'static str,
},
}
pub fn preflight_check(args: &PreFlightArgs) -> Result<(), PreFlightError> {
if is_skipped() {
tracing::warn!(
target: "preflight",
event = "preflight_skipped",
spawner = args.spawner_name,
"spawn.skip_preflight is set — pre-flight checks bypassed; the 5-bug-class risk is accepted"
);
return Ok(());
}
let argv_total = compute_argv_bytes(args.argv);
check_argv_size(argv_total)?;
check_binary_exists(args.binary_path)?;
check_output_buffer(args.expected_output_bytes)?;
check_mcp_config_inline(args.mcp_config_inline_json)?;
check_mcp_config_path(args.argv)?;
check_walkup_mcp_json(args.workspace_root)?;
check_claude_config_dir()?;
tracing::info!(
target: "preflight",
event = "preflight_passed",
spawner = args.spawner_name,
argv_bytes = argv_total,
workspace_root = %args.workspace_root.display(),
"pre-flight validation passed"
);
Ok(())
}
pub fn write_empty_mcp_config_tempfile() -> Result<PathBuf, std::io::Error> {
use std::io::Write;
let mut tmp = tempfile::Builder::new()
.prefix("graphrag-mcp-")
.suffix(".json")
.tempfile()?;
tmp.write_all(br#"{"mcpServers":{}}"#)?;
tmp.flush()?;
let (_, path) = tmp.keep()?;
Ok(path)
}
fn compute_argv_bytes(argv: &[OsString]) -> usize {
argv.iter().map(|s| s.as_os_str().len() + 1).sum()
}
fn arg_max_bytes() -> usize {
#[cfg(unix)]
{
let n = unsafe { libc::sysconf(libc::_SC_ARG_MAX) };
if n > 0 {
n as usize
} else {
DEFAULT_ARG_MAX_BYTES
}
}
#[cfg(not(unix))]
{
DEFAULT_ARG_MAX_BYTES
}
}
fn check_argv_size(argv_total: usize) -> Result<(), PreFlightError> {
let max = arg_max_bytes();
if argv_total + ARG_MAX_SAFETY_MARGIN_BYTES > max {
return Err(PreFlightError::ArgvExceedsArgMax {
total_bytes: argv_total,
arg_max: max,
});
}
Ok(())
}
fn check_binary_exists(binary_path: &Path) -> Result<(), PreFlightError> {
if binary_path.exists() {
Ok(())
} else {
Err(PreFlightError::BinaryNotFound {
path: binary_path.to_path_buf(),
})
}
}
fn check_output_buffer(expected: usize) -> Result<(), PreFlightError> {
if expected > DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES {
Err(PreFlightError::OutputBufferTooSmall {
expected,
configured: DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES,
})
} else {
Ok(())
}
}
fn check_mcp_config_inline(inline: Option<&str>) -> Result<(), PreFlightError> {
if let Some(s) = inline {
let trimmed = s.trim();
if trimmed.starts_with('{') && trimmed.ends_with('}') {
return Err(PreFlightError::McpConfigInlineJsonRejected(s.to_string()));
}
}
Ok(())
}
fn check_mcp_config_path(argv: &[OsString]) -> Result<(), PreFlightError> {
let mut iter = argv.iter();
while let Some(arg) = iter.next() {
let path = if arg == "--mcp-config" {
match iter.next() {
Some(value) => PathBuf::from(value),
None => continue,
}
} else if let Some(stripped) = arg.to_str().and_then(|s| s.strip_prefix("--mcp-config=")) {
PathBuf::from(stripped)
} else {
continue;
};
validate_mcp_config_path(&path)?;
}
Ok(())
}
fn validate_mcp_config_path(path: &Path) -> Result<(), PreFlightError> {
if !path.exists() {
return Err(PreFlightError::McpConfigPathMissing {
path: path.to_path_buf(),
});
}
let contents =
std::fs::read_to_string(path).map_err(|e| PreFlightError::McpConfigPathInvalidJson {
path: path.to_path_buf(),
error: e.to_string(),
})?;
if let Err(e) = serde_json::from_str::<serde_json::Value>(&contents) {
return Err(PreFlightError::McpConfigPathInvalidJson {
path: path.to_path_buf(),
error: e.to_string(),
});
}
Ok(())
}
fn check_walkup_mcp_json(workspace_root: &Path) -> Result<(), PreFlightError> {
let mut current = workspace_root.to_path_buf();
for _ in 0..WALKUP_MAX_DEPTH {
let candidate = current.join(".mcp.json");
if candidate.exists() {
let contents = std::fs::read_to_string(&candidate).map_err(|e| {
PreFlightError::WalkUpMcpJsonInvalid {
path: candidate.clone(),
error: e.to_string(),
}
})?;
let parsed: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
PreFlightError::WalkUpMcpJsonInvalid {
path: candidate.clone(),
error: e.to_string(),
}
})?;
let has_active_mcps = parsed
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|o| !o.is_empty())
.unwrap_or(false);
if has_active_mcps {
return Err(PreFlightError::WalkUpMcpJsonInvalid {
path: candidate,
error: "mcpServers declares active entries; set CLAUDE_CONFIG_DIR to an empty directory or remove the file".to_string(),
});
}
return Ok(());
}
match current.parent() {
Some(p) => current = p.to_path_buf(),
None => break,
}
}
Ok(())
}
fn check_claude_config_dir() -> Result<(), PreFlightError> {
let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") else {
return Ok(());
};
let path = PathBuf::from(&dir);
if !path.is_dir() {
return Ok(());
}
let settings = path.join("settings.json");
if !settings.exists() {
if std::fs::read_dir(&path)
.map(|mut i| i.next().is_some())
.unwrap_or(false)
{
tracing::warn!(
target: "preflight",
path = %path.display(),
"CLAUDE_CONFIG_DIR is populated but contains no settings.json; \
MCP servers and hooks will not be auto-loaded"
);
}
return Ok(());
}
let contents = match std::fs::read_to_string(&settings) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
target: "preflight",
path = %settings.display(),
error = %e,
"CLAUDE_CONFIG_DIR/settings.json exists but could not be read; \
skipping semantic validation"
);
return Ok(());
}
};
let parsed: serde_json::Value = match serde_json::from_str(&contents) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "preflight",
path = %settings.display(),
error = %e,
"CLAUDE_CONFIG_DIR/settings.json is not valid JSON; \
skipping semantic validation"
);
return Ok(());
}
};
let has_mcp_servers = parsed
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|o| !o.is_empty())
.unwrap_or(false);
if has_mcp_servers {
return Err(PreFlightError::ClaudeConfigDirNotEmpty {
path,
reason: "mcpServers",
});
}
Ok(())
}
#[cfg(test)]
#[path = "preflight_tests.rs"]
mod tests;