An MCP-native event store. Capture arbitrary events from any system, extract structured signals, enrich with a local LLM, and serve the result to AI agents over Model Context Protocol — 4 read-only tools, zero third-party dependencies, deterministic millisecond reads because the summarising already happened at write time.
- What is EventMesh?
- Why it exists
- Highlights
- Architecture
- Project layout
- Quick start
- Configuration
- HTTP API
- MCP server
- Database schema
- Event lifecycle
- Package walkthrough
- Development
- Testing
- Make targets
- Codebase graph
- Roadmap
- Contributing
- License
EventMesh is a Go service that:
- Receives events on a webhook (
POST /webhook/events) from any producer. - Persists them in Postgres alongside their raw JSON and a structured
facetsview (actors, locations, references, timestamps, quantities, status words). - Deduplicates identical events using a SHA-256 fingerprint (
UNIQUEconstraint). - Enriches every event asynchronously with a local LLM (Ollama) — adding a one-line summary, 3-7 tags, and a severity label.
- Exposes the resulting event store to AI agents (Claude Desktop, Claude Code, Cursor, custom agents) via an MCP server with 4 read-only tools —
describe_data,query_events,get_entity_state,summarize_window— returning agent-readable data instead of raw JSON.
Two binaries:
| Binary | Purpose | Status |
|---|---|---|
cmd/eventmesh-api |
Webhook server + AI worker | ✅ Working |
cmd/eventmesh-mcp |
MCP server (stdio transport) for Claude Desktop / Claude Code | ✅ Working |
Most event stores hand back raw JSON. Agents waste tokens parsing it and frequently misread domain-specific fields. EventMesh inverts the contract: structured facets are extracted at write time, and an LLM summary is attached out-of-band — so any downstream agent can ask "what happened to shipment X this week?" and get a sub-200ms answer assembled from pre-computed summaries, with no per-query inference cost.
- Schema-agnostic. Send any JSON; the facet extractor infers actors/locations/references/timestamps without configuration.
- Idempotent. Same event sent twice → silent no-op (
deduped: true). - Domain-portable. Same model handles logistics events, IoT sensors, user-activity events, etc. — verified end-to-end.
- Local-first. Postgres in Docker, Ollama on host. Zero cloud cost. Zero secrets.
- 12-factor compliant. Every knob lives in an env var with a sane default.
- Graceful. SIGINT drains HTTP for 10s, cancels worker context, closes the pool.
- Layered. Strict downward dependency direction makes every package unit-testable.
PRODUCER
│
│ POST /webhook/events (JSON)
▼
┌─────────────────┐
│ api/handlers │ thin HTTP layer
└────────┬────────┘
│ IncomingEvent
▼
┌─────────────────┐
│ ingest/Service │ validate → extract facets → fingerprint → persist
└────────┬────────┘
▼
┌─────────────────┐
│ Postgres │ events row written (ai_processed_at = NULL)
└────────┬────────┘
│ ┄┄┄ ~1–15s later ┄┄┄
▼
┌─────────────────────────┐
│ worker/Enricher │ ticks every 1s
│ ├ FetchUnprocessed │
│ ├ ai.EnrichEvent ─────┼──► Ollama (qwen2.5:7b)
│ └ MarkAIEnriched │
└─────────────────────────┘
│
▼
events row updated:
ai_summary + ai_tags + ai_severity + ai_processed_at
api ─────────────► ingest ────────► structure
│ │
└─────► db ◄───────┘
│
worker ─┼─► ai ─► (Ollama HTTP)
│
domain ◄─── everyone
config ◄─── main only
domain sits at the bottom and depends only on stdlib. Everyone depends on domain. This is what makes every package independently testable.
This repo follows the golang-standards/project-layout convention.
eventmesh/
├── api/ # Public API contracts
│ └── openapi.yaml # OpenAPI 3.1 spec for the HTTP surface
├── cmd/ # Main binaries (composition roots)
│ ├── eventmesh-api/ # Webhook server + AI worker
│ │ └── main.go
│ └── eventmesh-mcp/ # MCP server (stdio, 4 read-only tools)
├── configs/ # Sample env files, runtime configs
│ └── .env.example
├── deployments/ # Plug-and-play deployment surface (any cloud)
│ ├── docker-compose.yml # Local Postgres (+ optional `--profile app`)
│ ├── kubernetes/ # Vanilla manifests (kubectl / kustomize)
│ ├── helm/eventmesh/ # Helm chart
│ ├── cloudrun/ # GCP Cloud Run Knative spec
│ ├── ecs/ # AWS ECS / Fargate task + service defs
│ ├── terraform/ # Reference Terraform for AWS
│ └── README.md
├── docs/ # Long-form documentation
│ ├── ARCHITECTURE.md
│ ├── DESIGN_DECISIONS.md
│ ├── TESTING.md
│ ├── HLD.txt
│ └── Project.md
├── internal/ # Private application code (not importable externally)
│ ├── api/ # HTTP handlers (thin layer)
│ ├── ai/ # Ollama client + enrichment prompts
│ ├── config/ # Env-driven configuration
│ ├── db/ # Postgres pool + typed queries
│ ├── domain/ # Canonical types (stdlib-only)
│ ├── ingest/ # Orchestration layer
│ ├── structure/ # Facet extraction + fingerprint (pure)
│ └── worker/ # Background AI enrichment loop
├── migrations/ # SQL schema, applied at first Postgres boot
│ └── 001_init.sql
├── scripts/ # Dev / ops helper scripts
│ ├── dev.sh
│ └── seed.sh
├── test/ # Black-box tests, integration tests, fixtures
│ ├── integration/ # DB-bound tests (build tag: integration)
│ ├── unit/{ai,domain,structure}/ # External package_test unit tests
│ └── testdata/ # JSON fixtures used in tests + by `make seed`
├── .github/ # CI workflows, Dependabot, issue/PR templates, CODEQL
├── Dockerfile # Multi-stage build → distroless nonroot image
├── .dockerignore
├── Makefile # `make help` lists every target
├── .golangci.yml # Linter config
├── .editorconfig # Editor defaults
├── go.mod / go.sum
├── CHANGELOG.md
├── CODEOWNERS
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── SECURITY.md
├── LICENSE
└── README.md ← you are here
cmd/<binary>/main.gois always thin: parse flags/env, build dependencies, run.internal/...cannot be imported by anything outside this module — enforced by the Go toolchain.domainimports only stdlib. All other packages may importdomain.- Tests live next to source (
*_test.go) for unit coverage.test/holds black-box and integration tests.
| Tool | Min version | Install |
|---|---|---|
| Go | 1.26 | brew install go |
| Docker | 24+ | brew install --cask docker |
| Ollama | latest | brew install ollama && brew services start ollama |
| qwen2.5:7b | — | ollama pull qwen2.5:7b (~4.7 GB) |
# 1. Start Postgres (migration runs automatically the first time)
make docker-up
# or directly:
docker compose -f deployments/docker-compose.yml up -d
# 2. Run the API + AI worker
make run
# or directly:
go run ./cmd/eventmesh-apiYou should see:
level=INFO msg="config loaded" api_port=8080 ollama_model=qwen2.5:7b ai_worker=true
level=INFO msg="db connected"
level=INFO msg=listening addr=:8080
level=INFO msg="ollama reachable; enrichment loop starting" component=enricher tick=1s
curl -X POST http://localhost:8080/webhook/events \
-H 'Content-Type: application/json' \
-d @test/testdata/maersk_event.jsonResponse:
{"event_id":"<uuid>","deduped":false}Send the same event again → {"deduped": true}.
Within ~5 seconds the worker logs:
level=INFO msg="enriching batch" component=enricher count=1
level=INFO msg="event enriched" event_type=vessel_departure severity=low \
summary="MAERSK LABREA departs Bremerhaven with container MMAU1286399."
docker exec eventmesh-pg psql -U eventmesh -d eventmesh -c \
"SELECT event_type, ai_severity, ai_tags, ai_summary FROM events ORDER BY ingested_at DESC LIMIT 5;"Every knob is environment-driven. Defaults match local development out-of-the-box.
| Env var | Default | Description |
|---|---|---|
EVENTMESH_DB_URL |
postgres://eventmesh:eventmesh_dev@localhost:5432/eventmesh?sslmode=disable |
pgx-compatible Postgres URL |
EVENTMESH_API_PORT |
8080 |
Port the webhook server binds to |
EVENTMESH_OLLAMA_URL |
http://localhost:11434 |
Base URL of the Ollama HTTP server |
EVENTMESH_OLLAMA_MODEL |
qwen2.5:7b |
Model name registered with Ollama |
EVENTMESH_AI_WORKER |
true |
Toggle the background AI enrichment loop |
Copy configs/.env.example to .env and edit if you need to override anything.
Liveness probe. Always 200 if the process is up.
{"status":"ok","checked_at":"2026-05-24T16:00:00Z"}Ingest a single event. Synchronous DB write; AI enrichment happens async.
Request body
{
"entity_id": "MMAU1286399",
"event_type": "vessel_departure",
"occurred_at": "2026-05-08T14:00:00Z",
"payload": { "...": "any json object" }
}Constraints
entity_id,event_type,occurred_at,payloadare all required.- Body is capped at 1 MB (
http.MaxBytesReader). - Unknown top-level fields are rejected (
DisallowUnknownFields) — keeps client typos loud.
Responses
| Status | Body | Meaning |
|---|---|---|
202 Accepted |
{"event_id": "<uuid>", "deduped": false} |
Stored successfully |
202 Accepted |
{"event_id": "<uuid>", "deduped": true} |
Same fingerprint already exists — no-op |
400 Bad Request |
{"error": "..."} |
Validation failure (missing field, bad JSON, body too big) |
500 Internal Server Error |
{"error": "ingest failed"} |
Unexpected error; check server logs |
eventmesh-mcp speaks Model Context Protocol over a stdio
transport. All four tools are read-only — an agent triaging an incident has no path to mutate
the store, which is a property worth having structurally rather than by convention.
The protocol layer is hand-rolled JSON-RPC 2.0 (internal/mcp) rather than an SDK, which is what
keeps this module at exactly one dependency and the release image ~20MB.
| Tool | Purpose | Required args |
|---|---|---|
describe_data |
List every event type with description, count and time range. Call this first when you don't know the domain. | — |
query_events |
Filter events by entity, type, time window, tags or severity. Returns summaries and facets; raw payload is opt-in. | — |
get_entity_state |
Current merged state of one entity, plus event count and first/last seen. | entity_id |
summarize_window |
Precomputed AI summaries across a time range, oldest-first, with a severity tally. | since |
summarize_window performs no LLM call — summaries were computed once by the enrichment worker
at write time. That is what makes it a millisecond read rather than a multi-second inference, and
why an agent can afford to call it in a loop.
All timestamps are RFC3339 UTC, normalised on read so identical data serialises identically regardless of where the server runs.
make run-mcp # or: go run ./cmd/eventmesh-mcpThe server logs to stderr and speaks protocol on stdout. Never print to stdout from this binary — a single stray byte corrupts the JSON-RPC stream and the client drops the session.
Add to claude_desktop_config.json:
{
"mcpServers": {
"eventmesh": {
"command": "/absolute/path/to/bin/eventmesh-mcp",
"env": {
"EVENTMESH_DB_URL": "postgres://eventmesh:eventmesh_dev@localhost:5432/eventmesh?sslmode=disable"
}
}
}
}claude mcp add eventmesh /absolute/path/to/bin/eventmesh-mcp \
-e EVENTMESH_DB_URL="postgres://eventmesh:eventmesh_dev@localhost:5432/eventmesh?sslmode=disable"Any MCP client is just newline-delimited JSON-RPC, so a pipe works for debugging:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"describe_data","arguments":{}}}' \
| ./bin/eventmesh-mcpThree tables. Seven indexes. Optimized for read-heavy MCP queries with a small write-time index cost.
Raw event log. One row per webhook call (modulo dedup).
| Column | Type | Filled by | Purpose |
|---|---|---|---|
event_id |
UUID PK |
DB gen_random_uuid() |
Identity |
fingerprint |
TEXT UNIQUE |
structure.Fingerprint |
Dedup key (SHA-256) |
entity_id |
TEXT |
request | What the event is about |
event_type |
TEXT |
request | Category |
occurred_at |
TIMESTAMPTZ |
request | When the real-world thing happened |
ingested_at |
TIMESTAMPTZ |
DB NOW() |
When we received it |
payload |
JSONB |
request | Original event, untouched |
facets |
JSONB |
structure.Extract |
Structured signals |
searchable_text |
TEXT |
ingest.searchableText |
Flattened text (future FTS) |
ai_summary |
TEXT NULL |
worker | One-sentence plain English |
ai_tags |
TEXT[] NULL |
worker | 3–7 short lowercase tags |
ai_severity |
TEXT NULL |
worker | low | medium | high | critical |
ai_processed_at |
TIMESTAMPTZ NULL |
worker | NULL = worker hasn't seen this row |
Indexes
idx_events_entity_time (entity_id, occurred_at DESC)
idx_events_type_time (event_type, occurred_at DESC)
idx_events_facets_gin USING GIN (facets jsonb_path_ops)
idx_events_tags_gin USING GIN (ai_tags)
idx_events_unprocessed (ingested_at) WHERE ai_processed_at IS NULL -- partialOne row per entity_id. UPSERTed on every event. Powers "what is the current state of X?" in a single 5ms read.
Auto-built catalog. One row per distinct event_type ever seen. ai_description, field_types, and sample_events columns are reserved for the future schema profiler — a feature where an agent calls describe_data via MCP and learns the domain without reading raw events.
See migrations/001_init.sql for full DDL and column-level commentary.
| Step | What happens | Latency |
|---|---|---|
| 1 | HTTP request hits /webhook/events |
~5 ms |
| 2 | api/handlers.go decodes JSON, validates body size |
~1 ms |
| 3 | ingest.Service.Ingest validates required fields |
<1 ms |
| 4 | structure.Extract produces facets |
~1 ms |
| 5 | structure.Fingerprint returns SHA-256 hex |
~50 µs |
| 6 | db.InsertEvent (ON CONFLICT DO NOTHING) |
~5 ms |
| 7 | db.UpsertEntity |
~3 ms |
| 8 | db.TouchEventType |
~2 ms |
| 9 | HTTP returns 202 |
total: ~20 ms |
| ⋯ | AI columns are still NULL | |
| 10 | Worker polls (SELECT … WHERE ai_processed_at IS NULL FOR UPDATE SKIP LOCKED LIMIT 10) |
0–1 s |
| 11 | ai.EnrichEvent → Ollama with format: "json" |
3–15 s on CPU |
| 12 | db.MarkAIEnriched updates the row |
~3 ms |
| ✓ | Row is fully enriched; any agent query sees AI metadata |
Canonical Go types — IncomingEvent, Event, Facets, Enrichment, UnprocessedEvent. Stdlib-only. Imported by every other package.
Load() reads env vars, applies defaults, returns a populated Config. 12-factor compliant.
Pure functions, zero I/O.
Extract(payload any) Facets— walks decoded JSON and produces predictable facet keys regardless of input shape. Rules: timestamps → references → locations → actors → status words → quantities.Fingerprint(entity, type, time, payload) string— deterministic SHA-256 hex with0x1fseparators to prevent collision ambiguity.
Postgres connection pool and typed queries.
Open(ctx, url) *Pool— configures: 10 max conns, 2 min, 1-hour lifetime, 30-min idle, 5-second startupPing.InsertEvent(withErrDuplicate),UpsertEntity,TouchEventType,FetchUnprocessed,MarkAIEnriched,MarkAIFailed.
Ollama HTTP client + enrichment prompt. No business logic.
Client.GenerateJSON(ctx, prompt, dst)— usesformat: "json"for strict structured output.Client.EnrichEvent(ctx, e) (*Enrichment, error)— coerces severity into the closed set, dedupes/lowercases tags.
One goroutine. Polls Postgres every tick, runs each unprocessed event through ai.EnrichEvent with a 45-second per-event timeout, marks success or failure. Errors are logged, never propagated — one bad event must not stop the loop.
Validates → unmarshals → extracts facets → fingerprints → writes 3 tables. Returns ErrValidation so HTTP handlers can map to 400. Treats ErrDuplicate from db as success-with-deduped=true.
HTTP layer. Zero business logic. http.MaxBytesReader, DisallowUnknownFields, structured JSON errors.
The composition root — the only file that imports all packages. Sets up signal-aware context, opens DB pool, starts HTTP server and AI worker as goroutines, blocks on signal, runs graceful shutdown.
- Composition root only in
cmd/. Library packages never construct themselves at the top —main.gowires everything. - Internal beats public. Default location is
internal/. Promote to a top-level package only when external consumers exist. domainimports only stdlib. Anything common goes there.- One type, one truth. Don't define near-identical structs in two packages. Add to
domaininstead. - Pure functions where possible.
structureis the model: zero I/O, fully unit-testable.
make help # list every target
make run # start Postgres + API + worker
make test # all unit tests
make test-int # integration tests (needs DB up)
make lint # golangci-lint
make fmt # gofmt + goimports
make tidy # go mod tidy
make build # produce binaries in bin/
make clean # rm bin/, stop containersThe repo has three tiers of tests:
Live alongside source as *_test.go files. Cover pure logic (facet extraction, fingerprinting, prompt building, config loading, HTTP handler validation). No external dependencies.
go test ./... # everything
go test ./internal/structure/ # one package
go test -race -cover ./... # with race detector + coverageUse the package_test external package pattern to verify only the public surface area. Useful for catching unintentional API breaks.
go test ./test/unit/...Hit a real Postgres. Gated behind the integration build tag.
docker compose up -d
go test -tags=integration ./test/integration/...| Package | Coverage goal |
|---|---|
internal/structure |
90%+ |
internal/ingest |
85%+ |
internal/ai |
80%+ (prompt building only; HTTP behind a fake) |
internal/api |
80%+ |
internal/config |
100% |
make help # show this list
make run # docker compose up -d && go run ./cmd/eventmesh-api
make build # build binaries into ./bin
make test # unit tests
make test-int # integration tests (needs docker compose up)
make lint # golangci-lint
make fmt # gofmt -w + goimports
make tidy # go mod tidy
make seed # POST sample events to local server
make db-shell # psql into the running container
make db-reset # destroy and recreate Postgres data
make clean # remove bin/ and stop containers
The deployments/ folder is plug-and-play across the major cloud targets. Each subdirectory holds a self-contained manifest set; pick the one that matches your platform.
| Target | Path | Quick command |
|---|---|---|
| Local Docker Compose | deployments/docker-compose.yml |
make docker-up |
| Any Kubernetes (EKS/GKE/AKS/DOKS) | deployments/kubernetes/ |
kubectl apply -k deployments/kubernetes/ |
| Kubernetes (templated) | deployments/helm/eventmesh/ |
helm install eventmesh deployments/helm/eventmesh |
| GCP Cloud Run | deployments/cloudrun/service.yaml |
gcloud run services replace deployments/cloudrun/service.yaml |
| AWS ECS / Fargate | deployments/ecs/ |
aws ecs register-task-definition --cli-input-json file://deployments/ecs/task-definition.json |
| AWS reference IaC | deployments/terraform/ |
cd deployments/terraform && terraform apply |
| AWS App Runner / Azure Container Apps | Use Dockerfile directly, point to your registry |
— |
The Dockerfile at the project root produces a static, ~20 MB distroless nonroot image (gcr.io/distroless/static-debian12:nonroot) — no shell, no package manager, no root. The CI pipeline at .github/workflows/ci.yml publishes it to GHCR on every main push.
See deployments/README.md for full per-target instructions.
This repo is indexed with graphify — a knowledge graph of files, packages, symbols, and their cross-references. The full interactive graph viewer is hosted at:
🌐 https://eventmesh-vercel.vercel.app
Current snapshot: 534 nodes, 585 edges, 53 communities.
Use it to:
- Navigate the package hierarchy and see god-nodes (high-fan-in symbols) at a glance.
- Trace cross-file relationships before opening a single source file.
- Onboard new contributors with a visual map of the codebase.
The graph artifacts live under graphify-out/:
| File | Purpose |
|---|---|
graph.html |
Interactive force-directed graph (deployed at the URL above) |
GRAPH_REPORT.md |
Human-readable architecture report |
graph.json |
Raw graph (nodes + edges) for tooling |
manifest.json |
Build metadata for the current snapshot |
Regenerate locally after code changes (no LLM/API needed):
graphify update .| Milestone | Status |
|---|---|
| Webhook ingest with facet extraction + idempotency | ✅ |
| Background AI enrichment via Ollama | ✅ |
MCP server with 4 tools (describe_data, query_events, get_entity_state, summarize_window) |
✅ |
| Unit + integration test suite | 🚧 |
| HMAC signature on webhook | ⬜ |
Multi-tenancy (tenant_id + row-level security) |
⬜ |
Schema profiler (auto-fills event_types.ai_description) |
⬜ |
pgvector + embedding column (for find_similar MCP tool) |
⬜ |
| LISTEN/NOTIFY instead of polling | ⬜ |
| Batched LLM calls | ⬜ |
| Pluggable LLM backend (OpenAI / Anthropic / vLLM) | ⬜ |
See docs/DESIGN_DECISIONS.md for the reasoning behind these choices.
Pull requests welcome. Please read CONTRIBUTING.md first.
- Run
make fmt lint testbefore opening a PR. - One concern per commit; descriptive subject lines.
- New behavior needs a unit test. New SQL needs a migration file.
For security issues, see SECURITY.md.
MIT — do whatever you want, just keep the copyright notice.