feat: sentinel-mcp — MCP sidecar for governance telemetry#10
Conversation
Adds a new sentinel-mcp binary that runs as a stdio MCP server alongside agent sessions. 6 tools: ingest, recent, denials, hotspots, analyze, status. Reads chitin events from .chitin/events.jsonl, maps to governance_events schema, bulk inserts to Neon. Replaces the old Cloud ingestion pipeline (3 hops, 290ms TS) with direct sidecar ingestion (1 hop, async). 692 LOC, zero new dependencies beyond existing pgx/v5. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cross-platform builds (linux/darwin × amd64/arm64) via GitHub Actions. Triggered on release publish or manual dispatch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a new sentinel-mcp stdio MCP sidecar server intended to ingest Chitin governance events from a local JSONL file into Neon Postgres and expose query/analysis tools over MCP.
Changes:
- Adds a minimal JSON-RPC 2.0 stdio server (
internal/mcp/server.go) and registers six MCP tools (internal/mcp/tools.go). - Implements JSONL →
governance_eventsingestion (internal/mcp/ingest.go) and a newcmd/sentinel-mcpentrypoint. - Updates
go.modto target Go 1.21.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/mcp/tools.go | Adds MCP tools for ingestion, querying recent events/denials/hotspots, basic analysis, and health status. |
| internal/mcp/server.go | Implements the MCP/JSON-RPC stdio loop and tool dispatch. |
| internal/mcp/ingest.go | Reads .chitin/events.jsonl and inserts events into governance_events. |
| cmd/sentinel-mcp/main.go | New sentinel-mcp binary wiring env config, DB pool, tool registration, and server run loop. |
| go.mod | Sets the module’s Go version to 1.21. |
|
|
||
| path := filepath.Join(workspace, ".chitin", "events.jsonl") | ||
| if _, err := os.Stat(path); err != nil { | ||
| return "No events file found — nothing to ingest.", nil |
There was a problem hiding this comment.
os.Stat errors are treated as “no events file found”, which will silently ignore permission/IO failures. Handle os.IsNotExist(err) separately and return other errors to the caller so ingestion failures are visible.
| return "No events file found — nothing to ingest.", nil | |
| if os.IsNotExist(err) { | |
| return "No events file found — nothing to ingest.", nil | |
| } | |
| return "", fmt.Errorf("stat events file %q: %w", path, err) |
| } | ||
|
|
||
| // Truncate the file after successful ingestion | ||
| os.Truncate(path, 0) |
There was a problem hiding this comment.
The events file is truncated after ingestion but the os.Truncate error is ignored. This can silently leave the file un-truncated and cause duplicate ingestion on the next run; return an error if truncation fails.
| os.Truncate(path, 0) | |
| if err := os.Truncate(path, 0); err != nil { | |
| return "", fmt.Errorf("truncate events file %s: %w", path, err) | |
| } |
| count, err := IngestFile(pool, path, tenantID) | ||
| if err != nil { | ||
| return "", fmt.Errorf("ingest failed: %w", err) | ||
| } | ||
|
|
||
| // Truncate the file after successful ingestion | ||
| os.Truncate(path, 0) | ||
|
|
||
| return fmt.Sprintf("Ingested %d events from %s", count, path), nil |
There was a problem hiding this comment.
Truncating events.jsonl after reading it can drop events if another process appends while ingestion is running (sidecar is designed to run concurrently with the writer). Consider a safer rotation/offset strategy (e.g., ingest a rotated snapshot or only truncate if size/mtime unchanged) to avoid data loss.
| rows.Scan(&ts, &agent, &action, &resource, &outcome, &risk) | ||
| lines = append(lines, fmt.Sprintf(" %s | %-12s | %-8s | %-6s | %s | %s", | ||
| ts.Format("15:04:05"), agent, action, outcome, risk, truncStr(resource, 60))) | ||
| } | ||
|
|
There was a problem hiding this comment.
rows.Scan(...) return value is ignored, and rows.Err() is never checked. Scan failures will be silently dropped and the tool will return partial/incorrect output; propagate scan errors and check rows.Err() before returning.
| rows.Scan(&ts, &agent, &action, &resource, &outcome, &risk) | |
| lines = append(lines, fmt.Sprintf(" %s | %-12s | %-8s | %-6s | %s | %s", | |
| ts.Format("15:04:05"), agent, action, outcome, risk, truncStr(resource, 60))) | |
| } | |
| if err := rows.Scan(&ts, &agent, &action, &resource, &outcome, &risk); err != nil { | |
| return "", fmt.Errorf("scan recent governance event: %w", err) | |
| } | |
| lines = append(lines, fmt.Sprintf(" %s | %-12s | %-8s | %-6s | %s | %s", | |
| ts.Format("15:04:05"), agent, action, outcome, risk, truncStr(resource, 60))) | |
| } | |
| if err := rows.Err(); err != nil { | |
| return "", fmt.Errorf("iterate recent governance events: %w", err) | |
| } |
| for rows.Next() { | ||
| var ts time.Time | ||
| var agent, action, resource string | ||
| var reason *string | ||
| rows.Scan(&ts, &agent, &action, &resource, &reason) | ||
| r := "" | ||
| if reason != nil { | ||
| r = *reason | ||
| } | ||
| lines = append(lines, fmt.Sprintf(" %s | %s | %s | %s | %s", | ||
| ts.Format("15:04:05"), agent, action, truncStr(resource, 40), truncStr(r, 80))) | ||
| count++ | ||
| } |
There was a problem hiding this comment.
rows.Scan(...) return value is ignored, and rows.Err() is never checked. This can silently omit denied events or return incomplete results; handle scan errors and rows.Err().
|
|
||
| ctx := context.Background() | ||
| count := 0 | ||
| scanner := bufio.NewScanner(f) |
There was a problem hiding this comment.
bufio.Scanner uses a ~64K token limit by default; a single large JSONL line will cause scanning to stop with ErrTooLong and ingestion will fail. Set a larger scanner buffer (or use bufio.Reader) so ingestion is robust to larger events.
| scanner := bufio.NewScanner(f) | |
| scanner := bufio.NewScanner(f) | |
| scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| var ev ChitinEvent | ||
| if err := json.Unmarshal([]byte(line), &ev); err != nil { | ||
| continue // skip malformed lines | ||
| } |
There was a problem hiding this comment.
Malformed JSONL lines are silently skipped. This makes telemetry gaps hard to detect and debug; consider tracking the number of skipped lines and returning it in the ingest result (or logging it) so operators know data was dropped.
| _, err := pool.Exec(ctx, ` | ||
| INSERT INTO governance_events | ||
| (tenant_id, session_id, agent_id, event_type, action, resource, outcome, risk_level, event_source, driver_type, metadata, timestamp) | ||
| VALUES | ||
| ($1, $2, $3, 'tool_call', $4, $5, $6, $7, 'agent', $8, $9::jsonb, $10::timestamptz) | ||
| `, | ||
| tenantID, |
There was a problem hiding this comment.
The PR description claims events are “bulk inserted”, but ingestion currently performs one pool.Exec per line. Either update the description or implement batching (e.g., CopyFrom/batch/transaction) to match the stated design.
| _, err := pool.Exec(ctx, ` | ||
| INSERT INTO governance_events | ||
| (tenant_id, session_id, agent_id, event_type, action, resource, outcome, risk_level, event_source, driver_type, metadata, timestamp) | ||
| VALUES | ||
| ($1, $2, $3, 'tool_call', $4, $5, $6, $7, 'agent', $8, $9::jsonb, $10::timestamptz) | ||
| `, | ||
| tenantID, | ||
| ev.SessionID, | ||
| ev.Agent, | ||
| ev.Tool, | ||
| coalesce(ev.Path, ev.Command), | ||
| ev.Outcome, | ||
| riskLevel, | ||
| ev.Agent, | ||
| string(metadata), | ||
| ev.Timestamp, | ||
| ) |
There was a problem hiding this comment.
Inserting events with one pool.Exec per line will be a throughput bottleneck as event volume grows. Consider batching inserts in a transaction (or using pgx.CopyFrom) to reduce round-trips and improve ingest latency.
| // Run starts the stdio JSON-RPC loop. | ||
| func (s *Server) Run() error { | ||
| reader := bufio.NewReader(os.Stdin) | ||
| writer := os.Stdout | ||
|
|
||
| for { | ||
| line, err := reader.ReadString('\n') | ||
| if err == io.EOF { | ||
| return nil | ||
| } | ||
| if err != nil { | ||
| return fmt.Errorf("read error: %w", err) | ||
| } | ||
|
|
||
| line = strings.TrimSpace(line) | ||
| if line == "" { | ||
| continue | ||
| } | ||
|
|
||
| var req Request | ||
| if err := json.Unmarshal([]byte(line), &req); err != nil { | ||
| s.writeError(writer, nil, -32700, "Parse error") | ||
| continue | ||
| } | ||
|
|
||
| resp := s.handle(&req) | ||
| s.writeResponse(writer, resp) | ||
| } |
There was a problem hiding this comment.
There are extensive Go unit tests elsewhere in the repo, but no tests are added for the new MCP server/tool surface. Add tests for JSON-RPC handling (initialize/tools/list/tools/call) and for ingestion behavior (e.g., temp JSONL file parsing, error paths) to prevent regressions.
Summary
sentinel-mcpbinary — stdio MCP server that runs alongside agent sessionssentinel_ingest,sentinel_recent,sentinel_denials,sentinel_hotspots,sentinel_analyze,sentinel_status.chitin/events.jsonl, maps togovernance_eventsschema, bulk inserts to NeonArchitecture
Test plan
go build ./cmd/sentinel-mcp/)sentinel_statusreturns DB healthsentinel_ingest, verify rows in Neonsentinel_recent,sentinel_denials,sentinel_hotspots🤖 Generated with Claude Code