diff --git a/Dockerfile b/Dockerfile index f4211c06..fb3f1cbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ ARG BUN_VERSION=1.3 ARG DEBIAN_VERSION=trixie ARG ONNXRUNTIME_VERSION=1.24.2 ARG VECTORLITE_VERSION=16a01af79add +ARG MCP_GRAFANA_VERSION=v1.3.0 ARG GIT_USER_NAME="Claudear" ARG GIT_USER_EMAIL="claudear@noreply.local" @@ -109,6 +110,7 @@ RUN touch src/main.rs src/lib.rs \ FROM debian:${DEBIAN_VERSION}-slim AS final ARG GIT_USER_NAME ARG GIT_USER_EMAIL +ARG MCP_GRAFANA_VERSION WORKDIR /app @@ -129,6 +131,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get update && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* +# Grafana MCP server, so agents can query Prometheus metrics and Loki logs while +# triaging. A single static Go binary: no extra language runtime, and nothing is +# downloaded on first use the way `uvx mcp-grafana` would. Attached to runs only +# when [agent.providers.claude.mcp.grafana] is configured. +RUN ARCH=$(dpkg --print-architecture) \ + && case "${ARCH}" in \ + amd64) MCP_ARCH=x86_64 ;; \ + arm64) MCP_ARCH=arm64 ;; \ + *) echo "unsupported arch: ${ARCH}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "https://github.com/grafana/mcp-grafana/releases/download/${MCP_GRAFANA_VERSION}/mcp-grafana_Linux_${MCP_ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin mcp-grafana \ + && chmod 755 /usr/local/bin/mcp-grafana + COPY --from=vectorlite /build/build/release/vectorlite/vectorlite.so /usr/local/lib/vectorlite.so COPY --from=builder /app/target/release/claudear /usr/local/bin/claudear COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh diff --git a/README.md b/README.md index 587829eb..d75f5b6d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Point it at Linear, Sentry, Jira, GitLab, Discord, Slack, or GitHub review comme - [Installation](#installation) - [Quick Start](#quick-start) - [Configuration](#configuration) + - [MCP Servers](#mcp-servers) - [Usage](#usage) - [Daemon Mode](#daemon-mode) - [Polling Mode](#polling-mode-foreground) @@ -255,6 +256,7 @@ Point it at Linear, Sentry, Jira, GitLab, Discord, Slack, or GitHub review comme | | Codex | CLI | OpenAI Codex runner | | | Gemini | CLI | Google Gemini *(planned)* | | | Copilot | CLI | GitHub Copilot *(planned)* | +| **Telemetry** | Grafana | MCP (stdio) | Prometheus metrics + Loki logs, pulled by the agent while triaging | | **Storage** | SQLite | Local file | WAL mode, Vectorlite extension | | **Embeddings** | Nomic | ONNX | Default model, local inference | | | MiniLM | ONNX | Lightweight alternative | @@ -412,6 +414,59 @@ All config values can be overridden with environment variables, useful for keepi | `CLAUDEAR_TLS_HTTP_REDIRECT_PORT` | `tls.http_redirect_port` | | `CLAUDEAR_DISCORD_BOT_TOKEN` | `notifiers.discord.bot_token` | | `CLAUDEAR_SLACK_BOT_TOKEN` | `notifiers.slack.bot_token` | +| `GRAFANA_URL` | Grafana base URL (see [MCP Servers](#mcp-servers)) | +| `GRAFANA_SERVICE_ACCOUNT_TOKEN` | Grafana service account token | +| `GRAFANA_EXTRA_HEADERS` | Extra Grafana request headers, as a JSON object | + +The `GRAFANA_*` variables are read by the MCP server rather than by Claudear itself, +so they are not `CLAUDEAR_`-prefixed and do not map onto a config path. Reference them +as `${VAR}` from the server's `env` block. + +### MCP Servers + +Agent runs can be given [MCP](https://modelcontextprotocol.io) servers, letting the +agent reach systems the issue payload does not describe. Servers are declared per +provider and gated by issue source: + +```toml +[agent.providers.claude.mcp.grafana] +command = "mcp-grafana" +args = ["--disable-write"] +sources = ["sentry"] +tools = ["list_datasources", "query_prometheus", "query_loki_logs"] +instructions = "Bound every query to the issue's First Seen / Last Seen window." + +[agent.providers.claude.mcp.grafana.env] +GRAFANA_URL = "https://grafana.example.com/" +GRAFANA_SERVICE_ACCOUNT_TOKEN = "${GRAFANA_SERVICE_ACCOUNT_TOKEN}" +``` + +| Key | Meaning | +|-----|---------| +| `command` / `args` | stdio transport: the server process to spawn | +| `url` / `headers` / `type` | HTTP or SSE transport, as an alternative to `command` | +| `env` | Environment for the server process; `${VAR}` is expanded at run time | +| `sources` | Issue sources this server attaches for; empty means all | +| `tools` | Tool names to allow; empty grants every tool the server exposes | +| `instructions` | Guidance added to the agent's prompt whenever this server attaches | + +Notes: + +- `sources` gates on the issue's source, not the kind of run. A server listed for + `sentry` attaches to Sentry fix, verify, and reply runs alike. Classification runs + never attach MCP servers. +- Without `instructions` the agent holds the tools but is never told they exist, so it + will usually ignore them. Put your datasource UIDs and label conventions there. + Per-repo detail belongs in that repo's `AGENT.md`, already prepended to every prompt. +- Keep secrets out of `claudear.toml` — reference them as `${VAR}` from the daemon + environment. The rendered config is written to a private temp file (mode 0600) that + is deleted when the run ends, and the repo's own `.mcp.json` is ignored. +- **Grafana** gives the agent Prometheus metrics and Loki logs through Grafana's + datasources, so it can check what production was doing when an error fired instead of + reasoning from the stack trace alone. The Docker image ships the `mcp-grafana` binary; + for other installs, take it from + [grafana/mcp-grafana releases](https://github.com/grafana/mcp-grafana/releases) and put + it on `PATH`. A Viewer-role service account is enough. ### Minimal Configuration diff --git a/claudear.example.toml b/claudear.example.toml index 6f1847cd..889eac32 100644 --- a/claudear.example.toml +++ b/claudear.example.toml @@ -146,6 +146,9 @@ sandbox = "" # MCP servers attached to agent runs, keyed by server name. # Gated per-run by `sources` against the issue source. Default here: HelpScout only. # Add "discord" to enable there; set sources = [] to enable for all sources. +# Note `sources` gates on the issue's source, not the kind of run, so a server +# listed for "sentry" attaches to Sentry fix, verify, and reply runs alike. +# Classification runs never attach MCP servers. # Keep secrets out of this file: reference them via ${VAR} from the daemon/provider env. # [agent.providers.claude.mcp.appwrite] # command = "uvx" @@ -153,27 +156,61 @@ sandbox = "" # sources = ["helpscout"] # tools: tool names to allow (empty/omitted grants all of the server's tools). # tools = ["databases_list_documents", "databases_get_document"] +# instructions: optional guidance added to the prompt whenever this server +# attaches, telling the agent what the tools cover and when to reach for them. +# instructions = "Look up the customer's document by the ID in the ticket." # [agent.providers.claude.mcp.appwrite.env] # APPWRITE_ENDPOINT = "https://fra.cloud.appwrite.io/v1" # 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. +# Grafana (mcp-grafana) — reach Prometheus metrics and Loki logs through Grafana +# as datasources, so the agent can check what production was actually doing when +# an error fired rather than reasoning from the stack trace alone. +# The Docker image ships the mcp-grafana binary. Installing from a deb/tarball? +# Grab it from https://github.com/grafana/mcp-grafana/releases and put it on PATH. # [agent.providers.claude.mcp.grafana] -# command = "uvx" -# args = ["mcp-grafana"] +# command = "mcp-grafana" +# --disable-write drops every mutating tool (dashboards, incidents, alert rules). +# args = ["--disable-write"] # sources = ["sentry"] # tools: tool names to allow (empty/omitted grants all of the server's tools). -# tools = ["search_dashboards", "query_prometheus", "list_datasources"] +# Listing them explicitly keeps the grant read-only even if a future mcp-grafana +# release adds tools --disable-write does not cover. +# tools = [ +# "list_datasources", "get_datasource", +# "query_prometheus", "list_prometheus_metric_names", "list_prometheus_label_names", +# "list_prometheus_label_values", "query_prometheus_histogram", +# "query_loki_logs", "list_loki_label_names", "list_loki_label_values", +# "query_loki_stats", "query_loki_patterns", +# "search_dashboards", +# ] +# instructions: injected into the agent's prompt whenever this server attaches. +# Without it the agent holds the tools but is never told they exist. Put your +# deployment's specifics here — datasource UIDs, label conventions, limits. +# Per-repo detail (which service label a repo maps to) belongs in that repo's +# AGENT.md, which is already prepended to every prompt. +# instructions = """ +# You have read-only Grafana access covering Prometheus metrics and Loki logs. +# Before settling on a root cause, correlate the error against production telemetry: +# - Bound every query to the issue's First Seen / Last Seen window. Never query open-ended ranges. +# - Check whether the error rate tracks a deploy, a traffic spike, or resource saturation. +# - Pull the Loki lines around the failure for the affected service to see what preceded it. +# Call list_datasources once to find datasource UIDs. Keep result sets small: prefer +# aggregations and short windows over raw log dumps, which will crowd out your context. +# """ # [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}" +# Reference secrets as ${VAR} from the daemon env — Claude Code expands them when +# it reads the generated .mcp.json. Inlining a literal here puts the secret in a +# file the dashboard's config editor can read, so prefer ${VAR} for anything secret. +# GRAFANA_SERVICE_ACCOUNT_TOKEN = "${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\"}" +# JSON string. Keep the whole bundle in one env var rather than inlining the secret. +# GRAFANA_EXTRA_HEADERS = "${GRAFANA_EXTRA_HEADERS}" +# +# Give the Grafana service account the Viewer role: the query tools need +# datasources:query and datasources:read, and search_dashboards needs dashboards:read. # A/B Experiments (optional) # Test different providers or configurations against each other. diff --git a/crates/claudear-config/src/config.rs b/crates/claudear-config/src/config.rs index d619e9cf..c7fc9cdf 100644 --- a/crates/claudear-config/src/config.rs +++ b/crates/claudear-config/src/config.rs @@ -197,6 +197,12 @@ pub struct McpServerConfig { /// server's tools (`mcp__`). Applies to every run that attaches this /// server. pub tools: Vec, + /// Guidance injected into the agent's prompt whenever this server attaches. + /// Without it the agent is handed the server's tools but never told they + /// exist, what they cover, or when reaching for them is worthwhile. Put the + /// deployment-specific detail here: datasource UIDs, label conventions, and + /// any limits worth respecting. + pub instructions: Option, } impl McpServerConfig { @@ -3617,6 +3623,38 @@ mod tests { appwrite.env.get("APPWRITE_API_KEY").map(String::as_str), Some("${APPWRITE_API_KEY}") ); + // Omitted `instructions` must stay None rather than defaulting to a string, + // so the prompt's telemetry block stays absent for servers without guidance. + assert_eq!(appwrite.instructions, None); + } + + #[test] + fn test_mcp_config_parses_instructions() { + let toml = r#" + [agent.providers.claude.mcp.grafana] + command = "mcp-grafana" + args = ["--disable-write"] + sources = ["sentry"] + tools = ["query_loki_logs", "query_prometheus"] + instructions = """ +Bound every query to the issue's First Seen / Last Seen window. +Call list_datasources once to find datasource UIDs. +""" + "#; + let cfg: Config = toml::from_str(toml).expect("parse"); + let grafana = cfg + .agent + .providers + .get("claude") + .expect("provider") + .mcp + .get("grafana") + .expect("mcp server"); + assert_eq!(grafana.command.as_deref(), Some("mcp-grafana")); + assert_eq!(grafana.args, vec!["--disable-write".to_string()]); + let instructions = grafana.instructions.as_deref().expect("instructions"); + assert!(instructions.contains("First Seen / Last Seen")); + assert!(instructions.contains("list_datasources")); } #[test] diff --git a/crates/claudear-core/src/templates/mod.rs b/crates/claudear-core/src/templates/mod.rs index f77495df..944e5506 100644 --- a/crates/claudear-core/src/templates/mod.rs +++ b/crates/claudear-core/src/templates/mod.rs @@ -22,6 +22,13 @@ You are working in the repository: {{repo_name}} Before starting any work, verify this is the correct repository for this issue by checking that the file paths, modules, or stack traces reference code in this codebase. If this is NOT the correct repository, set "wrong_repo" in your response to the name of the repository you believe is correct (in "org/repo" format) and do NOT attempt any fixes. {{/if}} +{{#if mcp_instructions}} +## Live telemetry + +You have MCP tools connected to the services below. Use them to gather real evidence before settling on a root cause, and say in your summary what the telemetry showed - including when it contradicts what the stack trace implies. + +{{mcp_instructions}} +{{/if}} Your task: 1. Analyze the issue/error and any stack traces 2. Find the relevant code in this codebase @@ -62,6 +69,14 @@ Description: {{context}} +{{#if mcp_instructions}} +## Live telemetry + +You have MCP tools connected to the services below. Use them to gather real evidence before settling on a root cause, and say in your summary what the telemetry showed - including when it contradicts what the stack trace implies. + +{{mcp_instructions}} +{{/if}} + IMPORTANT: Use a test-driven development (TDD) approach for bug fixes. Before changing any application code, write a failing test that reproduces the issue. Then implement the minimal fix to make the test pass and verify all existing tests still pass. Create a PR that addresses this issue. Include "{{short_id}}" in the PR title. @@ -87,6 +102,14 @@ Event count: {{event_count}} {{context}} +{{#if mcp_instructions}} +## Live telemetry + +You have MCP tools connected to the services below. Use them to gather real evidence before settling on a root cause, and say in your summary what the telemetry showed - including when it contradicts what the stack trace implies. + +{{mcp_instructions}} +{{/if}} + Analyze the stack trace and error context to identify the root cause. IMPORTANT: Use a test-driven development (TDD) approach. Before changing any application code, write a failing test that reproduces the error. Then implement the minimal fix to make the test pass and verify all existing tests still pass. diff --git a/crates/claudear-engine/src/api/routes.rs b/crates/claudear-engine/src/api/routes.rs index d8ce3627..97767bae 100644 --- a/crates/claudear-engine/src/api/routes.rs +++ b/crates/claudear-engine/src/api/routes.rs @@ -2568,9 +2568,18 @@ struct ConfigUpdateRequest { } /// Redact values of keys that look like secrets in raw TOML content. +/// +/// `headers` is in the list because MCP servers carry auth in header bundles whose +/// key names say nothing about their contents - e.g. a Cloudflare Access client +/// secret inside `GRAFANA_EXTRA_HEADERS`, which none of the other patterns match. +/// +/// The value alternatives are ordered longest-form first, and the basic-string arm +/// understands backslash escapes. A naive `"[^"]*"` stops at the first escaped quote +/// inside the string, replacing only the opening fragment and leaving the rest of the +/// secret in the response - exactly the shape a JSON header bundle has. fn redact_secrets(content: &str) -> String { let re = regex_lite::Regex::new( - r#"(?im)^(\s*(?:[a-z_]*(?:token|secret|password|api_key|auth)[a-z_]*)\s*=\s*)("[^"]*"|'[^']*'|[^\n]*)"#, + r#"(?im)^(\s*(?:[a-z_]*(?:token|secret|password|api_key|auth|headers)[a-z_]*)\s*=\s*)("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'[^']*'|[^\n]*)"#, ) .expect("valid regex"); re.replace_all(content, r#"${1}"[REDACTED]""#).into_owned() @@ -5319,6 +5328,49 @@ mod tests { assert!(!truncated); } + #[test] + fn test_redact_secrets_header_bundle() { + // An MCP server's header bundle carries a secret under a key name that matches + // none of the other patterns, and wraps it in a JSON blob full of escaped + // quotes. Both parts have to work or GET /api/config leaks it to the dashboard. + let content = r#"GRAFANA_EXTRA_HEADERS = "{\"CF-Access-Client-Secret\": \"real-secret\"}" +"#; + let redacted = redact_secrets(content); + assert!(!redacted.contains("real-secret"), "redacted: {}", redacted); + assert!(redacted.contains("[REDACTED]")); + } + + #[test] + fn test_redact_secrets_multiline_string() { + // TOML multi-line strings must be consumed whole; matching the ordinary + // basic-string arm first would stop at the opening delimiter. + let content = + "auth_token = \"\"\"\nline-one-secret\nline-two-secret\n\"\"\"\nport = 3100\n"; + let redacted = redact_secrets(content); + assert!( + !redacted.contains("line-one-secret"), + "redacted: {}", + redacted + ); + assert!( + !redacted.contains("line-two-secret"), + "redacted: {}", + redacted + ); + // Non-secret keys after the multi-line value survive untouched. + assert!(redacted.contains("port = 3100")); + } + + #[test] + fn test_redact_secrets_leaves_instructions_intact() { + // `instructions` on an MCP server is prompt guidance, not a credential; it + // must survive redaction so the config editor round-trips it unchanged. + let content = "instructions = \"Call list_datasources once to find UIDs.\"\n"; + let redacted = redact_secrets(content); + assert!(redacted.contains("Call list_datasources once to find UIDs.")); + assert!(!redacted.contains("[REDACTED]")); + } + #[test] fn test_redact_secrets_basic() { let content = "api_token = \"sk-12345\"\nwebhook_secret = \"whsec_abc\"\nnormal_key = \"not_a_secret\"\n"; diff --git a/crates/claudear-integrations/src/runner/claude.rs b/crates/claudear-integrations/src/runner/claude.rs index cf719d30..94075eee 100644 --- a/crates/claudear-integrations/src/runner/claude.rs +++ b/crates/claudear-integrations/src/runner/claude.rs @@ -335,6 +335,38 @@ impl ClaudeAgentRunner { self.execute(prompt, None, project_dir).await } + /// Guidance for the MCP servers attaching to this run, joined into one block. + /// + /// Servers with no `instructions` contribute nothing: the tools are still + /// allowlisted, they just go unadvertised. Returns `None` when nothing matched, + /// which leaves `{{#if mcp_instructions}}` false for operators running no MCP. + fn mcp_instructions_for(&self, source: Option<&str>) -> Option { + let mut blocks: Vec<(&str, &str)> = self + .matched_mcp_servers(source) + .into_iter() + .filter_map(|(name, cfg)| { + cfg.instructions + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(|text| (name.as_str(), text)) + }) + .collect(); + if blocks.is_empty() { + return None; + } + // HashMap iteration order is arbitrary; sort so the prompt is stable across + // runs and prompt-hashing stays comparable between attempts. + blocks.sort_by_key(|(name, _)| *name); + Some( + blocks + .iter() + .map(|(name, text)| format!("### {}\n\n{}", name, text)) + .collect::>() + .join("\n\n"), + ) + } + fn build_prompt(&self, issue: &Issue, context: &str, project_dir: &Path) -> String { // Try to use template system let template_loader = TemplateLoader::new(project_dir); @@ -345,6 +377,10 @@ impl ClaudeAgentRunner { if let Some(repo_name) = issue.get_metadata::("target_repo_name") { template_context = template_context.with_variable("repo_name", repo_name); } + if let Some(mcp_instructions) = self.mcp_instructions_for(Some(&issue.source)) { + template_context = + template_context.with_variable("mcp_instructions", mcp_instructions); + } return self.template_renderer.render(&template, &template_context); } @@ -363,11 +399,15 @@ If this is NOT the correct repository, set "wrong_repo" in your response to the ) }) .unwrap_or_default(); + let mcp_section = self + .mcp_instructions_for(Some(&issue.source)) + .map(|text| format!("\n## Live telemetry\n\n{}\n", text)) + .unwrap_or_default(); format!( r#"You are fixing an issue from {}. Here is the issue context: {} -{} +{}{} Your task: 1. Analyze the issue/error and any stack traces 2. Find the relevant code in this codebase @@ -379,7 +419,7 @@ Your task: The PR title should include the issue ID: {} "#, - issue.source, context, repo_name_section, issue.short_id + issue.source, context, repo_name_section, mcp_section, issue.short_id ) } @@ -786,6 +826,32 @@ The PR title should include the issue ID: {} } } + /// MCP servers configured to attach for a run from `source`, skipping any whose + /// transport is ambiguous (strict MCP loading would reject those outright). + /// + /// Shared by prompt building and process spawning so the guidance the agent reads + /// can never describe a server it was not actually handed. Deliberately silent: + /// it runs more than once per run, and `unusable_mcp_servers` does the reporting + /// at the single point where the run label is in scope. + fn matched_mcp_servers(&self, source: Option<&str>) -> Vec<(&String, &McpServerConfig)> { + self.config + .mcp + .iter() + .filter(|(_, cfg)| cfg.matches_source(source)) + .filter(|(_, cfg)| cfg.has_valid_transport()) + .collect() + } + + /// Servers that `source` selected but which cannot be attached, for reporting. + fn unusable_mcp_servers(&self, source: Option<&str>) -> Vec<&String> { + self.config + .mcp + .iter() + .filter(|(_, cfg)| cfg.matches_source(source) && !cfg.has_valid_transport()) + .map(|(name, _)| name) + .collect() + } + /// Render matched MCP servers into a private temp file (claudear-mcp-*.json, /// 0600 on Unix) passed to the CLI via --mcp-config and deleted when the handle /// drops. `${VAR}` in env is expanded by the CLI. @@ -922,27 +988,17 @@ The PR title should include the issue ID: {} })); self.tracker.record_activity(&activity).ok(); - // Attach MCP servers whose sources match this run; held until return so the - // temp file outlives the child, then auto-deleted. Require exactly one of - // `command`/`url` so strict MCP loading never rejects an ambiguous server. - let matched_mcp: Vec<(&String, &McpServerConfig)> = self - .config - .mcp - .iter() - .filter(|(_, cfg)| cfg.matches_source(source)) - .filter(|(name, cfg)| { - let valid = cfg.has_valid_transport(); - if !valid { - tracing::warn!( - component = "claude", - label = label, - server = name.as_str(), - "Skipping MCP server: set exactly one of `command`/`url` with a matching `type`" - ); - } - valid - }) - .collect(); + // Attach MCP servers whose sources match this run; the temp file is held + // until return so it outlives the child, then auto-deleted. + for name in self.unusable_mcp_servers(source) { + tracing::warn!( + component = "claude", + label = label, + server = name.as_str(), + "Skipping MCP server: set exactly one of `command`/`url` with a matching `type`" + ); + } + let matched_mcp = self.matched_mcp_servers(source); let mut mcp_config_file: Option = None; if !matched_mcp.is_empty() { match Self::render_mcp_config(&matched_mcp) { @@ -3196,6 +3252,101 @@ mod tests { assert!(!prompt.is_empty()); } + /// Build a runner whose only MCP server is a Grafana-style one gated to Sentry. + fn runner_with_grafana_mcp(instructions: Option<&str>) -> ClaudeAgentRunner { + let mut mcp = HashMap::new(); + mcp.insert( + "grafana".to_string(), + McpServerConfig { + command: Some("mcp-grafana".to_string()), + args: vec!["--disable-write".to_string()], + sources: vec!["sentry".to_string()], + tools: vec!["query_loki_logs".to_string()], + instructions: instructions.map(str::to_string), + ..Default::default() + }, + ); + let config = ClaudeRunnerConfig { + mcp, + ..Default::default() + }; + ClaudeAgentRunner::new(config, Arc::new(claudear_storage::NoopTracker)) + } + + #[test] + fn test_mcp_instructions_gated_by_source() { + let runner = runner_with_grafana_mcp(Some("Query Loki for the failing service.")); + // The server is gated to Sentry, so only Sentry runs get told it exists. + let sentry = runner + .mcp_instructions_for(Some("sentry")) + .expect("sentry run gets guidance"); + assert!(sentry.contains("Query Loki for the failing service.")); + assert!(sentry.contains("### grafana")); + assert_eq!(runner.mcp_instructions_for(Some("linear")), None); + // Runs with no issue never attach MCP, so they never advertise it either. + assert_eq!(runner.mcp_instructions_for(None), None); + } + + #[test] + fn test_mcp_instructions_absent_when_not_configured() { + // A server with tools but no guidance stays unadvertised: the prompt's + // telemetry block must not appear as an empty heading. + let runner = runner_with_grafana_mcp(None); + assert_eq!(runner.mcp_instructions_for(Some("sentry")), None); + // Blank guidance is treated the same as none. + let blank = runner_with_grafana_mcp(Some(" \n ")); + assert_eq!(blank.mcp_instructions_for(Some("sentry")), None); + } + + #[test] + fn test_build_prompt_includes_telemetry_block_for_matching_source() { + let runner = runner_with_grafana_mcp(Some("Bound queries to First Seen / Last Seen.")); + let dir = std::path::Path::new("/tmp"); + + let sentry_issue = Issue::new("1", "PROJ-1", "Boom", "https://example.com", "sentry"); + let prompt = runner.build_prompt(&sentry_issue, "stack trace here", dir); + assert!(prompt.contains("## Live telemetry"), "prompt: {}", prompt); + assert!(prompt.contains("Bound queries to First Seen / Last Seen.")); + // The conditional must be consumed, not left as literal template syntax. + assert!(!prompt.contains("{{#if mcp_instructions}}")); + assert!(!prompt.contains("{{mcp_instructions}}")); + + // A source the server is not gated to gets no telemetry section at all. + let linear_issue = Issue::new("2", "PROJ-2", "Boom", "https://example.com", "linear"); + let other = runner.build_prompt(&linear_issue, "context", dir); + assert!(!other.contains("## Live telemetry")); + assert!(!other.contains("{{mcp_instructions}}")); + } + + #[test] + fn test_matched_mcp_servers_skips_invalid_transport() { + let mut mcp = HashMap::new(); + // Neither command nor url: strict MCP loading would reject this outright. + mcp.insert( + "broken".to_string(), + McpServerConfig { + sources: vec!["sentry".to_string()], + instructions: Some("never shown".to_string()), + ..Default::default() + }, + ); + let config = ClaudeRunnerConfig { + mcp, + ..Default::default() + }; + let runner = ClaudeAgentRunner::new(config, Arc::new(claudear_storage::NoopTracker)); + assert!(runner.matched_mcp_servers(Some("sentry")).is_empty()); + // And an unusable server is never advertised in the prompt. + assert_eq!(runner.mcp_instructions_for(Some("sentry")), None); + // It is still reported, so a misconfiguration is not silently dropped. + assert_eq!( + runner.unusable_mcp_servers(Some("sentry")), + vec![&"broken".to_string()] + ); + // A source the server was never gated to has nothing to report. + assert!(runner.unusable_mcp_servers(Some("linear")).is_empty()); + } + #[test] fn test_claude_runner_new_with_tracker() { let tracker = Arc::new(claudear_storage::NoopTracker); diff --git a/crates/claudear-integrations/src/source/sentry.rs b/crates/claudear-integrations/src/source/sentry.rs index 128558f8..684042d3 100644 --- a/crates/claudear-integrations/src/source/sentry.rs +++ b/crates/claudear-integrations/src/source/sentry.rs @@ -328,6 +328,21 @@ fn format_sentry_context(issue: &Issue) -> String { if let Some(user_count) = issue.get_metadata::("user_count") { context.push_str(&format!("**User Count:** {}\n", user_count)); } + // Sentry's firstSeen/lastSeen, mapped in `map_issue`. These bound the window an + // agent should look at when correlating against metrics or logs; without them it + // has to guess, and unbounded range queries are slow and drown out the signal. + if let Some(first_seen) = issue.created_at { + context.push_str(&format!( + "**First Seen:** {}\n", + first_seen.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + )); + } + if let Some(last_seen) = issue.updated_at { + context.push_str(&format!( + "**Last Seen:** {}\n", + last_seen.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + )); + } if let Some(project) = issue.get_metadata::("project") { context.push_str(&format!("**Project:** {}\n\n", project)); } @@ -3432,6 +3447,37 @@ mod tests { assert!(!context.contains("**Project:**")); assert!(!context.contains("**Culprit:**")); assert!(!context.contains("## Error Details")); + // No timestamps on the issue, so no misleading empty window. + assert!(!context.contains("**First Seen:**")); + assert!(!context.contains("**Last Seen:**")); + } + + #[test] + fn test_format_sentry_context_includes_seen_window() { + let mut issue = Issue::new( + "300", + "SENTRY-300", + "Windowed Error", + "https://sentry.io/issue/300", + "sentry", + ); + // As mapped from Sentry's firstSeen/lastSeen in `map_issue`. The agent needs + // these to bound metric and log queries when correlating against telemetry. + issue.created_at = Some( + chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + ); + issue.updated_at = Some( + chrono::DateTime::parse_from_rfc3339("2024-01-02T12:30:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + ); + + let context = format_sentry_context(&issue); + + assert!(context.contains("**First Seen:** 2024-01-01T00:00:00Z")); + assert!(context.contains("**Last Seen:** 2024-01-02T12:30:00Z")); } #[test] diff --git a/docker-compose.yml b/docker-compose.yml index 704acbb1..28e6e4a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,14 @@ services: # Claude Code authentication (if unset, container will prompt OAuth login on first start) ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + # Grafana telemetry for agent runs. Referenced as ${VAR} from the + # [agent.providers.claude.mcp.grafana] block in claudear.toml, so the values + # stay out of the config file. Unset unless that block is configured. + GRAFANA_URL: ${GRAFANA_URL:-} + GRAFANA_SERVICE_ACCOUNT_TOKEN: ${GRAFANA_SERVICE_ACCOUNT_TOKEN:-} + # JSON object of extra headers, e.g. Cloudflare Access service-token pairs. + GRAFANA_EXTRA_HEADERS: ${GRAFANA_EXTRA_HEADERS:-} + # Notifications CLAUDEAR_DISCORD_WEBHOOK_URL: ${CLAUDEAR_DISCORD_WEBHOOK_URL:-} CLAUDEAR_DISCORD_USER_ID: ${CLAUDEAR_DISCORD_USER_ID:-} diff --git a/website/docs/assets/docs-search-index.js b/website/docs/assets/docs-search-index.js index 1402abc4..b02050c3 100644 --- a/website/docs/assets/docs-search-index.js +++ b/website/docs/assets/docs-search-index.js @@ -50,10 +50,13 @@ window.__CLAUDEAR_DOCS_SEARCH_INDEX__ = [ { id: "notifications", text: "Notifications", level: 2 }, { id: "reply-capable", text: "Reply-capable (recommended)", level: 3 }, { id: "delivery-only", text: "Delivery-only", level: 3 }, + { id: "telemetry", text: "Telemetry", level: 2 }, + { id: "grafana", text: "Grafana", level: 3 }, + { id: "mcp-options", text: "MCP server options", level: 3 }, { id: "user-mapping", text: "User mapping", level: 2 }, { id: "webhooks", text: "Webhooks", level: 2 } ], - text: "Connect your tools. Add issue sources source control providers and notification channels. Linear picks up issues matching label state triggers api_key trigger_labels trigger_states. Sentry monitors errors above event count threshold auth_token org_slug project_slugs min_event_count. Jira Cloud and Server Data Center filter by project label custom JQL base_url email api_token project_keys trigger_labels. GitHub Issues repos trigger_labels. GitLab Issues groups trigger_labels. Discord Slack messages as issues. Source control GitHub Personal Access Token quickest setup token auto_resolve_on_merge. GitHub App for organisations app_id private_key_path installation_id. GitLab personal access token base_url. Notifications reply-capable recommended Discord bot_token channel_id Slack bot_token channel_id Email SMTP IMAP. Delivery-only SMS Push WhatsApp Telegram. User mapping cross-platform identities linear_name github_username discord_id email. Webhooks claudear webhook setup base-url react to events instantly." + text: "Connect your tools. Add issue sources source control providers and notification channels. Linear picks up issues matching label state triggers api_key trigger_labels trigger_states. Sentry monitors errors above event count threshold auth_token org_slug project_slugs min_event_count. Jira Cloud and Server Data Center filter by project label custom JQL base_url email api_token project_keys trigger_labels. GitHub Issues repos trigger_labels. GitLab Issues groups trigger_labels. Discord Slack messages as issues. Source control GitHub Personal Access Token quickest setup token auto_resolve_on_merge. GitHub App for organisations app_id private_key_path installation_id. GitLab personal access token base_url. Notifications reply-capable recommended Discord bot_token channel_id Slack bot_token channel_id Email SMTP IMAP. Delivery-only SMS Push WhatsApp Telegram. Telemetry MCP servers give the agent live context while triaging. Grafana mcp-grafana Prometheus metrics Loki logs datasources service account token GRAFANA_URL GRAFANA_SERVICE_ACCOUNT_TOKEN disable-write viewer role. MCP server options command args stdio url headers type http sse sources gating tools allowlist instructions prompt guidance env expansion. User mapping cross-platform identities linear_name github_username discord_id email. Webhooks claudear webhook setup base-url react to events instantly." }, { slug: "regression", diff --git a/website/docs/configuration.html b/website/docs/configuration.html index 20e1d363..544ffc66 100644 --- a/website/docs/configuration.html +++ b/website/docs/configuration.html @@ -155,10 +155,27 @@

skip_permissions: skip all interactive permission prompts (default: true).
  • binary: CLI binary name or absolute path (default: "claude"). Set the full path when the daemon cannot find the binary via PATH.
  • env: extra environment variables for the agent process. Useful when running as a systemd service where PATH is limited (e.g. env = { PATH = "/home/user/.local/bin:..." }).
  • +
  • mcp: MCP servers to attach to agent runs, keyed by server name — see MCP servers below.
  • +
    + MCP servers ([agent.providers.claude.mcp.<name>]) give the agent live context +
    +

    An issue payload says what broke, not what the service was doing at the time. MCP servers let the agent reach systems the payload does not describe — production telemetry, a database, an internal API — while it is triaging.

    +
      +
    • command, args: the server process to spawn (stdio transport).
    • +
    • url, headers, type: HTTP or SSE transport, as an alternative to command. Set exactly one of command or url.
    • +
    • env: environment for the server process. ${VAR} is expanded at run time, so keep secrets out of the config file and set them in the daemon environment instead.
    • +
    • sources: issue sources this server attaches for; empty means all. This gates on the issue's source, not the kind of run, so a server listed for sentry attaches to Sentry fix, verify, and reply runs alike. Classification runs never attach MCP servers.
    • +
    • tools: tool names to allow; empty grants every tool the server exposes.
    • +
    • instructions: guidance added to the agent's prompt whenever this server attaches. Without it the agent holds the tools but is never told they exist, so it will usually ignore them.
    • +
    +

    The rendered server config is written to a private temp file (mode 0600) that is deleted when the run ends, and the target repository's own .mcp.json is ignored. See Telemetry for a worked Grafana example.

    +
    +
    +
    User mapping ([users.<slug>]) cross-platform identity linking
    @@ -1483,6 +1500,7 @@

    On this page

  • Core settings
  • Retries ([retry])
  • AI agent ([agent])
  • +
  • MCP servers ([agent.providers.claude.mcp.<name>])
  • User mapping ([users.<slug>])
  • Questions ([ask])
  • Source control ([scm.*])
  • diff --git a/website/docs/integrations.html b/website/docs/integrations.html index 6a0a1514..c5f1c81e 100644 --- a/website/docs/integrations.html +++ b/website/docs/integrations.html @@ -207,6 +207,36 @@

    Messaging sources above).

    + +

    #Telemetry

    +

    An issue payload tells the agent what broke, not what the service was doing at the time. Attaching an MCP server lets it pull live telemetry while triaging, so it can tell a code bug from a load, config, or deploy problem.

    + +

    #Grafana

    +

    Grafana reaches Prometheus metrics and Loki logs through its own datasources, so one server and one credential cover all three:

    +
    [agent.providers.claude.mcp.grafana]
    +command = "mcp-grafana"
    +args    = ["--disable-write"]
    +sources = ["sentry"]
    +tools   = ["list_datasources", "query_prometheus", "query_loki_logs"]
    +instructions = """
    +Bound every query to the issue's First Seen / Last Seen window.
    +Call list_datasources once to find datasource UIDs.
    +"""
    +
    +[agent.providers.claude.mcp.grafana.env]
    +GRAFANA_URL                   = "https://grafana.example.com/"
    +GRAFANA_SERVICE_ACCOUNT_TOKEN = "${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
    +

    A Viewer-role service account is enough. The Docker image ships the mcp-grafana binary; for other installs take it from the mcp-grafana releases and put it on PATH.

    + +

    #MCP server options

    +

    command and args spawn a stdio server; url, headers, and type are the HTTP/SSE alternative. Beyond transport:

    +
      +
    • sources gates on the issue's source, not the kind of run — a server listed for sentry attaches to Sentry fix, verify, and reply runs alike. Classification runs never attach MCP servers. Leave it empty to attach everywhere.
    • +
    • tools allowlists tool names; empty grants every tool the server exposes.
    • +
    • instructions is added to the agent's prompt whenever the server attaches. Without it the agent holds the tools but is never told they exist, so it will usually ignore them. Per-repo detail belongs in that repo's AGENT.md, which is already prepended to every prompt.
    • +
    • env values are expanded at run time, so keep secrets out of claudear.toml and reference them as ${VAR}. The rendered config goes to a private temp file (mode 0600) deleted when the run ends, and the target repo's own .mcp.json is ignored.
    • +
    +

    #User mapping

    Link identities across platforms so notifications route to the right person:

    @@ -262,6 +292,9 @@

    On this page

  • Notifications
  • Reply-capable (recommended)
  • Delivery-only
  • +
  • Telemetry
  • +
  • Grafana
  • +
  • MCP server options
  • User mapping
  • Webhooks