Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions claudear.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,23 @@ sandbox = ""
# APPWRITE_PROJECT_ID = "monitoring-fra"
# APPWRITE_API_KEY = "${APPWRITE_API_KEY}" # read-only key, set in daemon env

# Grafana (mcp-grafana) — query dashboards, datasources, and metrics so the
# agent can pull live telemetry when triaging an issue.
# [agent.providers.claude.mcp.grafana]
# command = "uvx"
# args = ["mcp-grafana"]
# sources = ["sentry"]
# tools: tool names to allow (empty/omitted grants all of the server's tools).
# tools = ["search_dashboards", "query_prometheus", "list_datasources"]
# [agent.providers.claude.mcp.grafana.env]
# GRAFANA_URL = "https://telemetry.example.com/"
# Env values are written to .mcp.json verbatim, so you can inline literals here
# or reference ${VAR} from the daemon env (Claude Code expands ${VAR}).
# GRAFANA_SERVICE_ACCOUNT_TOKEN = "glsa_xxxxxxxxxxxx" # or "${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
# When Grafana sits behind Cloudflare Access, pass the service-token headers as a
# JSON string (inline literals or ${VAR} both work).
# GRAFANA_EXTRA_HEADERS = "{\"CF-Access-Client-Id\": \"your-cf-access-client-id\", \"CF-Access-Client-Secret\": \"your-cf-access-client-secret\"}"

# A/B Experiments (optional)
# Test different providers or configurations against each other.
#
Expand Down
146 changes: 146 additions & 0 deletions crates/claudear-integrations/src/runner/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ pub struct ClaudeRunnerConfig {
/// MCP servers to attach, keyed by server name. Attachment is gated per-run
/// by each server's `sources` list against the issue source.
pub mcp: HashMap<String, McpServerConfig>,
/// Mirrors the global `debug_logging` flag. When true, the rendered MCP
/// config is logged (with secret values redacted) on each attach.
pub debug_logging: bool,
}

impl Default for ClaudeRunnerConfig {
Expand All @@ -259,6 +262,7 @@ impl Default for ClaudeRunnerConfig {
binary: "claude".to_string(),
env: HashMap::new(),
mcp: HashMap::new(),
debug_logging: false,
}
}
}
Expand Down Expand Up @@ -828,6 +832,50 @@ The PR title should include the issue ID: {}
Ok(file)
}

/// A redacted JSON view of the matched MCP servers for debug logging.
/// Mirrors what `render_mcp_config` writes, but masks every `env`/`headers`
/// value that could hold a secret. Pure `${VAR}` references are shown as-is
/// (they name a variable, not a secret); anything else becomes "***".
fn redact_mcp_for_log(servers: &[(&String, &McpServerConfig)]) -> serde_json::Value {
let mask = |v: &str| -> String {
let t = v.trim();
if t.starts_with("${") && t.ends_with('}') && !t[2..].contains("${") {
v.to_string()
} else {
"***".to_string()
}
};
let redact_map = |m: &std::collections::HashMap<String, String>| -> serde_json::Value {
json!(m
.iter()
.map(|(k, v)| (k.clone(), mask(v)))
.collect::<std::collections::HashMap<_, _>>())
};
let mut out = serde_json::Map::new();
for (name, cfg) in servers {
let mut entry = serde_json::Map::new();
if let Some(ref command) = cfg.command {
entry.insert("command".to_string(), json!(command));
entry.insert("args".to_string(), json!(cfg.args));
if !cfg.env.is_empty() {
entry.insert("env".to_string(), redact_map(&cfg.env));
}
} else if let Some(ref url) = cfg.url {
entry.insert("url".to_string(), json!(url));
if !cfg.headers.is_empty() {
entry.insert("headers".to_string(), redact_map(&cfg.headers));
}
}
if let Some(ref transport) = cfg.transport {
entry.insert("type".to_string(), json!(transport));
}
entry.insert("sources".to_string(), json!(cfg.sources));
entry.insert("tools".to_string(), json!(cfg.tools));
out.insert((*name).clone(), serde_json::Value::Object(entry));
}
json!({ "mcpServers": serde_json::Value::Object(out) })
}

#[allow(clippy::too_many_arguments)]
async fn execute_with_env_and_attempt(
&self,
Expand Down Expand Up @@ -906,6 +954,16 @@ The PR title should include the issue ID: {}
servers = matched_mcp.len(),
"Attaching MCP servers to run"
);
if self.config.debug_logging {
let redacted = Self::redact_mcp_for_log(&matched_mcp);
tracing::info!(
component = "claude",
label = label,
path = %file.path().display(),
config = %redacted,
"Rendered MCP config (secret values redacted)"
);
}
mcp_config_file = Some(file);
}
Err(e) => {
Expand Down Expand Up @@ -3821,6 +3879,94 @@ mod tests {
}
}

#[test]
fn test_redact_mcp_for_log_masks_secrets() {
let name = "grafana".to_string();
let mut env = HashMap::new();
// Pure ${VAR} ref -> shown; literal secret and JSON blob -> masked.
env.insert(
"GRAFANA_URL".to_string(),
"https://tel.example.com/".to_string(),
);
env.insert(
"GRAFANA_SERVICE_ACCOUNT_TOKEN".to_string(),
"${GRAFANA_SERVICE_ACCOUNT_TOKEN}".to_string(),
);
env.insert(
"GRAFANA_TOKEN_LITERAL".to_string(),
"glsa_realsecret".to_string(),
);
env.insert(
"GRAFANA_EXTRA_HEADERS".to_string(),
"{\"CF-Access-Client-Id\": \"${CF_ID}\"}".to_string(),
);
let cfg = McpServerConfig {
command: Some("uvx".to_string()),
args: vec!["mcp-grafana".to_string()],
env,
sources: vec!["sentry".to_string()],
tools: vec!["list_datasources".to_string()],
..Default::default()
};
let servers = vec![(&name, &cfg)];
let doc = ClaudeAgentRunner::redact_mcp_for_log(&servers);
let env_out = &doc["mcpServers"]["grafana"]["env"];
// Non-secret-looking URL is still masked (we mask all non-${VAR} values).
assert_eq!(env_out["GRAFANA_URL"], "***");
// Pure var reference passes through unmasked.
assert_eq!(
env_out["GRAFANA_SERVICE_ACCOUNT_TOKEN"],
"${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
);
// Literal secret is masked.
assert_eq!(env_out["GRAFANA_TOKEN_LITERAL"], "***");
// JSON blob with an embedded ${VAR} is not a pure ref -> masked.
assert_eq!(env_out["GRAFANA_EXTRA_HEADERS"], "***");
// Non-secret structure is preserved.
assert_eq!(doc["mcpServers"]["grafana"]["command"], "uvx");
assert_eq!(doc["mcpServers"]["grafana"]["args"][0], "mcp-grafana");
assert_eq!(doc["mcpServers"]["grafana"]["sources"][0], "sentry");
assert_eq!(doc["mcpServers"]["grafana"]["tools"][0], "list_datasources");
}

#[test]
fn test_render_mcp_config_stdio_json_headers_env() {
// A stdio server whose env carries a JSON blob with ${VAR} references
// (e.g. Grafana behind Cloudflare Access). The value is written verbatim;
// Claude Code expands the ${VAR}s at runtime, including inside the string.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
let name = "grafana".to_string();
let mut env = HashMap::new();
env.insert(
"GRAFANA_SERVICE_ACCOUNT_TOKEN".to_string(),
"${GRAFANA_SERVICE_ACCOUNT_TOKEN}".to_string(),
);
let headers_json =
"{\"CF-Access-Client-Id\": \"${CF_ACCESS_CLIENT_ID}\", \"CF-Access-Client-Secret\": \"${CF_ACCESS_CLIENT_SECRET}\"}";
env.insert(
"GRAFANA_EXTRA_HEADERS".to_string(),
headers_json.to_string(),
);
let cfg = McpServerConfig {
command: Some("uvx".to_string()),
args: vec!["mcp-grafana".to_string()],
env,
sources: vec!["sentry".to_string()],
..Default::default()
};
let servers = vec![(&name, &cfg)];
let file = ClaudeAgentRunner::render_mcp_config(&servers).expect("render");
let doc: serde_json::Value = serde_json::from_str(&read_temp(&file)).unwrap();
let server = &doc["mcpServers"]["grafana"];
assert_eq!(server["command"], "uvx");
assert_eq!(server["args"][0], "mcp-grafana");
assert_eq!(
server["env"]["GRAFANA_SERVICE_ACCOUNT_TOKEN"],
"${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
);
// The JSON blob survives round-trip unescaped and unexpanded.
assert_eq!(server["env"]["GRAFANA_EXTRA_HEADERS"], headers_json);
}

#[test]
fn test_render_mcp_config_http() {
let name = "remote".to_string();
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ pub fn build_provider_runner(
.unwrap_or_else(|| "claude".to_string()),
env: provider.map(|p| p.env.clone()).unwrap_or_default(),
mcp: provider.map(|p| p.mcp.clone()).unwrap_or_default(),
debug_logging: config.debug_logging,
},
tracker,
);
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3805,6 +3805,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> {
.default_provider_config()
.map(|p| p.mcp.clone())
.unwrap_or_default(),
debug_logging: config.debug_logging,
},
tracker.clone(),
)));
Expand Down