Skip to content

feat: sentinel-mcp — MCP sidecar for governance telemetry#10

Merged
jpleva91 merged 2 commits intomainfrom
feat/sentinel-mcp
Apr 6, 2026
Merged

feat: sentinel-mcp — MCP sidecar for governance telemetry#10
jpleva91 merged 2 commits intomainfrom
feat/sentinel-mcp

Conversation

@jpleva91
Copy link
Copy Markdown
Contributor

@jpleva91 jpleva91 commented Apr 6, 2026

Summary

  • Adds sentinel-mcp binary — stdio MCP server that runs alongside agent sessions
  • 6 tools: sentinel_ingest, sentinel_recent, sentinel_denials, sentinel_hotspots, sentinel_analyze, sentinel_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

Architecture

Chitin kernel (2ms) → events.jsonl → sentinel-mcp → Neon Postgres
                                         ↕
                                    MCP tools (query, analyze)

Test plan

  • Build with Go 1.21+ (go build ./cmd/sentinel-mcp/)
  • Connect to Neon, verify sentinel_status returns DB health
  • Generate chitin events, run sentinel_ingest, verify rows in Neon
  • Query via sentinel_recent, sentinel_denials, sentinel_hotspots

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings April 6, 2026 08:26
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>
@jpleva91 jpleva91 merged commit c35a2ee into main Apr 6, 2026
3 of 4 checks passed
@jpleva91 jpleva91 deleted the feat/sentinel-mcp branch April 6, 2026 08:28
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_events ingestion (internal/mcp/ingest.go) and a new cmd/sentinel-mcp entrypoint.
  • Updates go.mod to 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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
}

// Truncate the file after successful ingestion
os.Truncate(path, 0)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
os.Truncate(path, 0)
if err := os.Truncate(path, 0); err != nil {
return "", fmt.Errorf("truncate events file %s: %w", path, err)
}

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +51
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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +96
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)))
}

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +147
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++
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.

ctx := context.Background()
count := 0
scanner := bufio.NewScanner(f)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
scanner := bufio.NewScanner(f)
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +51
var ev ChitinEvent
if err := json.Unmarshal([]byte(line), &ev); err != nil {
continue // skip malformed lines
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +74
_, 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,
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +84
_, 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,
)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +90
// 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)
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants