Heimdall MCP: Add OpenTelemetry tracing to any MCP server without touching its code
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. 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. 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. 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. 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, Pick the backend that fits your setup: --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. --store postgres://user:password@localhost:5432/mydb Best for: production environments, teams sharing observability data, --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. 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() 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. npm install -g @cardor/heimdall-mcp GitHub: github.com/enmanuelmag/heimdall-mcp 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].
