Heimdall MCP
About
Transparent MCP proxy with OpenTelemetry tracing. Wrap any MCP server, persist traces to any OTel backend · SQLite · Postgres · MySQL. No code changes needed.
Details
- Author
- enmanuelmag
- Categories
- Developer Tools, Infrastructure, Database
Jump to
Setup
Install Heimdall MCP in your MCP client (Claude Desktop, Cursor, Windsurf, and others).
Repository: https://github.com/enmanuelmag/heimdall-mcp
Follow the installation instructions in the repository README, then restart your MCP client.
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 thewebsiteto view a full explanation, examples, and other tools!
- Table of Contents
- How it works
- Installation
- Policy config
- Config files
- Config format
- Merge strategy
- Argument policies
- Resource locks
- Host policies
- Limitations
- What happens when a call is blocked
- Server name
- Policy config in library mode
- Mode 1 — CLI wrapping a subprocess (stdio)
- Mode 2 — CLI wrapping a remote HTTP server
- Mode 3 — CLI wrapping a remote SSE server
- Mode 4 — Library for developers
- 1. Start Jaeger
- 2. Add--otlpto your config
- 3. Open Jaeger UI
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 exposesstdioto 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.
npm install -g @cardor/heimdall-mcp # or as a project dependency npm install @cardor/heimdall-mcp
Drop aheimdall.config.tsin 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.
Both are optional. If neither exists, the proxy stays fully transparent (backward compatible).
// 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 viajitiwithout pre-compilation. Also works as.js,.mjs,.cjs, or.json.
When both local and global configs exist, they merge withsecurity-first semantics:
The global config enforces a floor the team can't accidentally loosen. Local configs can only addmorerestrictions, never fewer.
toolPoliciesadds a second enforcement layer on top of name-leveltoolsrules. Instead of just decidingwhichtools are callable, you can constrainwhat argumentsare allowed on each call.
// 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;
WhenisPath: true, patterns that look like directory roots are treated as containment checks rather than regex expressions:
The resolver usespath.resolve+fs.realpathSyncto prevent../traversal and symlink escapes. Patterns that don't look like directory roots (e.g."^/etc/.") fall back to regex matching.
Useful for gradual rollout: setwarn_only: trueto 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 towarn_only: false(the default) when you're ready to enforce.
Use dot notation to constrain fields inside nested parameter objects:
toolPolicies: { my_tool: { args: { 'options.target': { isPath: true, allow_pattern: './' }, }, }, }
Prevent concurrenttools/callinvocations from racing on the same resource (e.g. two agents writing the same file at once). Configure alocksblock per server, keyed by tool name.
Concrete scenarios where resource locks solve a real coordination problem:
- Two AI coding agents editing the same file concurrently.Two agent sessions (e.g. two Claude Code instances, or an MCP filesystem server invoked by multiple agents) running in parallel against the same repo can both decide to write the same file at the same time. Without a lock, the second write silently clobbers the first agent's changes. Locking on the file-path argument (resource: 'path'orresource: 'file_path') serializes those calls — the second call is rejected (or, withonConflict: 'warn', forwarded with a warning) until the first completes or the lock's TTL expires.
- Serializing a database migration tool across parallel sessions.Arun_migrationtool exposed through an MCP database server is dangerous to run concurrently — two overlapping migrations against the same database can corrupt state. Locking on a literal resource key (resource: 'db-migration', not tied to any particular argument) ensures only one migration call is in flight at a time, regardless of which session or agent triggered it.
- Avoiding duplicate/racing calls to a rate-limited external API.A tool that calls a third-party API with a strict rate limit (e.g. payment or search APIs) can be locked on a stable key (the tool name, or an argument likequery) so near-simultaneous duplicate calls from separate agent turns don't multiply API usage or trip the provider's rate limiter.
- Coordinating across multiple proxy instances, not just one process.With a shared Postgres/MySQL lock store (see below) instead of the default local SQLite file, resource locks coordinate across machines — useful for fleets of agents or proxy instances sharing the same backing infrastructure.
// heimdall.config.ts export default { servers: { filesystem: { tools: { allow: ['read_file', 'write_file'] }, locks: { write_file: { resource: 'path', ttl: 30_000 }, run_migration: { resource: 'db-migration', onConflict: 'warn' }, }, }, }, } satisfies HeimdallConfig;
Semantics: write mode, TTL, and read mode
- Write mode is exclusive.Every lock acquired byLockInterceptoris a'write'lock: at most one holder can hold a given resource key at a time, regardless of whether the underlying call is conceptually a read or a write. There's no separate shared/concurrent mode — two calls that both just need to read the same resource still serialize against each other if they share a lock rule.
- 'read'mode exists as a type, not as a feature.TheLockStoreinterface definesLockMode = 'read' | 'write'at the storage layer, butLockInterceptorcurrently hardcodes'write'on everyacquire()call, andLockRuleSchemahas nomodefield to select it fromheimdall.config.ts. Don't rely on'read'mode for anything — it's not configurable or reachable from user config yet.
- What TTL expiry means in practice.ttlis not a per-call timeout — it's a backstop for stuck holders. If the process holding a lock crashes, hangs, or is killed before it releases the lock, the lock would otherwise block that resource forever. Oncettlmilliseconds pass since acquisition, the lock is treated as expired and a new caller can acquire the same resource, even though the original holder never explicitly released it. Release is idempotent, so if the original (crashed) holder later callsrelease()after its lock has already expired and been reacquired by someone else, that stale release is a silent no-op — it does not release the new holder's lock.
Status:write-mode (exclusive) locking is enforced ontools/call—LockInterceptoracquires a lock before forwarding, releases it on completion or error, and is backed by aLockStore. Resource keys that look like filesystem paths (absolute,~,./,../, or a drive letter) are canonicalized — expanded, resolved to an absolute path, and symlinks followed — so the same real file is locked consistently no matter how it's referenced (relative path,~, symlink, or a different project directory).Not yet implemented:read-mode locking (every lock is currently acquired in exclusive'write'mode; there is nomodefield inLockRuleSchemayet — seeLimitations).
By default the lock store is a local SQLite file at~/.config/heimdall/locks.db— zero configuration required. Postgres and MySQL backends are also available for multi-machine lock coordination (e.g. multiple proxy instances sharing the same resource locks), via--lock-store:
heimdall-mcp --store sqlite://./traces.db --lock-store postgres://user:pass@host/db -- node server.js heimdall-mcp --store sqlite://./traces.db --lock-store mysql://user:pass@host/db -- node server.js
serversanddefaultconfigure MCP servers routed through the proxy'stools/callpipeline.hostsis a separate, sibling top-level field for configuring lock policy onhost-native tools— tools built into the coding agent itself (e.g. Claude Code'sWrite/Edit, or OpenCode's/Codex's equivalents) that are not MCP servers and are not intercepted by the JSON-RPC proxy. It is keyed by host name, and each host'slocksblock uses the exact sameLockRuleshape (resource/ttl/onConflict) documented above.
// heimdall.config.ts export default { hosts: { 'claude-code': { locks: { Write: { resource: 'file_path', ttl: 30_000 }, Edit: { resource: 'file_path', ttl: 30_000 }, MultiEdit: { resource: 'file_path', ttl: 30_000 }, NotebookEdit: { resource: 'file_path', ttl: 30_000 }, }, }, }, } satisfies HeimdallConfig;
@cardor/heimdall-mcpexportsCLAUDE_CODE_DEFAULT_HOST_POLICY, a ready-madeHostPolicycovering Claude Code's file-mutating native tools —Write,Edit,MultiEdit, andNotebookEdit— each locking on afile_pathargument with a 30s TTL.
Not yet implemented:Bashis deliberately excluded fromCLAUDE_CODE_DEFAULT_HOST_POLICYand has no recommended lock rule. Bash's arbitrary shell commands have no single stable "resource" argument to lock on — a command could touch zero, one, or many files — so locking it would be either meaningless (no resource key to extract) or dangerously coarse (serializing all Bash calls globally, unrelated work included).
Status:config loading, merging, and enforcement via a real Claude CodePreToolUsehook script. Thehostsfield,HostPolicySchema, andCLAUDE_CODE_DEFAULT_HOST_POLICYare defined, validated, and merged (global config'shostsand local config'shostsare combined per host key, with local'slocksfor a given host winning entirely over global's when both set it — no field-level union, matchingdefault/serverslockssemantics). If neither config setshosts['claude-code'],CLAUDE_CODE_DEFAULT_HOST_POLICYis applied automatically as a baseline; any user-suppliedhosts['claude-code']value (in either config) replaces the default entirely.
bin/hooks/claude-pretooluse.jsis a real, workingPreToolUsehook script for Claude Code. On every invocation it readstool_name/tool_inputfrom stdin, reloadsheimdall.config.fresh from disk (local + global, merged — never cached), matches the tool againsthosts['claude-code'].locks, resolves the lock resource from the matched rule'sresourceargument (canonicalizing filesystem paths the same wayLockRuleresource resolution does elsewhere in this doc), and attempts to acquire an exclusive lock against the same default SQLite lock store used by the proxy (~/.config/heimdall/locks.db). If the lock is free, the call is allowed. If it's held by another holder, the call is denied with a human-readable reason. The hook always exits0and fails open (silently allows) on any unexpected error — a bug in the hook must never brick a Claude Code session.
For the fullPreToolUsehook contract investigation and unresolved edge cases behind this implementation, seeSPIKE_CLAUDE_HOOKS.md.
Recommended:runheimdall-mcp init --hooks claude-codeto register this hook automatically:
This reads~/.claude/settings.json(creating it if it doesn't exist), resolves the absolute path to the installed package'sbin/hooks/claude-pretooluse.js, and appends aPreToolUseentry for it — without touching any otherhooks.*entries or unrelated top-level settings keys. It's idempotent: running it again when the hook is already registered prints a confirmation and makes no changes, instead of adding a duplicate entry. Written via an atomic temp-file-then-rename, and it never blind-overwrites the file.
The manual, hand-edited version is still supported and useful for troubleshooting or understanding exactly what gets written — it's the same shapeinit --hooks claude-codeproduces. Add it toPreToolUsein.claude/settings.json(or.claude/settings.local.json):
{ "hooks": { "PreToolUse": [ { "matcher": "Write|Edit|MultiEdit|NotebookEdit", "hooks": [ { "type": "command", "command": "node /absolute/path/to/node_modules/@cardor/heimdall-mcp/bin/hooks/claude-pretooluse.js" } ] } ] } }
- APostToolUsecompanion hook to release the lock once the tool call completes. Locks acquired by this hook are released only via TTL expiration (default 30s, or the rule's configuredttl), not immediately after the tool finishes — a known limitation, not a bug.
src/plugins/opencode-heimdall.ts(compiled todist/plugins/opencode-heimdall.js, re-exported bybin/plugins/opencode-heimdall.js) is an OpenCode plugin implementing thetool.execute.beforehook, following the same config-loading, host-matching, and lock-acquisition logic as the Claude Code hook above — but matched againsthosts['opencode']instead ofhosts['claude-code']. There is no built-in default policy for OpenCode yet (noOPENCODE_DEFAULT_HOST_POLICYequivalent toCLAUDE_CODE_DEFAULT_HOST_POLICY), so this plugin allows every tool call until you explicitly configurehosts.opencode.locksyourself, e.g.:
// heimdall.config.ts export default { hosts: { opencode: { locks: { write: { resource: 'filePath', ttl: 30_000 }, }, }, }, } satisfies HeimdallConfig;
For the fulltool.execute.beforecontract investigation, deny-mechanism verification method, and unresolved open questions behind this implementation, seeSPIKE_OPENCODE_HOOKS.md.
Unlike Claude Code's hook (a separate process spawned per tool call, communicating over stdin/stdout), OpenCode plugins are resolved and runin-process— OpenCode itselfimport()s the plugin module and calls its exported factory function once at startup; there is no subprocess, no stdin/stdout, and the resulting hooks stay registered for the whole session.
Recommended:runheimdall-mcp init --hooks opencodeto register this plugin automatically:
This reads~/.config/opencode/opencode.jsonc(creating it if it doesn't exist), resolves the absolute path to the installed package'sbin/plugins/opencode-heimdall.js, and appends it to the top-levelpluginarray — without touching any other keys. It's idempotent: running it again when the plugin is already registered prints a confirmation and makes no changes, instead of adding a duplicate entry. Written via an atomic temp-file-then-rename, and it never blind-overwrites the file.
opencode.jsoncis genuine JSONC — real configs can and do contain//comments and trailing commas. This installer usesjsonc-parser(the same library VS Code uses internally to editsettings.json) to compute a surgical text edit that appends the new array entry, rather than a plainJSON.parse/JSON.stringifyround-trip — so existing comments, trailing commas, and formatting elsewhere in the file are preserved. (Comments attached directly to the appended array entry's own line may shift by one entry as a side effect of the array-insertion edit; comments elsewhere in the file are untouched.)
The manual, hand-edited version is still supported and useful for troubleshooting or understanding exactly what gets written — it's the same shapeinit --hooks opencodeproduces. Add the compiled package's plugin path to youropencode.jsonc'spluginarray:
// opencode.jsonc { "plugin": ["/absolute/path/to/node_modules/@cardor/heimdall-mcp/bin/plugins/opencode-heimdall.js"] }
Confidence caveat on the registration mechanism itself.Thepluginarray's support for bare absolute file-path entries (nopackage.json, no npm packaging) was verified with HIGH confidence against OpenCode's real fetched source (sst/opencode,packages/opencode/src/plugin/shared.ts'sisPathPluginSpec()/resolvePathPluginTarget()) cross-checked against the actually-installed OpenCode binary's embedded strings — seeSPIKE_OPENCODE_HOOKS.mdfor the verification method. That said, like the plugin's deny mechanism documented below, this hasnot been validated end-to-end against a live OpenCode process actually loading and using the plugin— treat the registration step, same as the plugin itself, as experimental until independently verified against a real OpenCode session.
Confidence caveat — read before relying on this for security.OpenCode's own published documentation fortool.execute.beforewas not locally available during development (seeSPIKE_OPENCODE_HOOKS.md), and itsoutputtype is only{ args: any }— there is no confirmeddeny/block/statusfield on this hook (contrast OpenCode's ownpermission.askhook, which does have a typedstatus: "ask" | "deny" | "allow"field). This plugin denies a conflicting lock bythrowing anErrorfrom the hook callback, on the assumption that a rejected promise aborts the tool call — the conventional pattern for void-returning before-hooks, and nothing found contradicts it, butthis has not been confirmed against a live OpenCode session. If that assumption is wrong, this plugin will silently fail to block anything while still appearing to deny (it throws; whether OpenCode's runtime actually blocks the tool call on that throw is unverified). Treat this integration as experimental until independently verified.
- Atool.execute.aftercompanion to release the lock once the tool call completes — same TTL-only-release limitation as the Claude Code hook above.
- Live confirmation of the deny mechanism described above, and of theinit --hooks opencoderegistration mechanism actually being loaded by a live OpenCode process.
A single, canonical list of everything not yet implemented, or not yet independently verified, across resource locks and host policies. Each item also has an inline note near the relevant section above with more local context.
- No read-mode locking.Every lock is acquired in exclusive'write'mode;LockRuleSchemahas nomodefield yet, even though theLockStoretype defines'read' | 'write'. SeeSemanticsabove.
- No lock support forBash.Bashis deliberately excluded fromCLAUDE_CODE_DEFAULT_HOST_POLICYbecause arbitrary shell commands have no single stable "resource" argument to lock on — locking it would be either meaningless or dangerously coarse.
- No automatic release on tool completion (Claude Code).ThePreToolUsehook has noPostToolUsecompanion to release the lock immediately once the tool call finishes. Locks it acquires are released only via TTL expiration (default 30s, or the rule's configuredttl) — not a bug, but a known limitation.
- No automatic release on tool completion (OpenCode).Same limitation as Claude Code above — thetool.execute.beforeplugin has notool.execute.aftercompanion; release is TTL-only.
- OpenCode deny mechanism unverified end-to-end.The plugin denies a conflicting lock by throwing anErrorfrom thetool.execute.beforecallback, on the assumption that a thrown error aborts the tool call. This has not been confirmed against a live OpenCode session. SeeSPIKE_OPENCODE_HOOKS.md.
- OpenCodeinit --hooks opencoderegistration unverified end-to-end.The registration mechanism (appending a bare file path toopencode.jsonc'spluginarray) was verified with high confidence against OpenCode's source and installed binary, but has not been validated against a live OpenCode process actually loading and using the plugin. SeeSPIKE_OPENCODE_HOOKS.md.
For the full hook-contract investigation behind the Claude Code caveats above, seeSPIKE_CLAUDE_HOOKS.md. For the OpenCode plugin registration/deny-mechanism investigation, seeSPIKE_OPENCODE_HOOKS.md.
The blocked call never reaches the real server:
[TelemetryInterceptor] → [PolicyInterceptor] → [ForwardInterceptor] ↑ blocks here, returns JSON-RPC error
{ "jsonrpc": "2.0", "id": 42, "error": { "code": -32001, "message": "Tool 'write_file' is not permitted by policy" } }
The OTel span is still recorded — withpolicy.blocked = trueandmcp.error.code = -32001— so you get a full audit trail of what was attempted and blocked.
tools/list,prompts/list, andresources/listresponses are also filtered: denied entries are removed before the client sees them. The agent never learns a denied tool exists.
Atools/callblocked by an activeresource lockreturns a distinct JSON-RPC error with structured holder info inerror.data:
{ "jsonrpc": "2.0", "id": 42, "error": { "code": -32600, "message": "Resource '/repo/file.txt' is locked (requested by tool 'write_file')", "data": { "resource_key": "/repo/file.txt", "held_by": "a1b2c3d4...", "expires_at": 1735689600000 } } }
-32600(exported asRESOURCE_LOCKED) is used deliberately here even though it collides with JSON-RPC 2.0's reserved "Invalid Request" range — see the code comment inLockInterceptor.tsfor the rationale.
SetonConflict: 'warn'on a lock rule to forward the call instead of blocking — the OTel span getslock.conflict_warning = true,lock.resource_key,lock.held_by, andlock.expires_atattributes instead of an error response.
Policy entries are keyed by server name. Use--server-nameto set it explicitly in your MCP config:
{ "mcpServers": { "filesystem": { "command": "heimdall-mcp", "args": [ "start", "--store", "sqlite://~/.heimdall/traces.db", "--server-name", "filesystem", "--", "npx", "@modelcontextprotocol/server-filesystem", "/home/user/projects" ] } } }
--server-nameoverrides the name from the server'sinitializeresponse — for both policy lookup and themcp.server.nameOTel attribute.
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();
Mode 1 — CLI wrapping a subprocess (stdio)
The MCP client thinks it is talking toheimdall-mcp. The proxy spawns the real server as a child process and forwards all messages.
mcp.json/ Claude Desktop configuration:
{ "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.
{ "mcpServers": { "filesystem": { "command": "heimdall-mcp", "args": [ "--store", "sqlite://~/.mcp-traces/traces.db", "--", "npx", "@modelcontextprotocol/server-filesystem", "/tmp" ] } } }
{ "mcpServers": { "my-server": { "command": "heimdall-mcp", "args": [ "--store", "postgres://user:pass@localhost:5432/traces", "--", "node", "my-server.js" ] } } }
Sign in to leave a review
Use Google, GitHub, or an email account so ratings stay tied to real people.
No reviews posted yet.





