If you've been building with MCP servers lately, you've probably hit this wall: something goes wrong (or just feels slow) and you have zero visibility into what actually happened.
Which tool did Claude call? What input did it send? Did the server error silently? How long did it take?
That's the gap Heimdall fills.
What is Heimdall?
Heimdall is a transparent proxy for MCP servers. It sits between your MCP client (Claude Desktop, OpenCode, Cursor, or any other) and your MCP server — local or remote — and records every interaction as an OpenTelemetry span.
No modifications to your server. No SDK to integrate. No infra to run. Just wrap your existing server and start seeing what's happening inside.
The name comes from Norse mythology: Heimdall is the guardian of the Bifrost bridge, with the ability to see and hear everything that crosses between worlds. That's exactly the role this proxy plays.
The problem in one screenshot
You're running a Claude agent that orchestrates 5+ MCP tools. One of them is consistently slow. Another occasionally returns empty results. You have no way to know which one, when, or why — unless you dig into logs manually.
With Heimdall, every tool call becomes a structured span:
{
"name": "mcp.tool.call",
"attributes": {
"gen_ai.tool.name": "search_documents",
"mcp.duration_ms": 843,
"mcp.status": "ok"
},
"events": [
{ "name": "request", "body": { "query": "quarterly report" } },
{ "name": "response", "body": { "results": [...] } }
]
}
Stored. Queryable. Yours.
Zero config wrapping via mcp.json
The easiest way to use Heimdall requires zero access to the server's source code. Just install it globally and update your mcp.json:
npm install -g @cardor/heimdall-mcp
Before — your current config:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["my-server.js"]
}
}
}
After — wrapped with Heimdall:
{
"mcpServers": {
"my-server": {
"command": "heimdall",
"args": [
"--store", "sqlite://~/.heimdall/traces.db",
"--",
"node", "my-server.js"
]
}
}
}
That's it. Restart your client and every tool call starts being recorded. The -- separator tells Heimdall where its args end and the real server command begins.
For remote servers (HTTP or SSE), it's just as simple:
{
"mcpServers": {
"remote-server": {
"command": "heimdall",
"args": [
"--store", "postgres://user:pass@localhost/mydb",
"--target", "http://my-remote-mcp.com/sse"
]
}
}
}
Heimdall exposes stdio to your client, talks HTTP/SSE to the real server, and intercepts everything in between.
What gets recorded
Heimdall captures all standard MCP events:
| Event | What's recorded |
|---|---|
tools/call |
Tool name, input args, response, duration, status |
tools/list |
Available tools and their schemas |
resources/read |
URI, MIME type, response size |
prompts/get |
Prompt name, arguments, rendered output |
initialize |
Client/server versions, negotiated capabilities |
shutdown |
Total session duration |
Every span follows the OpenTelemetry gen_ai.* semantic conventions,
so if you later want to send traces to Jaeger, Honeycomb, or Grafana Tempo, the data is already well-formed.
Storage options
Pick the backend that fits your setup:
SQLite — local file, zero infra
--store sqlite://./traces.db
# or with absolute path
--store sqlite:///Users/you/.heimdall/traces.db
Best for: local development, single-machine setups, quick debugging sessions.
Uses @libsql/client under the hood — pure WASM, no native bindings, no node-gyp.
Postgres — your existing database
--store postgres://user:password@localhost:5432/mydb
Best for: production environments, teams sharing observability data,
long-term storage with SQL queries across multiple servers.
MySQL
--store mysql://user:password@localhost:3306/mydb
Same use case as Postgres. Pick whichever you already run.
All three use drizzle-orm with a shared schema, so the query experience is consistent regardless of which backend you choose.
Using it as a TypeScript library
If you have access to your server's code and want tighter integration, Heimdall ships as a fully-typed TypeScript library with a fluent builder API:
import { McpProxy } from 'heimdall-mcp'
const proxy = await McpProxy
.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'http', url: 'http://localhost:3001' })
.store('sqlite://./traces.db')
.build()
await proxy.start()
You can also plug in custom interceptors — for example, to redact sensitive fields before they're persisted, or to add your own business logic on top of the tracing:
import { McpProxy, type Interceptor } from 'heimdall-mcp'
// Custom interceptor: redact API keys from tool inputs before storing
const redactSecrets: Interceptor = {
name: 'redact-secrets',
async intercept(request, ctx, next) {
const sanitized = redactSensitiveFields(request)
return next(sanitized)
}
}
const proxy = await McpProxy
.create()
.inbound({ transport: 'stdio' })
.outbound({ transport: 'http', url: 'http://localhost:3001' })
.store('postgres://user:pass@host/db')
.intercept(redactSecrets)
.build()
Design goals
A few decisions worth calling out:
No native dependencies. SQLite runs via WASM (@libsql/client), Postgres via the pure-JS postgres package, MySQL via mysql2. You can install and run Heimdall in any Node 22+ environment without compilation steps.
No policy opinions. Heimdall doesn't block, approve, or modify tool calls. It only observes and records. If you need access control or rate limiting, pair it with a tool like mcpwall.
Transport mixing. Your client connects via stdio. Your server can be stdio, HTTP, or SSE. Heimdall bridges them transparently — useful for wrapping local CLI servers behind an HTTP interface without touching their code.
Get started
npm install -g @cardor/heimdall-mcp
GitHub: github.com/enmanuelmag/heimdall-mcp
Website: https://heimdall-mcp.cardor.dev
If you try it out, I'd love to hear what you think — especially feedback on the interceptor API design and the OTel schema. Open an issue or find me on X [@enmanuelmag].
Top comments (0)