use rmcp::ErrorData as McpErrorData;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum McpToolError {
#[error("target is outside allowed roots")]
TargetOutsideAllowedRoots,
#[error("target could not be resolved")]
TargetCanonicalisationFailed,
#[error("path argument rejected: {0}")]
InvalidPathSyntax(String),
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("artifact not found: {0}")]
NotFound(String),
#[error("internal error: {0}")]
Internal(String),
}
impl McpToolError {
#[allow(clippy::needless_pass_by_value)]
pub fn from_anyhow(e: anyhow::Error) -> Self {
tracing::warn!(error = %format!("{e:#}"), "mcp tool internal error");
Self::Internal("internal error (see server logs)".into())
}
#[allow(clippy::needless_pass_by_value)]
pub fn from_io(e: std::io::Error) -> Self {
tracing::warn!(error = %e, kind = ?e.kind(), "mcp tool io error");
let coarse = match e.kind() {
std::io::ErrorKind::NotFound => "io: not found",
std::io::ErrorKind::PermissionDenied => "io: permission denied",
std::io::ErrorKind::InvalidData => "io: invalid data",
_ => "io: error",
};
Self::Internal(coarse.into())
}
#[allow(clippy::needless_pass_by_value)]
pub fn internal_redacted(phase: &'static str, inner: impl std::fmt::Display) -> Self {
tracing::warn!(phase, error = %inner, "mcp tool internal error");
Self::Internal(format!("{phase}: see server logs"))
}
pub fn artifact_missing(label: &'static str) -> Self {
tracing::warn!(label, "mcp tool: artifact missing");
Self::NotFound(format!("{label} missing — run cordance pack to produce it"))
}
}
impl From<McpToolError> for McpErrorData {
fn from(value: McpToolError) -> Self {
match value {
McpToolError::TargetOutsideAllowedRoots => {
Self::invalid_params("target is outside allowed roots", None)
}
McpToolError::TargetCanonicalisationFailed => {
Self::invalid_params("target could not be resolved", None)
}
McpToolError::InvalidPathSyntax(msg)
| McpToolError::InvalidArgument(msg)
| McpToolError::NotFound(msg) => Self::invalid_params(msg, None),
McpToolError::Internal(msg) => Self::internal_error(msg, None),
}
}
}
pub type McpToolResult<T> = Result<T, McpToolError>;
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::ErrorCode;
#[test]
fn target_outside_roots_is_invalid_params() {
let e = McpToolError::TargetOutsideAllowedRoots;
let wire: McpErrorData = e.into();
assert_eq!(wire.code, ErrorCode::INVALID_PARAMS);
}
#[test]
fn target_outside_roots_wire_message_omits_paths() {
let e = McpToolError::TargetOutsideAllowedRoots;
let wire: McpErrorData = e.into();
let msg = wire.message.to_string();
assert!(
!msg.contains('/') && !msg.contains('\\'),
"wire message must not contain any path separator: {msg:?}"
);
assert_eq!(msg, "target is outside allowed roots");
}
#[test]
fn target_canonicalisation_failed_wire_message_omits_paths() {
let e = McpToolError::TargetCanonicalisationFailed;
let wire: McpErrorData = e.into();
let msg = wire.message.to_string();
assert!(
!msg.contains('/') && !msg.contains('\\'),
"wire message must not contain any path separator: {msg:?}"
);
assert_eq!(msg, "target could not be resolved");
}
#[test]
fn internal_is_internal_error_code() {
let e = McpToolError::Internal("disk full".into());
let wire: McpErrorData = e.into();
assert_eq!(wire.code, ErrorCode::INTERNAL_ERROR);
}
#[test]
fn from_anyhow_returns_fixed_shape_internal() {
let e = McpToolError::from_anyhow(anyhow::anyhow!("upstream broke"));
match e {
McpToolError::Internal(msg) => {
assert!(
!msg.contains("upstream broke"),
"inner anyhow message leaked to wire: {msg}"
);
assert!(
msg.contains("server logs"),
"fixed-shape message should hint at log correlation: {msg}"
);
}
other => panic!("expected Internal, got {other:?}"),
}
}
#[test]
fn from_anyhow_does_not_leak_inner_message() {
let inner_path = "/secret/path/to/config.toml";
let inner = anyhow::anyhow!("loading {inner_path} failed");
let err = McpToolError::from_anyhow(inner);
let wire = format!("{err}");
assert!(
!wire.contains("/secret"),
"wire error leaked path: {wire}"
);
assert!(
!wire.contains("config.toml"),
"wire error leaked filename: {wire}"
);
}
#[test]
fn from_anyhow_wire_message_omits_paths() {
let inner = anyhow::anyhow!(
"failed to read /etc/passwd: permission denied while processing C:\\Users\\foo"
);
let err = McpToolError::from_anyhow(inner);
let wire: McpErrorData = err.into();
let msg = wire.message.to_string();
assert!(
!msg.contains("/etc/passwd"),
"posix path leaked through to wire: {msg}"
);
assert!(
!msg.contains("C:\\Users"),
"windows path leaked through to wire: {msg}"
);
assert_eq!(wire.code, ErrorCode::INTERNAL_ERROR);
}
#[test]
fn from_io_does_not_leak_inner_message() {
let inner = std::io::Error::new(
std::io::ErrorKind::NotFound,
"/secret/path/missing",
);
let err = McpToolError::from_io(inner);
let wire = format!("{err}");
assert!(
!wire.contains("/secret"),
"io error leaked path: {wire}"
);
assert!(
wire.contains("not found"),
"io kind should still surface: {wire}"
);
}
#[test]
fn from_io_classifies_permission_denied() {
let inner = std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"/root/.ssh/id_rsa",
);
let err = McpToolError::from_io(inner);
let wire = format!("{err}");
assert!(
!wire.contains("/root"),
"io error leaked path: {wire}"
);
assert!(
!wire.contains("id_rsa"),
"io error leaked filename: {wire}"
);
assert!(
wire.contains("permission denied"),
"io kind should surface as 'permission denied': {wire}"
);
}
#[test]
fn from_io_falls_back_to_generic_for_unmapped_kinds() {
let inner = std::io::Error::other("/var/log/secret.log was bad");
let err = McpToolError::from_io(inner);
let wire = format!("{err}");
assert!(
!wire.contains("/var/log"),
"fallback path leaked: {wire}"
);
assert!(
!wire.contains("secret.log"),
"fallback filename leaked: {wire}"
);
assert!(
wire.contains("io:"),
"fallback should still announce it was an io error: {wire}"
);
}
#[test]
fn internal_redacted_carries_phase_and_log_hint() {
let err = McpToolError::internal_redacted(
"parse sources.lock",
"expected value at line 17 column 3",
);
let wire = format!("{err}");
assert!(
wire.contains("parse sources.lock"),
"phase name should surface to the peer: {wire}"
);
assert!(
wire.contains("see server logs"),
"log-correlation hint should surface: {wire}"
);
assert!(
!wire.contains("line 17"),
"inner serde-style detail leaked: {wire}"
);
assert!(
!wire.contains("column 3"),
"inner serde-style detail leaked: {wire}"
);
assert!(
!wire.contains("expected value"),
"inner error text leaked: {wire}"
);
}
#[test]
fn internal_redacted_wire_message_has_no_path_separators() {
let err = McpToolError::internal_redacted(
"load sources.lock",
"/etc/passwd: permission denied while parsing C:\\Users\\alice\\config.toml",
);
let wire: McpErrorData = err.into();
let msg = wire.message.to_string();
assert!(
!msg.contains('/'),
"forward slash leaked into wire message: {msg:?}"
);
assert!(
!msg.contains('\\'),
"backslash leaked into wire message: {msg:?}"
);
}
#[test]
fn internal_redacted_does_not_leak_embedded_secrets() {
let inner = format!(
"{} | {} | {}",
"/etc/passwd",
"C:\\Users\\alice\\.ssh\\id_rsa",
"-----BEGIN PRIVATE KEY-----",
);
let err = McpToolError::internal_redacted("dummy phase", inner);
let wire = format!("{err}");
assert!(
!wire.contains("/etc/passwd"),
"posix path leaked through redacted wrapper: {wire}"
);
assert!(
!wire.contains("C:\\Users"),
"windows path leaked through redacted wrapper: {wire}"
);
assert!(
!wire.contains("BEGIN PRIVATE KEY"),
"PEM banner leaked through redacted wrapper: {wire}"
);
}
#[test]
fn internal_redacted_routes_through_internal_error_code() {
let err = McpToolError::internal_redacted("phase", "anything");
let wire: McpErrorData = err.into();
assert_eq!(wire.code, ErrorCode::INTERNAL_ERROR);
}
#[test]
fn artifact_missing_wire_message_has_no_path_bytes() {
let err = McpToolError::artifact_missing("sources.lock");
let wire: McpErrorData = err.into();
let msg = wire.message.to_string();
assert!(
!msg.contains('/'),
"forward slash leaked into artifact_missing wire message: {msg:?}"
);
assert!(
!msg.contains('\\'),
"backslash leaked into artifact_missing wire message: {msg:?}"
);
assert!(
msg.contains("sources.lock"),
"label should surface to the peer: {msg:?}"
);
assert!(
msg.contains("cordance pack"),
"remediation hint should surface: {msg:?}"
);
}
#[test]
fn artifact_missing_routes_through_invalid_params() {
let err = McpToolError::artifact_missing("sources.lock");
let wire: McpErrorData = err.into();
assert_eq!(wire.code, ErrorCode::INVALID_PARAMS);
}
}