https://github.com/enmanuelmag/heimdall-mcp
Transparent MCP proxy with OpenTelemetry tracing. Wrap any MCP server and persist traces to SQLite, Postgres or MySQL.
https://github.com/enmanuelmag/heimdall-mcp
agent audit audit-trail javascript mcp model-context-protocol observability opentelemetry postgresql proxy sqlite tracing typescript
Last synced: 6 days ago
JSON representation
Transparent MCP proxy with OpenTelemetry tracing. Wrap any MCP server and persist traces to SQLite, Postgres or MySQL.
- Host: GitHub
- URL: https://github.com/enmanuelmag/heimdall-mcp
- Owner: enmanuelmag
- License: other
- Created: 2026-05-09T04:13:32.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-05-27T02:02:58.000Z (26 days ago)
- Last Synced: 2026-06-11T08:05:06.658Z (10 days ago)
- Topics: agent, audit, audit-trail, javascript, mcp, model-context-protocol, observability, opentelemetry, postgresql, proxy, sqlite, tracing, typescript
- Language: TypeScript
- Homepage: https://stack.cardor.dev/heimdall
- Size: 489 KB
- Stars: 8
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
- awesome-opentelemetry - heimdall-mcp - Transparent proxy for any MCP server that intercepts JSON-RPC messages, measures latency, and exports OpenTelemetry-native spans to any OTLP-compatible backend (Jaeger, Tempo, Grafana) without modifying the original server. (OpenTelemetry Instrumentation / GenAI / LLM Instrumentation)
README
# @cardor/heimdall-mcp
Transparent proxy for any MCP server. Intercepts all JSON-RPC messages, measures latency, stores traces in a configurable database, and enforces per-server allow/deny policies — without touching the original server.
Visit the [website](https://stack.cardor.dev/heimdall) to view a full explanation, examples, and other tools!



[](https://snyk.io/test/npm/@cardor/heimdall-mcp)
[](https://glama.ai/mcp/servers/enmanuelmag/heimdall-mcp)
## Table of Contents
- [@cardor/heimdall-mcp](#cardorheimdall-mcp)
- [Table of Contents](#table-of-contents)
- [How it works](#how-it-works)
- [Installation](#installation)
- [Policy config](#policy-config)
- [Config files](#config-files)
- [Config format](#config-format)
- [Merge strategy](#merge-strategy)
- [Argument policies](#argument-policies)
- [What happens when a call is blocked](#what-happens-when-a-call-is-blocked)
- [Server name](#server-name)
- [Policy config in library mode](#policy-config-in-library-mode)
- [Usage modes](#usage-modes)
- [Mode 1 — CLI wrapping a subprocess (stdio)](#mode-1--cli-wrapping-a-subprocess-stdio)
- [Mode 2 — CLI wrapping a remote HTTP server](#mode-2--cli-wrapping-a-remote-http-server)
- [Mode 3 — CLI wrapping a remote SSE server](#mode-3--cli-wrapping-a-remote-sse-server)
- [Mode 4 — Library for developers](#mode-4--library-for-developers)
- [Stores](#stores)
- [SQLite](#sqlite)
- [PostgreSQL](#postgresql)
- [MySQL](#mysql)
- [What gets recorded](#what-gets-recorded)
- [Jaeger UI (OTLP)](#jaeger-ui-otlp)
- [1. Start Jaeger](#1-start-jaeger)
- [2. Add `--otlp` to your config](#2-add---otlp-to-your-config)
- [3. Open Jaeger UI](#3-open-jaeger-ui)
- [Custom interceptors](#custom-interceptors)
- [CLI reference](#cli-reference)
- [start](#start)
- [init](#init)
- [health](#health)
- [Tracing reference](#tracing-reference)
- [Roadmap](#roadmap)
---
## How it works
```mermaid
flowchart LR
A["MCP Client\n(Claude Desktop / OpenCode / Cursor)"]
subgraph proxy["heimdall-mcp"]
B["TelemetryInterceptor"]
P["PolicyInterceptor"]
C["ForwardInterceptor"]
D[("SQLite\nPostgres\nMySQL")]
B --> P
P --> C
B -->|"saves span"| D
end
S["Real MCP server\n(subprocess / HTTP / SSE)"]
A -->|"stdio"| B
C -->|"stdio · http · sse"| S
S -->|"response"| C
C -->|"response"| A
```
The proxy always exposes **stdio** to the MCP client and speaks the correct transport to the real server. Every request/response pair is converted into a span with timing, attributes, and the input/output body.
---
## Installation
```bash
npm install -g @cardor/heimdall-mcp
# or as a project dependency
npm install @cardor/heimdall-mcp
```
---
## Policy config
Drop a `heimdall.config.ts` in your project root and define exactly which tools, prompts, and resources each MCP server is allowed to expose to the agent — at the proxy layer, without touching the server code.
### Config files
| Scope | Path | Purpose |
|--------|------|---------|
| Local | `{project-root}/heimdall.config.{ts,js,mjs,cjs,json}` | Per-repo rules |
| Global | `~/.config/heimdall/heimdall.config.{ts,js,mjs,cjs,json}` | User/org-wide rules |
Both are optional. If neither exists, the proxy stays fully transparent (backward compatible).
### Config format
```typescript
// heimdall.config.ts
import type { HeimdallConfig } from '@cardor/heimdall-mcp';
export default {
// default: applies to any server without an explicit entry
default: {
tools: { allow: ['*'], deny: [] },
},
// servers: keyed by --server-name (or serverInfo.name from initialize response)
servers: {
filesystem: {
tools: {
allow: ['read_file', 'list_directory', 'search_files'],
deny: ['write_file', 'create_file', 'delete_file', 'move_file'],
},
resources: {
allow: ['*'],
deny: ['file:///etc/*', 'file:///root/*'],
},
},
database: {
tools: {
allow: ['query', 'describe_table', 'list_tables'],
deny: ['execute', 'drop_table', 'truncate'],
},
},
},
} satisfies HeimdallConfig;
```
TypeScript configs are loaded via [`jiti`](https://github.com/unjs/jiti) without pre-compilation. Also works as `.js`, `.mjs`, `.cjs`, or `.json`.
### Merge strategy
When both local and global configs exist, they merge with **security-first semantics**:
| Rule | Behavior |
|---|---|
| **Deny → union** | Denied by either = denied. Global deny cannot be overridden locally. |
| **Allow → intersection** | Must pass both. `*` or `[]` means "defer to the other side." |
| **Deny beats allow** | Within any single config, deny always wins. |
The global config enforces a floor the team can't accidentally loosen. Local configs can only add *more* restrictions, never fewer.
### Argument policies
`toolPolicies` adds a second enforcement layer on top of name-level `tools` rules. Instead of just deciding _which_ tools are callable, you can constrain _what arguments_ are allowed on each call.
```typescript
// heimdall.config.ts
export default {
servers: {
filesystem: {
tools: { allow: ['read_file', 'list_directory'] },
toolPolicies: {
// '*' applies to every tool (merged first; tool-specific entries override)
'*': {
args: {
path: { isPath: true, deny_pattern: ['\\.env$', '\\.pem$'] },
},
},
read_file: {
args: {
// scope path to the current working directory
path: { isPath: true, allow_pattern: './' },
// allow only safe encodings
encoding: { allow_pattern: ['utf-8', 'utf8', 'ascii'] },
},
},
},
},
},
} satisfies HeimdallConfig;
```
#### `ArgConstraint` fields
| Field | Type | Default | Description |
|---|---|---|---|
| `isPath` | `boolean` | `false` | Enables path-aware matching (containment check) instead of regex |
| `allow_pattern` | `string \| string[]` | — | Arg must match at least one pattern to pass |
| `deny_pattern` | `string \| string[]` | — | Arg is blocked if it matches any pattern; deny wins over allow |
| `array_mode` | `'all' \| 'any'` | `'all'` | For array-typed args: require all items to pass (`all`) or at least one (`any`) |
| `case_sensitive` | `boolean` | `true` | Regex flag; not applied to path-root matching |
| `warn_only` | `boolean` | `false` | Record the violation in the OTel span without blocking the call |
#### Path scoping with `isPath: true`
When `isPath: true`, patterns that look like directory roots are treated as containment checks rather than regex expressions:
| Pattern | Meaning |
|---|---|
| `"./"` or `"."` | Arg must resolve within `process.cwd()` |
| `"/some/dir"` | Arg must resolve within `/some/dir` |
| `"~"` / `"${HOME}/projects"` | Resolved to homedir |
| `"${CWD}/data"` | Resolved to cwd + `/data` |
The resolver uses `path.resolve` + `fs.realpathSync` to prevent `../` traversal and symlink escapes. Patterns that don't look like directory roots (e.g. `"^/etc/.*"`) fall back to regex matching.
#### `warn_only` mode
Useful for gradual rollout: set `warn_only: true` to observe violations without blocking. The call is forwarded and the following attributes appear in the OTel span:
```
policy.arg_warning = true
policy.arg_warning_field = "path"
policy.arg_warning_message = "Tool arg 'path' is denied by policy"
```
Switch to `warn_only: false` (the default) when you're ready to enforce.
#### Dot-notation for nested args
Use dot notation to constrain fields inside nested parameter objects:
```typescript
toolPolicies: {
my_tool: {
args: {
'options.target': { isPath: true, allow_pattern: './' },
},
},
}
```
---
### What happens when a call is blocked
The blocked call never reaches the real server:
```
[TelemetryInterceptor] → [PolicyInterceptor] → [ForwardInterceptor]
↑
blocks here, returns JSON-RPC error
```
The client receives:
```json
{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32001,
"message": "Tool 'write_file' is not permitted by policy"
}
}
```
The OTel span is still recorded — with `policy.blocked = true` and `mcp.error.code = -32001` — so you get a full audit trail of what was attempted and blocked.
`tools/list`, `prompts/list`, and `resources/list` responses are also filtered: denied entries are removed before the client sees them. The agent never learns a denied tool exists.
### Server name
Policy entries are keyed by server name. Use `--server-name` to set it explicitly in your MCP config:
```json
{
"mcpServers": {
"filesystem": {
"command": "heimdall-mcp",
"args": [
"start",
"--store", "sqlite://~/.heimdall/traces.db",
"--server-name", "filesystem",
"--",
"npx", "@modelcontextprotocol/server-filesystem", "/home/user/projects"
]
}
}
}
```
`--server-name` overrides the name from the server's `initialize` response — for both policy lookup and the `mcp.server.name` OTel attribute.
### Policy config in library mode
```typescript
import { ProxyBuilder } from '@cardor/heimdall-mcp';
import type { HeimdallConfig } from '@cardor/heimdall-mcp';
const policy: HeimdallConfig = {
servers: {
filesystem: {
tools: { deny: ['write_file', 'delete_file'] },
},
},
};
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'stdio', command: 'npx', args: ['@modelcontextprotocol/server-filesystem', '/tmp'] })
.store('sqlite://./traces.db')
.config(policy) // attach policy
.serverName('filesystem') // match config key
.build();
await proxy.start();
```
---
## Usage modes
### Mode 1 — CLI wrapping a subprocess (stdio)
The MCP client thinks it is talking to `heimdall-mcp`. The proxy spawns the real server as a child process and forwards all messages.
**`mcp.json` / Claude Desktop configuration:**
```json
{
"mcpServers": {
"my-server": {
"command": "heimdall-mcp",
"args": [
"--store", "sqlite://~/.mcp-traces/traces.db",
"--", "node", "my-server.js"
]
}
}
}
```
The `--` separator divides heimdall-mcp flags from the real server command. Everything after it is executed as a subprocess.
**With a globally installed server:**
```json
{
"mcpServers": {
"filesystem": {
"command": "heimdall-mcp",
"args": [
"--store", "sqlite://~/.mcp-traces/traces.db",
"--", "npx", "@modelcontextprotocol/server-filesystem", "/tmp"
]
}
}
}
```
**With Postgres instead of SQLite:**
```json
{
"mcpServers": {
"my-server": {
"command": "heimdall-mcp",
"args": [
"--store", "postgres://user:pass@localhost:5432/traces",
"--", "node", "my-server.js"
]
}
}
}
```
---
### Mode 2 — CLI wrapping a remote HTTP server
When the MCP server is already running and exposes an HTTP endpoint.
```json
{
"mcpServers": {
"remote-server": {
"command": "heimdall-mcp",
"args": [
"--store", "sqlite://~/.mcp-traces/traces.db",
"--out", "http",
"--target", "http://localhost:3001"
]
}
}
}
```
The proxy exposes **stdio** to the client and forwards each message as an HTTP `POST` to the target URL.
---
### Mode 3 — CLI wrapping a remote SSE server
For servers that use Server-Sent Events.
```json
{
"mcpServers": {
"sse-server": {
"command": "heimdall-mcp",
"args": [
"--store", "postgres://user:pass@host/db",
"--out", "sse",
"--target", "http://remote.example.com"
]
}
}
}
```
The proxy connects to `{target}/sse` to receive responses and sends requests as `POST` to `{target}`.
---
### Mode 4 — Library for developers
When you have access to the source code and want to integrate the proxy programmatically.
**Minimal setup:**
```ts
import { ProxyBuilder } from '@cardor/heimdall-mcp'
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'stdio', command: 'node', args: ['my-server.js'] })
.store('sqlite://./traces.db')
.build()
await proxy.start()
// clean shutdown
process.on('SIGINT', () => proxy.stop())
```
**stdio → remote HTTP:**
```ts
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'http', url: 'http://localhost:3001' })
.store('postgres://user:pass@localhost/traces')
.build()
await proxy.start()
```
**HTTP inbound (proxy listens on a port):**
```ts
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'http', port: 8080 })
.outbound({ transport: 'stdio', command: 'node', args: ['server.js'] })
.store('mysql://user:pass@localhost/traces')
.build()
await proxy.start()
```
**With OTLP export and debug logging:**
```ts
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'stdio', command: 'node', args: ['my-server.js'] })
.store('sqlite://./traces.db')
.otlp('http://localhost:4318/v1/traces') // export to Jaeger / Tempo / Grafana
.setDebug(true) // verbose span logs to stderr
.build()
await proxy.start()
```
**With a custom interceptor:**
```ts
import type { Interceptor, InterceptorContext, JsonRpcMessage } from '@cardor/heimdall-mcp'
class LogAllInterceptor implements Interceptor {
name = 'LogAllInterceptor'
async intercept(
request: JsonRpcMessage,
context: InterceptorContext,
next: () => Promise
): Promise {
console.log('→', request.method, request.id)
const response = await next()
console.log('←', response.id, response.error ? 'ERROR' : 'OK')
return response
}
}
const proxy = await ProxyBuilder.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'stdio', command: 'node', args: ['server.js'] })
.store('sqlite://./traces.db')
.build()
proxy.addInterceptor(new LogAllInterceptor())
await proxy.start()
```
---
## Stores
### SQLite
No external server required — ideal for local development.
**Valid connection strings:**
```
sqlite://./traces.db
sqlite://~/.mcp-traces/traces.db
sqlite:///absolute/path/traces.db
```
Driver: [`@libsql/client`](https://github.com/tursodatabase/libsql-client-ts) — pure WASM, no native compilation required.
**Schema:**
```
heimdall_spans
span_id TEXT PRIMARY KEY
trace_id TEXT NOT NULL
name TEXT NOT NULL → "mcp.tool.call", "mcp.initialize", etc.
kind INTEGER → OTel SpanKind: 0=INTERNAL, 1=SERVER, 2=CLIENT, 3=PRODUCER, 4=CONSUMER
status INTEGER NOT NULL → 0=UNSET, 1=OK, 2=ERROR
status_message TEXT
start_time_unix_nano INTEGER NOT NULL → Unix nanoseconds (OTel native)
end_time_unix_nano INTEGER NOT NULL
attributes TEXT/JSON → mcp.jsonrpc.method, mcp.jsonrpc.id, mcp.trace.request_id, mcp.tool.name, mcp.transport, mcp.status, mcp.error.type, mcp.server.name, mcp.latency.*, duration.ms, etc.
events TEXT/JSON → OTel events array (e.g. error events)
links TEXT/JSON → OTel links array
resource_attributes TEXT/JSON → service.name, service.version, service.namespace (OTel semantic conventions)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
heimdall_metrics
id INTEGER PRIMARY KEY AUTOINCREMENT
tool_name TEXT NOT NULL
call_count INTEGER DEFAULT 0
error_count INTEGER DEFAULT 0
avg_duration INTEGER
updated_at TEXT NOT NULL
```
> **Note:** SQLite uses `INTEGER` for nanosecond timestamps because SQLite has no native `BIGINT` type — the integer affinity handles large values correctly.
---
### PostgreSQL
```
postgres://user:pass@localhost:5432/my_db
postgresql://user:pass@localhost:5432/my_db
```
Driver: [`postgres`](https://github.com/porsager/postgres) — pure JS, no node-gyp.
**Schema differences from SQLite:**
- `start_time_unix_nano` / `end_time_unix_nano` → `BIGINT` (native 64-bit, exact for nanoseconds)
- `attributes` / `events` / `links` / `resource_attributes` → `JSONB` (indexable, queryable)
- `avg_duration` → `REAL`
- `updated_at` → `TIMESTAMP`
- `created_at` → `TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP`
---
### MySQL
```
mysql://user:pass@localhost:3306/my_db
```
Driver: [`mysql2`](https://github.com/sidorares/node-mysql2).
**Schema differences from SQLite:**
- `span_id` / `trace_id` / `name` → `VARCHAR(64/512)` (explicit lengths)
- `start_time_unix_nano` / `end_time_unix_nano` → `BIGINT` (native 64-bit, exact for nanoseconds)
- `attributes` / `events` / `links` / `resource_attributes` → `JSON`
- `avg_duration` → `FLOAT`
- `id` in metrics → `BIGINT UNSIGNED AUTO_INCREMENT`
- `updated_at` → `TIMESTAMP(3)` (millisecond precision)
---
## What gets recorded
Every JSON-RPC message produces a span in the `heimdall_spans` table. All attributes follow the `mcp.*` namespace for interoperability with other MCP-aware tools.
| MCP method | Span name | Key attributes |
|------------------|----------------------|-----------------------------------------------------------------------------------------------------------------------|
| `initialize` | `mcp.initialize` | `mcp.jsonrpc.method`, `mcp.jsonrpc.id`, `mcp.trace.request_id`, `mcp.transport`, `mcp.status`, `duration.ms` |
| `tools/list` | `mcp.tools.list` | + `mcp.server.name`, `mcp.server.version`, `mcp.response_mode`, `mcp.response.body_hash` |
| `tools/call` | `mcp.tool.call` | + `mcp.tool.name`, `mcp.request.body_hash`, `mcp.response.body_hash`, `mcp.latency.proxy_to_server_ms`, `mcp.latency.proxy_overhead_ms` |
| `resources/read` | `mcp.resource.read` | + `mcp.server.name`, `mcp.server.version` |
| `resources/list` | `mcp.resources.list` | + `mcp.server.name`, `mcp.server.version` |
| `prompts/get` | `mcp.prompt.get` | + `mcp.server.name`, `mcp.server.version` |
| `prompts/list` | `mcp.prompts.list` | + `mcp.server.name`, `mcp.server.version` |
| `shutdown` | `mcp.shutdown` | `mcp.jsonrpc.method`, `mcp.jsonrpc.id`, `mcp.trace.request_id`, `mcp.transport`, `mcp.status`, `duration.ms` |
| any other | `mcp.{method}` | `mcp.jsonrpc.method`, `mcp.jsonrpc.id`, `mcp.trace.request_id`, `mcp.transport`, `mcp.status`, `duration.ms` |
**Common attributes on every span:**
| Attribute | Description |
|---|---|
| `mcp.rpc.system` | Always `"mcp"` |
| `mcp.jsonrpc.method` | The JSON-RPC method name |
| `mcp.jsonrpc.id` | JSON-RPC `id` from the frame, coerced to string. Empty for notifications |
| `mcp.trace.request_id` | Proxy-generated per-request correlation ID (stable across numeric/string/null JSON-RPC IDs) |
| `mcp.transport` | `stdio`, `http`, or `sse` |
| `mcp.status` | `ok` · `error` · `timeout` · `cancelled` |
| `mcp.server.name` | Name of the real MCP server (captured from `initialize` response) |
| `mcp.server.version` | Version of the real MCP server (captured from `initialize` response) |
| `duration.ms` | Total round-trip latency in milliseconds |
**Latency breakdown (on `tools/call`):**
| Attribute | Description |
|---|---|
| `mcp.latency.proxy_to_server_ms` | Time the real server took to respond |
| `mcp.latency.proxy_overhead_ms` | Overhead introduced by the proxy itself |
**Body capture modes:**
Body capture is controlled by `--body-mode` (CLI) or `.setBodyMode()` (library). Default is `redacted`.
| Mode | `mcp.tool.request` / `mcp.tool.response` | `mcp.request.body_hash` | `mcp.response.body_hash` |
|---|---|---|---|
| `redacted` (default) | — (omitted) | `sha256:` (always) | `[redacted]` |
| `hash` | — (omitted) | `sha256:` (always) | `sha256:` |
| `full` | raw JSON | `sha256:` (always) | `sha256:` |
`mcp.request.body_hash` is always a real hash regardless of mode — use it to correlate repeated identical calls without exposing the payload. `mcp.response.body_hash` follows the redaction setting.
> Use `full` only for local development — raw bodies in shared OTLP backends can leak secrets.
**Agent correlation (optional):**
If the MCP client sends a `_meta` object inside `params`, heimdall-mcp will automatically extract and record these attributes:
| `_meta` field | Span attribute |
|---|---|
| `conversationId` | `gen_ai.conversation.id` |
| `turnId` | `gen_ai.turn.id` |
| `agentRunId` | `gen_ai.agent.run.id` |
As a fallback, the env vars `MCP_CONVERSATION_ID`, `MCP_TURN_ID`, and `MCP_AGENT_RUN_ID` are used if set.
On error, every span also gets `mcp.error.type` (`protocol` · `tool` · `proxy` · `transport`), `mcp.error.message`, and `mcp.error.code` attributes plus an `error` OTel event attached to the span.
When a call is blocked by policy, the span additionally includes `policy.blocked = true` — so you can query for attempted-but-blocked calls separately from real errors.
Every span's `resource_attributes` column contains OTel resource metadata:
- `service.name` — `@cardor/heimdall-mcp`
- `service.version` — package version
- `service.namespace` — `mcp-proxy`
The schema follows the [OpenTelemetry data model](https://opentelemetry.io/docs/concepts/signals/traces/) natively:
- Timestamps stored as **Unix nanoseconds** (`BIGINT` in Postgres/MySQL, `INTEGER` in SQLite)
- `kind` is an integer **SpanKind** (0=INTERNAL, 1=SERVER, 2=CLIENT, 3=PRODUCER, 4=CONSUMER)
- `status` is an integer **SpanStatusCode** (0=UNSET, 1=OK, 2=ERROR)
- JSON columns map directly to OTLP attribute bags
This means rows can be consumed directly by any OTel-compatible tool without transformation.
---
## Jaeger UI (OTLP)
heimdall-mcp can export every span to a Jaeger instance in real time via OTLP HTTP, so you can visualize traces without querying the database directly.

### 1. Start Jaeger
```bash
docker run -d \
--name jaeger \
-p 16686:16686 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
```
### 2. Add `--otlp` to your config
```json
{
"mcpServers": {
"my-server": {
"command": "heimdall-mcp",
"args": [
"--store", "sqlite://~/.mcp-traces/traces.db",
"--otlp", "http://localhost:4318/v1/traces",
"--", "node", "my-server.js"
]
}
}
}
```
For the HTTP/SSE variant (e.g. the setup used during development of this project):
```json
{
"mcpServers": {
"my-server": {
"command": "sh", "-c",
"args": [
"heimdall-mcp --store postgresql://user:pass@localhost:5432/db --out http --target http://localhost:3000/mcp --otlp http://localhost:4318/v1/traces"
]
}
}
}
```
### 3. Open Jaeger UI
```
http://localhost:16686
```
Select service **heimdall-mcp** and click **Find Traces**. Each MCP method (`mcp.tool.call`, `mcp.initialize`, `mcp.tools.list`, …) appears as a separate trace with full attributes and input/output event bodies.
**Dark mode** — append `?uiConfig={"theme":"dark"}` to the URL, or mount a config file:
```bash
echo '{"uiConfig":{"theme":"dark"}}' > jaeger-ui.json
docker rm -f jaeger && docker run -d \
--name jaeger \
-p 16686:16686 \
-p 4318:4318 \
-v $(pwd)/jaeger-ui.json:/etc/jaeger/ui-config.json \
-e JAEGER_UI_CONFIG_FILE=/etc/jaeger/ui-config.json \
jaegertracing/all-in-one:latest
```
> The `--otlp` flag is additive — spans are saved to the database **and** exported to Jaeger at the same time.
---
## Custom interceptors
The `Interceptor` interface is public. You can add your own logic into the pipeline before the telemetry interceptor:
```ts
interface Interceptor {
name: string
intercept(
request: JsonRpcMessage,
context: InterceptorContext,
next: () => Promise
): Promise
}
interface InterceptorContext {
startedAt: Date
traceId: string
spanId: string
bodyMode: 'redacted' | 'hash' | 'full'
transport: 'stdio' | 'http' | 'sse'
serverInfo: { name?: string; version?: string } // populated after initialize
conversationId?: string // from _meta or env var
turnId?: string
agentRunId?: string
metadata: Record // shared bag between interceptors
}
```
Calling `next()` passes control to the next interceptor in the chain. `ForwardInterceptor` is always last — it makes the actual call to the real server and records `latency.proxy_to_server_ms` in `context.metadata` for the telemetry interceptor to read.
You can use `context.metadata` to pass data between your interceptor and others in the same pipeline run.
---
## CLI reference
### start
The default command. Starts the proxy.
```
heimdall-mcp start [options] [-- command [args...]]
heimdall-mcp [options] [-- command [args...]] # "start" is optional
Options:
--store Store connection string (required)
sqlite://./traces.db
postgres://user:pass@host/db
mysql://user:pass@host/db
--out Transport to the real server (default: stdio)
stdio | http | sse
--target Server URL when --out is http or sse
--in Inbound transport (default: stdio)
stdio | http | sse
--in-port Port for --in http or --in sse
--otlp Export spans to an OTLP HTTP endpoint (e.g. Jaeger, Tempo)
Additive — spans are also saved to the store
Example: http://localhost:4318/v1/traces
--body-mode Body capture mode for tool args and responses (default: redacted)
redacted → stores [redacted] + size (safe for production)
hash → stores sha256: + size (safe for production)
full → stores raw JSON (local/dev only — may leak secrets)
--server-name Override server name for policy lookup and mcp.server.name OTel attribute.
If not provided, falls back to serverInfo.name from the initialize response.
--out-port Port for outbound http or sse transport
--debug Write verbose logs to stderr (prints span names + trace IDs to stderr)
-V, --version Print version
-h, --help Print this help
-- Separates proxy flags from the subprocess command
(required when --out is stdio)
Examples:
# stdio proxy → subprocess
heimdall-mcp --store sqlite://./t.db -- node server.js
# stdio proxy with policy enforcement
heimdall-mcp --store sqlite://./t.db --server-name filesystem -- npx @modelcontextprotocol/server-filesystem /tmp
# stdio proxy → remote HTTP server
heimdall-mcp --store sqlite://./t.db --out http --target http://localhost:3001
# stdio proxy → remote SSE server with Postgres
heimdall-mcp --store postgres://user:pass@host/db --out sse --target http://remote.com
# with OTLP export to Jaeger
heimdall-mcp --store sqlite://./t.db --otlp http://localhost:4318/v1/traces -- node server.js
```
`start` auto-discovers `heimdall.config.{ts,js,json}` in the current working directory and `~/.config/heimdall/heimdall.config.*` at startup. Config load errors print a warning but never crash the proxy.
---
### init
Scaffolds a `heimdall.config.ts` with commented examples.
```
heimdall-mcp init [options]
Options:
--global Write to ~/.config/heimdall/ instead of the current directory
--format File format: ts | js | json (default: ts)
--force Overwrite an existing config file
```
```bash
# Create local config in current directory
heimdall-mcp init
# Create global config (applies to all projects)
heimdall-mcp init --global
# Create as JSON
heimdall-mcp init --format json
```
---
### health
Validates the merged policy config and reports per-server policies. Does not connect to any MCP server.
```
heimdall-mcp health [options]
Options:
--config Path to a specific config file (skips auto-discovery)
```
Example output:
```
Config files loaded:
global: ~/.config/heimdall/heimdall.config.ts
local: ./heimdall.config.ts
Default policy:
tools: allow=[*] deny=[none]
Server policies:
filesystem:
tools: allow=[read_file, list_directory, search_files] deny=[write_file, create_file, delete_file, move_file]
database:
tools: allow=[query, describe_table, list_tables] deny=[execute, drop_table, truncate]
No conflicts detected.
```
If the same entity appears in both `allow` and `deny`, `health` exits with code 1 and lists all conflicts.
---
## Tracing reference
The complete attribute vocabulary — required attributes, optional attributes, body capture fields, error classification, and annotated span examples for `initialize`, `tools/list`, `tools/call`, and a proxy failure case — is documented in [TRACING.md](TRACING.md).
---
## Roadmap
| Phase | Feature | Status |
|---|---|---|
| 1 | Auto-discovered `heimdall.config.{ts,js,json}` — local + global, allow/deny lists per server | ✅ Done (v1.2) |
| 2 | `PolicyInterceptor` — blocked calls return JSON-RPC error -32001, recorded in OTel with `policy.blocked = true` | ✅ Done (v1.2) |
| 3 | `heimdall-mcp init` + `heimdall-mcp health` CLI commands | ✅ Done (v1.2) |
| 4 | Stable tracing vocabulary — `mcp.jsonrpc.id`, `mcp.trace.request_id`, `mcp.error.type`, per-direction body hashes, [TRACING.md](TRACING.md) spec | ✅ Done (v1.3) |
| 5 | Filter by action type (`read` / `write` / `execute`) inferred from tool name or MCP metadata | 📋 Planned |
| 6 | Runtime enforcement modes: `warn` (log + forward), `audit` (span with `policy.violation = true`) | 📋 Planned |