Skip to content
This repository was archived by the owner on Apr 2, 2026. It is now read-only.
Merged
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ make lint # runs go vet ./...
- `cmd/beacon-proxy/main.go` — CLI entry point
- `internal/proxy/` — core proxy (child process, stdin/stdout piping, JSON-RPC parsing)
- `internal/audit/` — SQLite audit store (sessions, messages)
- `internal/policy/` — policy engine (not yet implemented)
- `internal/web/` — dashboard web UI (not yet implemented)
- `internal/policy/` — policy engine (YAML rules, pause/approve flow)
- `internal/web/` — dashboard web UI (embedded HTML, SSE live stream, API handlers)

## Conventions

Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,34 @@ beacon-proxy --server-name filesystem -- npx -y @modelcontextprotocol/server-fil
}
```

## Dashboard

Beacon includes a real-time web dashboard at `http://localhost:8080` (configurable via `--port`).

![Beacon Dashboard](docs/dashboard.png)

- **Live tool call stream** — see every MCP action as it happens via SSE
- **Session overview** — browse sessions, see tool call counts and max risk per server
- **Risk & policy views** — filter by operation type, policy action, or risk level
- **Intent chains** — visualize temporal groupings of related tool calls
- **Approve/deny** — handle paused tool calls directly from the browser
- **Hash chain verification** — one-click integrity check of the entire audit trail

No build step — the dashboard is a single HTML file embedded in the binary.

### Generate Demo Traffic

To populate the dashboard with realistic sample data across multiple servers:

```bash
go run ./cmd/beacon-traffic/
```

This creates sessions for GitHub, filesystem, and PostgreSQL MCP servers with a mix of read/write/delete/execute operations, risk scores, and policy actions (flag, pause, block).

## Inspecting the Audit Trail

All messages are logged to SQLite. Query directly:
The dashboard provides the primary UI. You can also query SQLite directly:

```bash
# List all sessions
Expand Down Expand Up @@ -173,8 +198,9 @@ sqlite3 ~/.beacon/audit.db "SELECT direction, method, jsonrpc_id FROM messages O
- [x] HTTP approval endpoints for paused tool calls
- [x] Temporal intent grouping (5s gap threshold)

- [x] Real-time web dashboard with live SSE stream

### Next
- [ ] Real-time web dashboard

### Vision
- [ ] Cross-system intent chains — one human request, multiple MCP servers, one narrative
Expand Down
44 changes: 9 additions & 35 deletions cmd/beacon-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
Expand All @@ -17,13 +16,14 @@ import (
"github.com/ottojongerius/beacon/internal/audit"
"github.com/ottojongerius/beacon/internal/policy"
"github.com/ottojongerius/beacon/internal/proxy"
"github.com/ottojongerius/beacon/internal/web"
)

func main() {
serverName := flag.String("server-name", "unknown", "label for this MCP server")
dbPath := flag.String("db", "~/.beacon/audit.db", "path to SQLite audit database")
rulesPath := flag.String("rules", "", "path to YAML policy rules file (uses defaults if not set)")
port := flag.Int("port", 8080, "HTTP port for approval endpoints")
port := flag.Int("port", 8080, "HTTP port for dashboard and approval endpoints")
retentionDays := flag.Int("retention-days", 0, "auto-delete sessions older than N days on startup (0 = keep forever)")

// Find "--" separator — everything after it is the server command
Expand Down Expand Up @@ -82,8 +82,9 @@ func main() {
}
engine := policy.NewEngine(rules)

// Start HTTP server for approvals
go startHTTPServer(*port, engine)
// Start dashboard web server
dashboard := web.NewServer(store, engine)
go startHTTPServer(*port, dashboard)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand All @@ -103,45 +104,18 @@ func main() {
Store: store,
Policy: engine,
Intent: audit.NewIntentTracker(store, 5*time.Second),
Dashboard: dashboard,
}

if err := p.Run(ctx); err != nil {
log.Fatalf("beacon: %v", err)
}
}

func startHTTPServer(port int, engine *policy.Engine) {
mux := http.NewServeMux()

mux.HandleFunc("POST /api/tool-calls/{id}/approve", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := engine.Approve(id, "http-user"); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"approved"}`)
})

mux.HandleFunc("POST /api/tool-calls/{id}/deny", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := engine.Deny(id); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"denied"}`)
})

mux.HandleFunc("GET /api/tool-calls/pending", func(w http.ResponseWriter, r *http.Request) {
ids := engine.PendingApprovals()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"pending": ids})
})

func startHTTPServer(port int, dashboard *web.Server) {
addr := fmt.Sprintf("127.0.0.1:%d", port)
log.Printf("beacon: approval server listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Printf("beacon: dashboard listening on http://%s", addr)
if err := http.ListenAndServe(addr, dashboard.Handler()); err != nil {
log.Printf("beacon: HTTP server error: %v", err)
}
}
Expand Down
200 changes: 200 additions & 0 deletions cmd/beacon-traffic/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// beacon-traffic generates realistic MCP audit traffic for dashboard demos.
package main

import (
"encoding/json"
"fmt"
"log"
"math/rand"
"os"
"path/filepath"
"strings"
"time"

"github.com/ottojongerius/beacon/internal/audit"
)

type scenario struct {
server string
command string
toolCalls []fakeCall
}

type fakeCall struct {
tool string
args map[string]any
result map[string]any
delayMs int // delay before this call
policySet string
}

func main() {
home, _ := os.UserHomeDir()
dbPath := filepath.Join(home, ".beacon", "audit.db")

store, err := audit.Open(dbPath)
if err != nil {
log.Fatalf("failed to open db: %v", err)
}
defer store.Close()

scenarios := []scenario{
{
server: "github",
command: "npx -y @modelcontextprotocol/server-github",
toolCalls: []fakeCall{
{tool: "list_repos", args: m("owner", "ojongerius"), result: m("count", 12), delayMs: 200},
{tool: "search_issues", args: m("repo", "beacon", "query", "audit"), result: m("count", 3), delayMs: 400},
{tool: "read_file", args: m("repo", "beacon", "path", "README.md"), result: m("size", 4096), delayMs: 150},
{tool: "create_issue", args: m("repo", "beacon", "title", "Add SIEM export", "body", "Stream events to Datadog"), result: m("number", 9), delayMs: 800, policySet: "flag"},
{tool: "update_issue", args: m("repo", "beacon", "number", 5, "labels", []string{"enhancement"}), result: m("updated", true), delayMs: 300, policySet: "flag"},
},
},
{
server: "filesystem",
command: "npx -y @modelcontextprotocol/server-filesystem /Users/otto/projects",
toolCalls: []fakeCall{
{tool: "list_directory", args: m("path", "/Users/otto/projects"), result: m("entries", 8), delayMs: 100},
{tool: "read_file", args: m("path", "/Users/otto/projects/beacon/go.mod"), result: m("size", 512), delayMs: 120},
{tool: "read_file", args: m("path", "/Users/otto/projects/beacon/internal/web/server.go"), result: m("size", 6144), delayMs: 180},
{tool: "write_file", args: m("path", "/Users/otto/projects/beacon/TODO.md", "content", "# TODO\n- SIEM export\n- Multi-server"), result: m("written", true), delayMs: 500, policySet: "flag"},
{tool: "delete_file", args: m("path", "/Users/otto/projects/beacon/tmp/old-cache.json"), result: m("deleted", true), delayMs: 400, policySet: "pause"},
},
},
{
server: "postgres",
command: "npx -y @modelcontextprotocol/server-postgres postgres://localhost/app",
toolCalls: []fakeCall{
{tool: "list_tables", args: m("schema", "public"), result: m("tables", []string{"users", "orders", "products"}), delayMs: 150},
{tool: "describe_table", args: m("table", "users"), result: m("columns", 8), delayMs: 200},
{tool: "read_query", args: m("sql", "SELECT COUNT(*) FROM users"), result: m("count", 1847), delayMs: 250},
{tool: "read_query", args: m("sql", "SELECT email, created_at FROM users ORDER BY created_at DESC LIMIT 5"), result: m("rows", 5), delayMs: 300},
{tool: "exec_query", args: m("sql", "UPDATE users SET status = 'active' WHERE last_login > NOW() - INTERVAL '30 days'"), result: m("affected", 423), delayMs: 1200, policySet: "pause"},
{tool: "exec_query", args: m("sql", "DELETE FROM sessions WHERE expired_at < NOW()"), result: m("affected", 89), delayMs: 600, policySet: "block"},
},
},
}

fmt.Println("🔦 Beacon traffic generator")
fmt.Println(" Generating realistic MCP audit traffic...")
fmt.Println()

for _, sc := range scenarios {
fmt.Printf(" ▸ %s (%s)\n", sc.server, sc.command)
sessionID, err := store.CreateSession(sc.server, sc.command)
if err != nil {
log.Printf(" ✗ failed to create session: %v", err)
continue
}

intentID := ""
var lastCallTime time.Time

for i, tc := range sc.toolCalls {
time.Sleep(time.Duration(tc.delayMs) * time.Millisecond)

now := time.Now().UTC()
argsJSON, _ := json.Marshal(tc.args)

// Log request message
reqRaw := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":"%s","arguments":%s}}`,
i+1, tc.tool, argsJSON)
msgID, err := store.LogMessage(sessionID, "client_to_server", fmt.Sprintf("%d", i+1), "tools/call", reqRaw)
if err != nil {
log.Printf(" ✗ log request: %v", err)
continue
}

// Classify and score
opType := audit.ClassifyOperation(tc.tool)
score, reasons := audit.ScoreRisk(tc.tool, opType, string(argsJSON))

tcID := fmt.Sprintf("tc-%s-%d", sc.server, i+1)
err = store.CreateToolCall(audit.ToolCallRecord{
ID: tcID,
SessionID: sessionID,
RequestMsgID: msgID,
ToolName: tc.tool,
Arguments: string(argsJSON),
OperationType: opType,
RiskScore: score,
RiskReasons: reasons,
RequestedAt: now,
})
if err != nil {
log.Printf(" ✗ create tool call: %v", err)
continue
}

// Set policy if specified
if tc.policySet != "" {
store.UpdateToolCallPolicy(tcID, tc.policySet, nil)
}

// Simulate response after a brief delay
respDelay := time.Duration(50+rand.Intn(200)) * time.Millisecond
time.Sleep(respDelay)
respTime := time.Now().UTC()

resultJSON, _ := json.Marshal(tc.result)
respRaw := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":%s}`, i+1, resultJSON)
respMsgID, err := store.LogMessage(sessionID, "server_to_client", fmt.Sprintf("%d", i+1), "", respRaw)
if err != nil {
log.Printf(" ✗ log response: %v", err)
continue
}

durationMs := respTime.Sub(now).Milliseconds()
result := string(resultJSON)
store.CompleteToolCall(tcID, respMsgID, &result, nil, respTime, durationMs)

// Intent grouping: new intent if gap > 2s
if intentID == "" || now.Sub(lastCallTime) > 2*time.Second {
intentID2, err := store.CreateIntentContext(sessionID)
if err == nil {
intentID = intentID2
}
}
if intentID != "" {
store.AddToolCallToIntent(intentID, tcID, i+1)
}
lastCallTime = now

policyTag := ""
if tc.policySet != "" {
policyTag = fmt.Sprintf(" [%s]", strings.ToUpper(tc.policySet))
}
riskBar := riskIndicator(score)
fmt.Printf(" %s %-20s %s %-7s risk:%2d %s%s\n",
"✓", tc.tool, riskBar, opType, score, reasons, policyTag)
}

// End some sessions (leave last one "live")
if sc.server != "postgres" {
store.EndSession(sessionID)
}
}

fmt.Println("\n ✅ Done! Refresh the dashboard at http://localhost:8080")
}

func m(kvs ...any) map[string]any {
out := make(map[string]any)
for i := 0; i < len(kvs)-1; i += 2 {
out[kvs[i].(string)] = kvs[i+1]
}
return out
}

func riskIndicator(score int) string {
switch {
case score <= 30:
return "🟢"
case score <= 60:
return "🟡"
case score <= 80:
return "🟠"
default:
return "🔴"
}
}
Binary file added docs/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading