npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-mcp

The 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 start

3. 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-mcp

The 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-mcp

Point 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/audit

Policy

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: allow

OPA / 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 with opa test coverage, 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 start

Both 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 start

Fail-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 start

What 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)

  1. Copy src/adapters/_template/ExampleAuditSink.ts.
  2. Implement the relevant port against the backend you already run.
  3. Validate it with the shared conformance harness:
import { auditSinkConformance } from "./test/conformance/auditSinkConformance.js";
auditSinkConformance("MyPostgresSink", async () => new MyPostgresSink(pool));
  1. 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:

  1. Allowed call works and lands in the audit log.
  2. Denied tool (delete_record) is blocked with a clean error — and never appears in tools/list, so the agent couldn't have chosen it.
  3. 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.