Skip to content

Commit c7ca10d

Browse files
feat(controller): wire incident.io MCP credentials for incident triage (#50)
Add a per-flow `incident_triage.incident_io_api_key_secret` config field that mounts `INCIDENT_IO_API_KEY` onto incident-triage agent Jobs via SecretKeyRef. When the env var is present at job startup, setup-claude.sh registers the incident.io remote MCP server (mcp.incident.io/mcp, HTTP transport, Bearer-token auth) in the workspace mcp.json. When empty no MCP server is registered — ticketing pods are unaffected. The Bearer header is templated into mcp.json via `jq --arg` rather than Claude Code's runtime `${VAR}` substitution, which has known bugs for HTTP MCP headers (anthropics/claude-code#51581, #6204). Mirrors the per-flow config precedent from #46. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7f8f719 commit c7ca10d

5 files changed

Lines changed: 117 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Per-flow incident.io MCP credentials**: new
13+
`incident_triage.incident_io_api_key_secret` config field selects a
14+
Kubernetes Secret whose `INCIDENT_IO_API_KEY` key is injected into the
15+
incident-triage agent Job via SecretKeyRef. When set, `setup-claude.sh`
16+
registers the incident.io remote MCP server (`https://mcp.incident.io/mcp`,
17+
HTTP transport, Bearer-token auth) in the workspace `mcp.json` at job
18+
startup. When empty, no incident.io MCP server is registered — the
19+
ticketing flow is unaffected. The Bearer header is templated into
20+
`mcp.json` via `jq --arg` rather than Claude Code's runtime `${VAR}`
21+
substitution, which has known bugs for HTTP MCP headers
22+
(anthropics/claude-code#51581, #6204).
23+
1024
### Changed
1125

1226
- **Richer incident.io context surfaced to the agent**:

docker/engine-claude-code/setup-claude.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ cp /etc/claude-code/settings.json "${CLAUDE_DIR}/settings.json"
4646
# available via the explicit --mcp-config flag in the claude invocation.
4747
cp /etc/claude-code/mcp.json /workspace/.mcp.json
4848

49+
# Conditionally register the incident.io remote MCP server when an API key
50+
# is present on the pod (set via SecretKeyRef from IncidentTriageConfig).
51+
# We assemble the workspace mcp.json ourselves rather than relying on Claude
52+
# Code's ${VAR} substitution in HTTP headers, which has known bugs
53+
# (anthropics/claude-code#51581, #6204).
54+
if [ -n "${INCIDENT_IO_API_KEY:-}" ]; then
55+
jq --arg key "$INCIDENT_IO_API_KEY" \
56+
'.mcpServers."incident-io" = {
57+
type: "http",
58+
url: "https://mcp.incident.io/mcp",
59+
headers: {"Authorization": ("Bearer " + $key)}
60+
}' /workspace/.mcp.json > /workspace/.mcp.json.tmp \
61+
&& mv /workspace/.mcp.json.tmp /workspace/.mcp.json
62+
fi
63+
4964
# Export CLAUDE_CONFIG_DIR so claude itself picks it up when the non-default
5065
# path is in use. A no-op when CLAUDE_CONFIG_DIR was already set in the env.
5166
export CLAUDE_CONFIG_DIR="${CLAUDE_DIR}"

internal/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,14 @@ type IncidentTriageConfig struct {
214214
// from the first configured Slack channel in Notifications.Channels
215215
// is used.
216216
SlackTokenSecret string `yaml:"slack_token_secret,omitempty"`
217+
218+
// IncidentIOAPIKeySecret is the Kubernetes Secret backing
219+
// INCIDENT_IO_API_KEY on the incident-triage agent Job. When set,
220+
// setup-claude.sh registers the incident.io remote MCP server in
221+
// the agent's workspace mcp.json, authenticated as a Bearer token.
222+
// The Secret must contain a key named INCIDENT_IO_API_KEY. When
223+
// empty, no incident.io MCP server is registered.
224+
IncidentIOAPIKeySecret string `yaml:"incident_io_api_key_secret,omitempty"`
217225
}
218226

219227
// SecretResolverConfig configures the task-scoped secret resolver.

internal/controller/incident.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,20 @@ func (r *Reconciler) ProcessIncidentEvent(ctx context.Context, evt webhook.Incid
169169
Key: r.resolveSlackTokenKey(ctx, tokenSecret),
170170
}
171171
}
172+
// Per-flow incident.io MCP credentials. The agent's MCP client reads
173+
// INCIDENT_IO_API_KEY from the pod env to authenticate to
174+
// mcp.incident.io. Only the incident-triage flow needs this — ticketing
175+
// pods skip it. setup-claude.sh registers the incident.io MCP server in
176+
// the workspace mcp.json when this env var is present at job startup.
177+
if apiKeySecret := r.config.IncidentTriage.IncidentIOAPIKeySecret; apiKeySecret != "" {
178+
if engineCfg.SecretKeyRefs == nil {
179+
engineCfg.SecretKeyRefs = make(map[string]engine.SecretKeyRef)
180+
}
181+
engineCfg.SecretKeyRefs["INCIDENT_IO_API_KEY"] = engine.SecretKeyRef{
182+
SecretName: apiKeySecret,
183+
Key: "INCIDENT_IO_API_KEY",
184+
}
185+
}
172186

173187
if err := r.prepareSession(ctx, tr.ID); err != nil {
174188
return fmt.Errorf("preparing session storage: %w", err)

internal/controller/incident_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,3 +586,69 @@ func TestProcessIncidentEvent_OmitsEmptyIncidentLabels(t *testing.T) {
586586
"empty IncidentStatus.Category must not produce a dangling osmia:incident-status: label")
587587
}
588588
}
589+
590+
// TestProcessIncidentEvent_SetsIncidentIOAPIKeySecretKeyRef verifies that
591+
// the incident-triage agent Job's INCIDENT_IO_API_KEY SecretKeyRef points
592+
// at the Secret named by IncidentTriage.IncidentIOAPIKeySecret. The Key
593+
// is the literal "INCIDENT_IO_API_KEY" — unlike the Slack token there is
594+
// no well-known-key probing because the operator-managed Secret is
595+
// expected to follow the documented naming convention.
596+
func TestProcessIncidentEvent_SetsIncidentIOAPIKeySecretKeyRef(t *testing.T) {
597+
cfg := incidentTestConfigWithTicketingSlack()
598+
cfg.IncidentTriage.IncidentIOAPIKeySecret = "incident-io-api"
599+
600+
logger := testLogger()
601+
k8s := fake.NewSimpleClientset(
602+
&corev1.Secret{
603+
ObjectMeta: metav1.ObjectMeta{
604+
Name: "incident-io-api",
605+
Namespace: "test-ns",
606+
},
607+
Data: map[string][]byte{
608+
"INCIDENT_IO_API_KEY": []byte("inc_test_key"),
609+
},
610+
},
611+
)
612+
eng := &recordingEngine{name: "claude-code"}
613+
jb := &mockJobBuilder{}
614+
r := NewReconciler(cfg, logger,
615+
WithEngine(eng),
616+
WithJobBuilder(jb),
617+
WithK8sClient(k8s),
618+
WithNamespace("test-ns"),
619+
)
620+
621+
require.NoError(t, r.ProcessIncidentEvent(context.Background(), incidentTestEvent("01HZINCIOAPI")))
622+
623+
ref, ok := eng.lastConfig.SecretKeyRefs["INCIDENT_IO_API_KEY"]
624+
require.True(t, ok, "INCIDENT_IO_API_KEY SecretKeyRef must be set on the agent Job")
625+
assert.Equal(t, "incident-io-api", ref.SecretName,
626+
"SecretName must point at IncidentTriage.IncidentIOAPIKeySecret")
627+
assert.Equal(t, "INCIDENT_IO_API_KEY", ref.Key,
628+
"Key is the literal env var name — no well-known-key probing for this field")
629+
}
630+
631+
// TestProcessIncidentEvent_OmitsIncidentIOAPIKeyWhenSecretNotConfigured
632+
// verifies that when IncidentTriage.IncidentIOAPIKeySecret is empty the
633+
// agent Job receives no INCIDENT_IO_API_KEY SecretKeyRef. This is the
634+
// path setup-claude.sh relies on to skip MCP server registration for
635+
// flows that don't need incident.io access.
636+
func TestProcessIncidentEvent_OmitsIncidentIOAPIKeyWhenSecretNotConfigured(t *testing.T) {
637+
cfg := incidentTestConfigWithTicketingSlack()
638+
// IncidentIOAPIKeySecret deliberately left empty.
639+
640+
logger := testLogger()
641+
eng := &recordingEngine{name: "claude-code"}
642+
jb := &mockJobBuilder{}
643+
r := NewReconciler(cfg, logger,
644+
WithEngine(eng),
645+
WithJobBuilder(jb),
646+
WithNamespace("test-ns"),
647+
)
648+
649+
require.NoError(t, r.ProcessIncidentEvent(context.Background(), incidentTestEvent("01HZINCIOOFF")))
650+
651+
_, ok := eng.lastConfig.SecretKeyRefs["INCIDENT_IO_API_KEY"]
652+
assert.False(t, ok,
653+
"INCIDENT_IO_API_KEY SecretKeyRef must be absent when IncidentIOAPIKeySecret is empty")
654+
}

0 commit comments

Comments
 (0)