heimdall-mcp
v0.3.1
Published
Portable policy gateway for MCP (Model Context Protocol) — enforce allow/deny rules, redact PII/PHI, and audit every agent tool call.
Maintainers
Readme
Heimdall
The watchman on the bridge between your agents and their tools. Nothing crosses without its say-so.
Heimdall (heimdall-mcp) is a portable, pluggable policy gateway for the Model Context Protocol. It sits between your agents and the MCP servers they call — a single chokepoint that enforces policy, redacts sensitive data, and produces a complete audit trail for every tool call.
It speaks MCP on both sides: an MCP server to your agent, an MCP client to the real upstream servers. Your agent points at the gateway instead of at the tools directly.
The pitch: your agents can use MCP tools, and you can prove to an auditor exactly what they touched — and that they never touched what they shouldn't.
Why it's portable
Nothing in this product ties you to a cloud. The core depends only on four small interfaces ("ports"); every backend is an adapter. The defaults are pure-JS, zero-native-dependency, so it runs anywhere Node does — a VM, a container, ECS, Cloud Run, Container Apps, or k8s.
┌──────────────────────────────┐
agent ───▶│ POST /mcp (enforcement) │───▶ upstream MCP servers
│ GET /audit (accountability)│
│ GET/POST /policy (control) │
└──────────────┬───────────────┘
│ core depends only on ports ▼
PolicyEngine Detector AuditSink IdentityResolver
(YAML default) (regex) (JSONL/mem) (OIDC JWT)
▲ ▲ ▲ ▲
swap: OPA/Rego Presidio/Azure Postgres/Cosmos mTLS/API-key
/Comprehend/DLP /S3/Splunk/...The four ports
Defined in src/ports/index.ts. Each ships with one default adapter; implement the interface to use your own stack.
| Port | Job | Default adapter | Swap for |
|------|-----|-----------------|----------|
| PolicyEngine | Evaluate a tool call against policy | YamlPolicyEngine (dev) or OpaPolicyEngine (prod) | Custom config service, your own backend |
| Detector | Find PHI/PII in text | RegexDetector (dev) or PresidioDetector (prod) | Azure AI Language, AWS Comprehend, GCP DLP |
| AuditSink | Persist & query the immutable trail | JsonlAuditSink / InMemoryAuditSink | Postgres, Cosmos, DynamoDB, S3, Splunk, Snowflake |
| IdentityResolver | Resolve the caller from the request | JwtIdentityResolver (Entra/Okta/Auth0) | mTLS, custom header, API-key map |
The three endpoints
| Endpoint | Value prop | What it does |
|----------|-----------|--------------|
| POST /mcp | Enforcement | Intercepts initialize / tools/list / tools/call. Filters denied tools out of tools/list (so the agent can't even pick them), enforces policy on tools/call, redacts the response, and logs the call. |
| GET /audit | Accountability | Query the trail. Filters: server, tool, decision, since, limit. |
| GET/POST /policy | Control | View the effective policy; POST /policy/reload hot-reloads it without redeploying. |
Quickstart
Three ways to run it. Pick the one that matches your stage.
1. npx — zero install, 30 seconds
UPSTREAMS='{"patient-db":"http://localhost:9000/mcp"}' \
HEIMDALL_ADMIN_TOKEN=$(openssl rand -hex 32) \
npx heimdall-mcpThe package bundles policy.example.yaml so the default policy works out of the box. Point your agent's MCP client at http://localhost:8080/mcp.
2. Clone and hack on it
git clone https://github.com/Luxen-Systems/heimdall-mcp.git && cd heimdall-mcp
npm install
npm test # 124 tests
npm run coverage # gated at >90%
npm run build # tsc -> dist/
UPSTREAMS='{"patient-db":"http://localhost:9000/mcp"}' \
HEIMDALL_ADMIN_TOKEN=$(openssl rand -hex 32) \
npm start3. Docker — containerized, production-shaped
docker build -t heimdall-mcp .
docker run --rm -p 8080:8080 \
-e UPSTREAMS='{"patient-db":"http://host.docker.internal:9000/mcp"}' \
-e HEIMDALL_ADMIN_TOKEN=$(openssl rand -hex 32) \
heimdall-mcpThe image runs as non-root (USER node), ships a HEALTHCHECK against /healthz, and uses the bundled policy.example.yaml by default. Mount your own policy file for real workloads:
docker run --rm -p 8080:8080 \
-v "$PWD/my-policy.yaml:/app/policy.yaml:ro" \
-e POLICY_PATH=/app/policy.yaml \
-e UPSTREAMS='...' \
-e HEIMDALL_ADMIN_TOKEN=$(openssl rand -hex 32) \
heimdall-mcpPoint your agent's MCP client at http://localhost:8080/mcp.
Environment
| Variable | Default | Purpose |
|----------|---------|---------|
| POLICY_PATH | policy.example.yaml | YAML policy file |
| AUDIT_PATH | audit.log.jsonl | JSONL audit-trail file |
| UPSTREAMS | {} | JSON map of server → upstream URL |
| PORT | 8080 | listen port |
| HEIMDALL_ADMIN_TOKEN | unset | bearer token for /audit and /policy*. If unset, those endpoints return 503 — open admin is the worst outcome. |
| HEIMDALL_ALLOW_UNSIGNED_JWT | 0 | set to 1 to decode JWTs without signature verification (local dev only). Ignored when any verifier (HEIMDALL_JWKS_URL or HEIMDALL_JWT_SECRET) is set. |
| HEIMDALL_JWKS_URL | unset | JWKS endpoint URL — switches identity verification to RS/ES via the published keys (Okta/Entra/Auth0/Cognito/Google). The production path. |
| HEIMDALL_JWT_SECRET | unset | UTF-8 symmetric secret for HS-style verification. Use either this or HEIMDALL_JWKS_URL. |
| HEIMDALL_JWT_ISSUER | unset | enforce iss claim matches. Strongly recommended whenever a verifier is set. |
| HEIMDALL_JWT_AUDIENCE | unset | enforce aud claim matches. Strongly recommended whenever a verifier is set. |
| HEIMDALL_JWT_ROLES_CLAIM | roles | JWT claim to read principal roles from (use scope for OAuth-style space-delimited scopes) |
| HEIMDALL_UPSTREAM_TIMEOUT_MS | 30000 | per-request timeout when calling an upstream MCP server |
| HEIMDALL_MAX_BODY_BYTES | 1000000 | maximum body size accepted on POST /mcp (returns 413 if exceeded) |
| HEIMDALL_OPA_URL | unset | when set, switches the policy engine from the YAML adapter to OPA (e.g. http://opa:8181). YAML file is ignored and POST /policy/reload returns 501. |
| HEIMDALL_OPA_DECISION_PATH | heimdall/authz/allow | decision path under OPA's data tree. Dot- or slash-separated. |
| HEIMDALL_OPA_TIMEOUT_MS | 5000 | per-decision timeout |
| HEIMDALL_OPA_TOKEN | unset | optional bearer token sent to OPA |
| HEIMDALL_PRESIDIO_URL | unset | when set, switches the detector from regex to Microsoft Presidio Analyzer (e.g. http://presidio:5002) |
| HEIMDALL_PRESIDIO_LANGUAGE | en | ISO language code passed to the analyzer |
| HEIMDALL_PRESIDIO_ENTITIES | unset | comma-separated entity types to detect (PERSON,US_SSN,EMAIL_ADDRESS,...). Default: all recognizers Presidio is configured with. |
| HEIMDALL_PRESIDIO_SCORE_THRESHOLD | 0.5 | minimum analyzer confidence for a finding to be redacted |
| HEIMDALL_PRESIDIO_TIMEOUT_MS | 5000 | per-request timeout for the analyzer |
| HEIMDALL_PRESIDIO_TOKEN | unset | optional bearer token sent to the analyzer |
| HEIMDALL_OTEL_ENABLED | 0 | set to 1 to boot the OpenTelemetry SDK and export traces + metrics to an OTLP collector. All standard OTEL_* env vars (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES, …) flow through natively. |
Admin requests carry Authorization: Bearer $HEIMDALL_ADMIN_TOKEN:
curl -H "Authorization: Bearer $HEIMDALL_ADMIN_TOKEN" http://localhost:8080/auditPolicy
Heimdall ships two policy backends. They're not redundant — they sit at different points on a maturity curve, and you switch between them with a single env var.
| | YAML (default) | OPA / Rego |
|---|---|---|
| Right for | Solo dev, single app, evaluating Heimdall, demos | Security team, multi-app deployment, audit-bound production |
| Where rules live | A YAML file you ship with the gateway | An OPA sidecar your security team owns |
| Language | First-match rule list | Rego — full policy-as-code, the OPA ecosystem |
| Updates | POST /policy/reload re-reads the file | OPA's existing bundle/discovery push reaches every consumer |
| Setup cost | Zero — works the moment you npm start | Run OPA somewhere; write & deploy rego |
| Switched on by | (no env var — this is the default) | HEIMDALL_OPA_URL=http://opa:8181 |
The architecture is deliberately a maturity ramp: start on YAML in five minutes, move to OPA when scale or audit pressure demands it — no code change, just an env var. Both adapters implement the same PolicyEngine port, so the gateway itself is unchanged.
YAML — the on-ramp
First matching rule wins; anything unmatched falls through to default (use deny to fail closed). See policy.example.yaml.
default: deny
rules:
- server: "*"
tool: "delete_*"
action: deny
- server: "patient-db"
tool: "get_*"
roles: ["clinician"] # role-gated
action: allowOPA / Rego — production policy-as-code
Set HEIMDALL_OPA_URL and Heimdall hands every decision to an OPA sidecar over the Data API. The YAML file is no longer read; POST /policy/reload returns 501 because policy now lives in OPA, not in a file the gateway controls. Input is the full evaluation context:
package heimdall.authz
default allow := { "allow": false, "rule": "default-deny" }
# Allow clinicians to read from patient-db.
allow := { "allow": true, "rule": "clinician-read" } if {
input.server == "patient-db"
startswith(input.tool, "get_")
"clinician" in input.principal.roles
}
# Block destructive tools everywhere.
allow := { "allow": false, "rule": "destructive-tool" } if {
startswith(input.tool, "delete_")
}The adapter accepts a bare boolean ({"result": true}) or a structured object ({"result": {"allow": ..., "rule": "..."}}). Anything else — missing result, wrong type, HTTP 5xx, timeout — throws, and Heimdall's fail-closed handler blocks the call with -32603 and audits it as policy-engine-error.
Don't start from scratch.
examples/rego/ships three working starter policies —hipaa/,pci/,internal-only/— each withopa testcoverage, a per-flavour README, and a Docker Compose wiring example. Copy the closest one and adjust the role/server names.
Detection
Heimdall also ships two PII/PHI detectors, on the same on-ramp-to-production curve as the policy backends.
| | RegexDetector (default) | PresidioDetector |
|---|---|---|
| Right for | Quickstart, demos, predictable rule-based redaction | Production redaction of PHI/PII across natural-language content |
| How it works | A small bundled set of regex patterns (SSN, MRN, email, phone, …) with custom-pattern override | Calls a Microsoft Presidio Analyzer sidecar over HTTP — ML-backed entity recognition |
| Detection quality | High precision on the shapes it knows; misses entities expressed in prose | Catches contextual entities ("the patient's name is Jane Doe") regex can't |
| Dependencies | None — zero native deps, runs everywhere Node does | Requires a running Presidio Analyzer (sidecar container) |
| Setup cost | Zero — the default | One docker container; HEIMDALL_PRESIDIO_URL pointed at it |
| Switched on by | (default) | HEIMDALL_PRESIDIO_URL=http://presidio:5002 |
# Minimal Presidio sidecar — same compose network as Heimdall.
docker run -d --name presidio -p 5002:3000 mcr.microsoft.com/presidio-analyzer:latest
# Point Heimdall at it, scoped to the entity types you actually care about.
HEIMDALL_PRESIDIO_URL=http://localhost:5002 \
HEIMDALL_PRESIDIO_ENTITIES=PERSON,US_SSN,EMAIL_ADDRESS,PHONE_NUMBER \
HEIMDALL_PRESIDIO_SCORE_THRESHOLD=0.6 \
npm startBoth detectors implement the same Detector port. Fail-closed: if Presidio throws, times out, or returns malformed JSON, the gateway blocks the call with -32603 and audits it as detector-error rather than shipping un-scanned content to the model.
Identity
Three verifier modes on a maturity ramp — pick the strongest one your environment supports.
| | None (default) | HS-symmetric | JWKS |
|---|---|---|---|
| Right for | Anonymous-only flows / local dev | Internal services with a shared secret | Production OIDC (Okta, Entra, Auth0, Cognito, Google) |
| Verifies | Nothing — every bearer token collapses to ANONYMOUS | HS256/384/512 signature against the secret | RS/ES signature against a key fetched from the JWKS endpoint (cached, rotated automatically) |
| Switched on by | (no env vars) | HEIMDALL_JWT_SECRET=... | HEIMDALL_JWKS_URL=https://... |
| Also set | HEIMDALL_ALLOW_UNSIGNED_JWT=1 to decode without verifying (local dev only) | HEIMDALL_JWT_ISSUER, HEIMDALL_JWT_AUDIENCE (strongly recommended) | HEIMDALL_JWT_ISSUER, HEIMDALL_JWT_AUDIENCE (strongly recommended) |
# Production: Okta-issued JWTs verified via JWKS, with iss/aud bound.
HEIMDALL_JWKS_URL=https://example.okta.com/oauth2/default/v1/keys \
HEIMDALL_JWT_ISSUER=https://example.okta.com/oauth2/default \
HEIMDALL_JWT_AUDIENCE=api://heimdall \
HEIMDALL_JWT_ROLES_CLAIM=scope \
HEIMDALL_ADMIN_TOKEN=$(openssl rand -hex 32) \
PORT=8080 npm startFail-closed: bad signature, expired, wrong issuer, wrong audience, JWKS unreachable, or malformed token — all collapse to ANONYMOUS. Policy then decides whether anonymous is allowed (it almost never is, which is the point).
Observability
Heimdall ships OpenTelemetry instrumentation out of the box. With HEIMDALL_OTEL_ENABLED=1 the gateway exports traces and metrics over OTLP/HTTP to any compatible collector (Jaeger, Tempo, Grafana Cloud, Honeycomb, Datadog, New Relic, Splunk Observability, …). Off by default — zero overhead when not in use.
HEIMDALL_OTEL_ENABLED=1 \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
OTEL_SERVICE_NAME=heimdall-prod \
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod,k8s.namespace.name=mcp \
HEIMDALL_ADMIN_TOKEN=$(openssl rand -hex 32) \
UPSTREAMS='...' \
npm startWhat you get
| Signal | Name | Detail |
|---|---|---|
| Trace | heimdall.mcp.handle | One root span per POST /mcp request. Attributes: mcp.method, mcp.server, mcp.tool, mcp.decision (allowed/blocked), mcp.rule (matching policy rule or fail-closed sentinel), mcp.redactions, principal.subject. ERROR status on every blocked decision. Exceptions recorded on fail-closed throws. |
| Trace | HTTP auto-instrumentation | Inbound /mcp requests and outbound upstream / OPA / JWKS / Presidio calls become child spans of the root automatically — full request waterfall, no manual work. |
| Metric (counter) | heimdall.policy.decisions | Bumped once per decision. Labels: decision, rule. "Denial rate per principal" lives here. |
| Metric (histogram) | heimdall.detector.redactions | PII/PHI spans redacted per allowed tools/call. Spikes flag tools whose upstream is leaking more than usual. |
Wiring it
The default OTLP/HTTP endpoints are localhost:4318 (traces) and localhost:4318 (metrics) — fine for a sidecar collector. For everything else, point OTEL_EXPORTER_OTLP_ENDPOINT at the collector you already run. SaaS vendors usually need OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer%20YOUR_TOKEN too.
Graceful shutdown flushes pending traces/metrics before exit, so SIGTERM under k8s/ECS doesn't drop the tail of the trail.
Writing an adapter (bring your own stack)
- Copy
src/adapters/_template/ExampleAuditSink.ts. - Implement the relevant port against the backend you already run.
- Validate it with the shared conformance harness:
import { auditSinkConformance } from "./test/conformance/auditSinkConformance.js";
auditSinkConformance("MyPostgresSink", async () => new MyPostgresSink(pool));- Wire it in at the composition root (
src/index.ts) — the only file that names concrete adapters. The core never changes.
You define the socket; the customer brings the plug.
The demo (proven by the test suite)
The three scenarios in test/gateway.test.ts are the live demo:
- Allowed call works and lands in the audit log.
- Denied tool (
delete_record) is blocked with a clean error — and never appears intools/list, so the agent couldn't have chosen it. - A response containing PHI (SSN, MRN) is redacted before it reaches the model, and only the redacted form is stored.
Then GET /audit shows the complete, queryable record. That screen is the sale.
License
MIT.
