{"id":50194870,"url":"https://github.com/enmanuelmag/heimdall-mcp","last_synced_at":"2026-06-15T17:32:17.628Z","repository":{"id":356741430,"uuid":"1233534767","full_name":"enmanuelmag/heimdall-mcp","owner":"enmanuelmag","description":"Transparent MCP proxy with OpenTelemetry tracing. Wrap any MCP server and persist traces to SQLite, Postgres or MySQL.","archived":false,"fork":false,"pushed_at":"2026-05-27T02:02:58.000Z","size":501,"stargazers_count":8,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-11T08:05:06.658Z","etag":null,"topics":["agent","audit","audit-trail","javascript","mcp","model-context-protocol","observability","opentelemetry","postgresql","proxy","sqlite","tracing","typescript"],"latest_commit_sha":null,"homepage":"https://stack.cardor.dev/heimdall","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/enmanuelmag.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-09T04:13:32.000Z","updated_at":"2026-05-27T02:03:02.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/enmanuelmag/heimdall-mcp","commit_stats":null,"previous_names":["enmanuelmag/heimdall-mcp"],"tags_count":9,"template":false,"template_full_name":null,"purl":"pkg:github/enmanuelmag/heimdall-mcp","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enmanuelmag%2Fheimdall-mcp","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enmanuelmag%2Fheimdall-mcp/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enmanuelmag%2Fheimdall-mcp/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enmanuelmag%2Fheimdall-mcp/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/enmanuelmag","download_url":"https://codeload.github.com/enmanuelmag/heimdall-mcp/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/enmanuelmag%2Fheimdall-mcp/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34374146,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-15T02:00:07.085Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agent","audit","audit-trail","javascript","mcp","model-context-protocol","observability","opentelemetry","postgresql","proxy","sqlite","tracing","typescript"],"created_at":"2026-05-25T17:00:20.581Z","updated_at":"2026-06-15T17:32:17.604Z","avatar_url":"https://github.com/enmanuelmag.png","language":"TypeScript","funding_links":["https://ko-fi.com/S6S31ZBGQK'"],"categories":["OpenTelemetry Instrumentation"],"sub_categories":["GenAI / LLM Instrumentation"],"readme":"# @cardor/heimdall-mcp\n\nTransparent 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.\n\nVisit the [website](https://stack.cardor.dev/heimdall) to view a full explanation, examples, and other tools!\n\n![npm version](https://img.shields.io/npm/v/@cardor/heimdall-mcp)\n![npm downloads](https://img.shields.io/npm/dm/@cardor/heimdall-mcp)\n![license](https://img.shields.io/npm/l/@cardor/heimdall-mcp)\n[![Known Vulnerabilities](https://snyk.io/test/npm/@cardor/heimdall-mcp/badge.svg)](https://snyk.io/test/npm/@cardor/heimdall-mcp)\n[![heimdall-mcp MCP server](https://glama.ai/mcp/servers/enmanuelmag/heimdall-mcp/badges/score.svg)](https://glama.ai/mcp/servers/enmanuelmag/heimdall-mcp)\n\n\u003ca href='https://ko-fi.com/S6S31ZBGQK' target='_blank'\u003e\u003cimg height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi6.png?v=6' border='0' alt='Buy Me a Coffee at ko-fi.com' /\u003e\u003c/a\u003e\n\n## Table of Contents\n\n- [@cardor/heimdall-mcp](#cardorheimdall-mcp)\n  - [Table of Contents](#table-of-contents)\n  - [How it works](#how-it-works)\n  - [Installation](#installation)\n  - [Policy config](#policy-config)\n    - [Config files](#config-files)\n    - [Config format](#config-format)\n    - [Merge strategy](#merge-strategy)\n    - [Argument policies](#argument-policies)\n    - [What happens when a call is blocked](#what-happens-when-a-call-is-blocked)\n    - [Server name](#server-name)\n    - [Policy config in library mode](#policy-config-in-library-mode)\n  - [Usage modes](#usage-modes)\n    - [Mode 1 — CLI wrapping a subprocess (stdio)](#mode-1--cli-wrapping-a-subprocess-stdio)\n    - [Mode 2 — CLI wrapping a remote HTTP server](#mode-2--cli-wrapping-a-remote-http-server)\n    - [Mode 3 — CLI wrapping a remote SSE server](#mode-3--cli-wrapping-a-remote-sse-server)\n    - [Mode 4 — Library for developers](#mode-4--library-for-developers)\n  - [Stores](#stores)\n    - [SQLite](#sqlite)\n    - [PostgreSQL](#postgresql)\n    - [MySQL](#mysql)\n  - [What gets recorded](#what-gets-recorded)\n  - [Jaeger UI (OTLP)](#jaeger-ui-otlp)\n    - [1. Start Jaeger](#1-start-jaeger)\n    - [2. Add `--otlp` to your config](#2-add---otlp-to-your-config)\n    - [3. Open Jaeger UI](#3-open-jaeger-ui)\n  - [Custom interceptors](#custom-interceptors)\n  - [CLI reference](#cli-reference)\n    - [start](#start)\n    - [init](#init)\n    - [health](#health)\n  - [Tracing reference](#tracing-reference)\n  - [Roadmap](#roadmap)\n\n---\n\n## How it works\n\n```mermaid\nflowchart LR\n    A[\"MCP Client\\n(Claude Desktop / OpenCode / Cursor)\"]\n\n    subgraph proxy[\"heimdall-mcp\"]\n        B[\"TelemetryInterceptor\"]\n        P[\"PolicyInterceptor\"]\n        C[\"ForwardInterceptor\"]\n        D[(\"SQLite\\nPostgres\\nMySQL\")]\n        B --\u003e P\n        P --\u003e C\n        B --\u003e|\"saves span\"| D\n    end\n\n    S[\"Real MCP server\\n(subprocess / HTTP / SSE)\"]\n\n    A --\u003e|\"stdio\"| B\n    C --\u003e|\"stdio · http · sse\"| S\n    S --\u003e|\"response\"| C\n    C --\u003e|\"response\"| A\n```\n\nThe 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.\n\n---\n\n## Installation\n\n```bash\nnpm install -g @cardor/heimdall-mcp\n# or as a project dependency\nnpm install @cardor/heimdall-mcp\n```\n\n---\n\n## Policy config\n\nDrop 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.\n\n### Config files\n\n| Scope  | Path | Purpose |\n|--------|------|---------|\n| Local  | `{project-root}/heimdall.config.{ts,js,mjs,cjs,json}` | Per-repo rules |\n| Global | `~/.config/heimdall/heimdall.config.{ts,js,mjs,cjs,json}` | User/org-wide rules |\n\nBoth are optional. If neither exists, the proxy stays fully transparent (backward compatible).\n\n### Config format\n\n```typescript\n// heimdall.config.ts\nimport type { HeimdallConfig } from '@cardor/heimdall-mcp';\n\nexport default {\n  // default: applies to any server without an explicit entry\n  default: {\n    tools: { allow: ['*'], deny: [] },\n  },\n\n  // servers: keyed by --server-name (or serverInfo.name from initialize response)\n  servers: {\n    filesystem: {\n      tools: {\n        allow: ['read_file', 'list_directory', 'search_files'],\n        deny:  ['write_file', 'create_file', 'delete_file', 'move_file'],\n      },\n      resources: {\n        allow: ['*'],\n        deny:  ['file:///etc/*', 'file:///root/*'],\n      },\n    },\n    database: {\n      tools: {\n        allow: ['query', 'describe_table', 'list_tables'],\n        deny:  ['execute', 'drop_table', 'truncate'],\n      },\n    },\n  },\n} satisfies HeimdallConfig;\n```\n\nTypeScript configs are loaded via [`jiti`](https://github.com/unjs/jiti) without pre-compilation. Also works as `.js`, `.mjs`, `.cjs`, or `.json`.\n\n### Merge strategy\n\nWhen both local and global configs exist, they merge with **security-first semantics**:\n\n| Rule | Behavior |\n|---|---|\n| **Deny → union** | Denied by either = denied. Global deny cannot be overridden locally. |\n| **Allow → intersection** | Must pass both. `*` or `[]` means \"defer to the other side.\" |\n| **Deny beats allow** | Within any single config, deny always wins. |\n\nThe global config enforces a floor the team can't accidentally loosen. Local configs can only add *more* restrictions, never fewer.\n\n### Argument policies\n\n`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.\n\n```typescript\n// heimdall.config.ts\nexport default {\n  servers: {\n    filesystem: {\n      tools: { allow: ['read_file', 'list_directory'] },\n      toolPolicies: {\n        // '*' applies to every tool (merged first; tool-specific entries override)\n        '*': {\n          args: {\n            path: { isPath: true, deny_pattern: ['\\\\.env$', '\\\\.pem$'] },\n          },\n        },\n        read_file: {\n          args: {\n            // scope path to the current working directory\n            path: { isPath: true, allow_pattern: './' },\n            // allow only safe encodings\n            encoding: { allow_pattern: ['utf-8', 'utf8', 'ascii'] },\n          },\n        },\n      },\n    },\n  },\n} satisfies HeimdallConfig;\n```\n\n#### `ArgConstraint` fields\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `isPath` | `boolean` | `false` | Enables path-aware matching (containment check) instead of regex |\n| `allow_pattern` | `string \\| string[]` | — | Arg must match at least one pattern to pass |\n| `deny_pattern` | `string \\| string[]` | — | Arg is blocked if it matches any pattern; deny wins over allow |\n| `array_mode` | `'all' \\| 'any'` | `'all'` | For array-typed args: require all items to pass (`all`) or at least one (`any`) |\n| `case_sensitive` | `boolean` | `true` | Regex flag; not applied to path-root matching |\n| `warn_only` | `boolean` | `false` | Record the violation in the OTel span without blocking the call |\n\n#### Path scoping with `isPath: true`\n\nWhen `isPath: true`, patterns that look like directory roots are treated as containment checks rather than regex expressions:\n\n| Pattern | Meaning |\n|---|---|\n| `\"./\"` or `\".\"` | Arg must resolve within `process.cwd()` |\n| `\"/some/dir\"` | Arg must resolve within `/some/dir` |\n| `\"~\"` / `\"${HOME}/projects\"` | Resolved to homedir |\n| `\"${CWD}/data\"` | Resolved to cwd + `/data` |\n\nThe 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.\n\n#### `warn_only` mode\n\nUseful 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:\n\n```\npolicy.arg_warning = true\npolicy.arg_warning_field = \"path\"\npolicy.arg_warning_message = \"Tool arg 'path' is denied by policy\"\n```\n\nSwitch to `warn_only: false` (the default) when you're ready to enforce.\n\n#### Dot-notation for nested args\n\nUse dot notation to constrain fields inside nested parameter objects:\n\n```typescript\ntoolPolicies: {\n  my_tool: {\n    args: {\n      'options.target': { isPath: true, allow_pattern: './' },\n    },\n  },\n}\n```\n\n---\n\n### What happens when a call is blocked\n\nThe blocked call never reaches the real server:\n\n```\n[TelemetryInterceptor] → [PolicyInterceptor] → [ForwardInterceptor]\n                                ↑\n                         blocks here, returns JSON-RPC error\n```\n\nThe client receives:\n\n```json\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 42,\n  \"error\": {\n    \"code\": -32001,\n    \"message\": \"Tool 'write_file' is not permitted by policy\"\n  }\n}\n```\n\nThe 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.\n\n`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.\n\n### Server name\n\nPolicy entries are keyed by server name. Use `--server-name` to set it explicitly in your MCP config:\n\n```json\n{\n  \"mcpServers\": {\n    \"filesystem\": {\n      \"command\": \"heimdall-mcp\",\n      \"args\": [\n        \"start\",\n        \"--store\", \"sqlite://~/.heimdall/traces.db\",\n        \"--server-name\", \"filesystem\",\n        \"--\",\n        \"npx\", \"@modelcontextprotocol/server-filesystem\", \"/home/user/projects\"\n      ]\n    }\n  }\n}\n```\n\n`--server-name` overrides the name from the server's `initialize` response — for both policy lookup and the `mcp.server.name` OTel attribute.\n\n### Policy config in library mode\n\n```typescript\nimport { ProxyBuilder } from '@cardor/heimdall-mcp';\nimport type { HeimdallConfig } from '@cardor/heimdall-mcp';\n\nconst policy: HeimdallConfig = {\n  servers: {\n    filesystem: {\n      tools: { deny: ['write_file', 'delete_file'] },\n    },\n  },\n};\n\nconst proxy = await ProxyBuilder.create()\n  .inbound({ transport: 'stdio' })\n  .outbound({ transport: 'stdio', command: 'npx', args: ['@modelcontextprotocol/server-filesystem', '/tmp'] })\n  .store('sqlite://./traces.db')\n  .config(policy)           // attach policy\n  .serverName('filesystem') // match config key\n  .build();\n\nawait proxy.start();\n```\n\n---\n\n## Usage modes\n\n### Mode 1 — CLI wrapping a subprocess (stdio)\n\nThe MCP client thinks it is talking to `heimdall-mcp`. The proxy spawns the real server as a child process and forwards all messages.\n\n**`mcp.json` / Claude Desktop configuration:**\n\n```json\n{\n  \"mcpServers\": {\n    \"my-server\": {\n      \"command\": \"heimdall-mcp\",\n      \"args\": [\n        \"--store\", \"sqlite://~/.mcp-traces/traces.db\",\n        \"--\", \"node\", \"my-server.js\"\n      ]\n    }\n  }\n}\n```\n\nThe `--` separator divides heimdall-mcp flags from the real server command. Everything after it is executed as a subprocess.\n\n**With a globally installed server:**\n\n```json\n{\n  \"mcpServers\": {\n    \"filesystem\": {\n      \"command\": \"heimdall-mcp\",\n      \"args\": [\n        \"--store\", \"sqlite://~/.mcp-traces/traces.db\",\n        \"--\", \"npx\", \"@modelcontextprotocol/server-filesystem\", \"/tmp\"\n      ]\n    }\n  }\n}\n```\n\n**With Postgres instead of SQLite:**\n\n```json\n{\n  \"mcpServers\": {\n    \"my-server\": {\n      \"command\": \"heimdall-mcp\",\n      \"args\": [\n        \"--store\", \"postgres://user:pass@localhost:5432/traces\",\n        \"--\", \"node\", \"my-server.js\"\n      ]\n    }\n  }\n}\n```\n\n---\n\n### Mode 2 — CLI wrapping a remote HTTP server\n\nWhen the MCP server is already running and exposes an HTTP endpoint.\n\n```json\n{\n  \"mcpServers\": {\n    \"remote-server\": {\n      \"command\": \"heimdall-mcp\",\n      \"args\": [\n        \"--store\",  \"sqlite://~/.mcp-traces/traces.db\",\n        \"--out\",    \"http\",\n        \"--target\", \"http://localhost:3001\"\n      ]\n    }\n  }\n}\n```\n\nThe proxy exposes **stdio** to the client and forwards each message as an HTTP `POST` to the target URL.\n\n---\n\n### Mode 3 — CLI wrapping a remote SSE server\n\nFor servers that use Server-Sent Events.\n\n```json\n{\n  \"mcpServers\": {\n    \"sse-server\": {\n      \"command\": \"heimdall-mcp\",\n      \"args\": [\n        \"--store\",  \"postgres://user:pass@host/db\",\n        \"--out\",    \"sse\",\n        \"--target\", \"http://remote.example.com\"\n      ]\n    }\n  }\n}\n```\n\nThe proxy connects to `{target}/sse` to receive responses and sends requests as `POST` to `{target}`.\n\n---\n\n### Mode 4 — Library for developers\n\nWhen you have access to the source code and want to integrate the proxy programmatically.\n\n**Minimal setup:**\n\n```ts\nimport { ProxyBuilder } from '@cardor/heimdall-mcp'\n\nconst proxy = await ProxyBuilder.create()\n  .inbound({ transport: 'stdio' })\n  .outbound({ transport: 'stdio', command: 'node', args: ['my-server.js'] })\n  .store('sqlite://./traces.db')\n  .build()\n\nawait proxy.start()\n\n// clean shutdown\nprocess.on('SIGINT', () =\u003e proxy.stop())\n```\n\n**stdio → remote HTTP:**\n\n```ts\nconst proxy = await ProxyBuilder.create()\n  .inbound({ transport: 'stdio' })\n  .outbound({ transport: 'http', url: 'http://localhost:3001' })\n  .store('postgres://user:pass@localhost/traces')\n  .build()\n\nawait proxy.start()\n```\n\n**HTTP inbound (proxy listens on a port):**\n\n```ts\nconst proxy = await ProxyBuilder.create()\n  .inbound({ transport: 'http', port: 8080 })\n  .outbound({ transport: 'stdio', command: 'node', args: ['server.js'] })\n  .store('mysql://user:pass@localhost/traces')\n  .build()\n\nawait proxy.start()\n```\n\n**With OTLP export and debug logging:**\n\n```ts\nconst proxy = await ProxyBuilder.create()\n  .inbound({ transport: 'stdio' })\n  .outbound({ transport: 'stdio', command: 'node', args: ['my-server.js'] })\n  .store('sqlite://./traces.db')\n  .otlp('http://localhost:4318/v1/traces')   // export to Jaeger / Tempo / Grafana\n  .setDebug(true)                             // verbose span logs to stderr\n  .build()\n\nawait proxy.start()\n```\n\n**With a custom interceptor:**\n\n```ts\nimport type { Interceptor, InterceptorContext, JsonRpcMessage } from '@cardor/heimdall-mcp'\n\nclass LogAllInterceptor implements Interceptor {\n  name = 'LogAllInterceptor'\n\n  async intercept(\n    request: JsonRpcMessage,\n    context: InterceptorContext,\n    next: () =\u003e Promise\u003cJsonRpcMessage\u003e\n  ): Promise\u003cJsonRpcMessage\u003e {\n    console.log('→', request.method, request.id)\n    const response = await next()\n    console.log('←', response.id, response.error ? 'ERROR' : 'OK')\n    return response\n  }\n}\n\nconst proxy = await ProxyBuilder.create()\n  .inbound({ transport: 'stdio' })\n  .outbound({ transport: 'stdio', command: 'node', args: ['server.js'] })\n  .store('sqlite://./traces.db')\n  .build()\n\nproxy.addInterceptor(new LogAllInterceptor())\nawait proxy.start()\n```\n\n---\n\n## Stores\n\n### SQLite\n\nNo external server required — ideal for local development.\n\n**Valid connection strings:**\n\n```\nsqlite://./traces.db\nsqlite://~/.mcp-traces/traces.db\nsqlite:///absolute/path/traces.db\n```\n\nDriver: [`@libsql/client`](https://github.com/tursodatabase/libsql-client-ts) — pure WASM, no native compilation required.\n\n**Schema:**\n\n```\nheimdall_spans\n  span_id               TEXT     PRIMARY KEY\n  trace_id              TEXT     NOT NULL\n  name                  TEXT     NOT NULL      → \"mcp.tool.call\", \"mcp.initialize\", etc.\n  kind                  INTEGER               → OTel SpanKind: 0=INTERNAL, 1=SERVER, 2=CLIENT, 3=PRODUCER, 4=CONSUMER\n  status                INTEGER  NOT NULL     → 0=UNSET, 1=OK, 2=ERROR\n  status_message        TEXT\n  start_time_unix_nano  INTEGER  NOT NULL     → Unix nanoseconds (OTel native)\n  end_time_unix_nano    INTEGER  NOT NULL\n  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.\n  events                TEXT/JSON             → OTel events array (e.g. error events)\n  links                 TEXT/JSON             → OTel links array\n  resource_attributes   TEXT/JSON             → service.name, service.version, service.namespace (OTel semantic conventions)\n  created_at            TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n\nheimdall_metrics\n  id           INTEGER  PRIMARY KEY AUTOINCREMENT\n  tool_name    TEXT     NOT NULL\n  call_count   INTEGER  DEFAULT 0\n  error_count  INTEGER  DEFAULT 0\n  avg_duration INTEGER\n  updated_at   TEXT     NOT NULL\n```\n\n\u003e **Note:** SQLite uses `INTEGER` for nanosecond timestamps because SQLite has no native `BIGINT` type — the integer affinity handles large values correctly.\n\n---\n\n### PostgreSQL\n\n```\npostgres://user:pass@localhost:5432/my_db\npostgresql://user:pass@localhost:5432/my_db\n```\n\nDriver: [`postgres`](https://github.com/porsager/postgres) — pure JS, no node-gyp.\n\n**Schema differences from SQLite:**\n- `start_time_unix_nano` / `end_time_unix_nano` → `BIGINT` (native 64-bit, exact for nanoseconds)\n- `attributes` / `events` / `links` / `resource_attributes` → `JSONB` (indexable, queryable)\n- `avg_duration` → `REAL`\n- `updated_at` → `TIMESTAMP`\n- `created_at` → `TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP`\n\n---\n\n### MySQL\n\n```\nmysql://user:pass@localhost:3306/my_db\n```\n\nDriver: [`mysql2`](https://github.com/sidorares/node-mysql2).\n\n**Schema differences from SQLite:**\n- `span_id` / `trace_id` / `name` → `VARCHAR(64/512)` (explicit lengths)\n- `start_time_unix_nano` / `end_time_unix_nano` → `BIGINT` (native 64-bit, exact for nanoseconds)\n- `attributes` / `events` / `links` / `resource_attributes` → `JSON`\n- `avg_duration` → `FLOAT`\n- `id` in metrics → `BIGINT UNSIGNED AUTO_INCREMENT`\n- `updated_at` → `TIMESTAMP(3)` (millisecond precision)\n\n---\n\n## What gets recorded\n\nEvery JSON-RPC message produces a span in the `heimdall_spans` table. All attributes follow the `mcp.*` namespace for interoperability with other MCP-aware tools.\n\n| MCP method       | Span name            | Key attributes                                                                                                        |\n|------------------|----------------------|-----------------------------------------------------------------------------------------------------------------------|\n| `initialize`     | `mcp.initialize`     | `mcp.jsonrpc.method`, `mcp.jsonrpc.id`, `mcp.trace.request_id`, `mcp.transport`, `mcp.status`, `duration.ms`         |\n| `tools/list`     | `mcp.tools.list`     | + `mcp.server.name`, `mcp.server.version`, `mcp.response_mode`, `mcp.response.body_hash`                             |\n| `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` |\n| `resources/read` | `mcp.resource.read`  | + `mcp.server.name`, `mcp.server.version`                                                                             |\n| `resources/list` | `mcp.resources.list` | + `mcp.server.name`, `mcp.server.version`                                                                             |\n| `prompts/get`    | `mcp.prompt.get`     | + `mcp.server.name`, `mcp.server.version`                                                                             |\n| `prompts/list`   | `mcp.prompts.list`   | + `mcp.server.name`, `mcp.server.version`                                                                             |\n| `shutdown`       | `mcp.shutdown`       | `mcp.jsonrpc.method`, `mcp.jsonrpc.id`, `mcp.trace.request_id`, `mcp.transport`, `mcp.status`, `duration.ms`         |\n| any other        | `mcp.{method}`       | `mcp.jsonrpc.method`, `mcp.jsonrpc.id`, `mcp.trace.request_id`, `mcp.transport`, `mcp.status`, `duration.ms`         |\n\n**Common attributes on every span:**\n\n| Attribute | Description |\n|---|---|\n| `mcp.rpc.system` | Always `\"mcp\"` |\n| `mcp.jsonrpc.method` | The JSON-RPC method name |\n| `mcp.jsonrpc.id` | JSON-RPC `id` from the frame, coerced to string. Empty for notifications |\n| `mcp.trace.request_id` | Proxy-generated per-request correlation ID (stable across numeric/string/null JSON-RPC IDs) |\n| `mcp.transport` | `stdio`, `http`, or `sse` |\n| `mcp.status` | `ok` · `error` · `timeout` · `cancelled` |\n| `mcp.server.name` | Name of the real MCP server (captured from `initialize` response) |\n| `mcp.server.version` | Version of the real MCP server (captured from `initialize` response) |\n| `duration.ms` | Total round-trip latency in milliseconds |\n\n**Latency breakdown (on `tools/call`):**\n\n| Attribute | Description |\n|---|---|\n| `mcp.latency.proxy_to_server_ms` | Time the real server took to respond |\n| `mcp.latency.proxy_overhead_ms` | Overhead introduced by the proxy itself |\n\n**Body capture modes:**\n\nBody capture is controlled by `--body-mode` (CLI) or `.setBodyMode()` (library). Default is `redacted`.\n\n| Mode | `mcp.tool.request` / `mcp.tool.response` | `mcp.request.body_hash` | `mcp.response.body_hash` |\n|---|---|---|---|\n| `redacted` (default) | — (omitted) | `sha256:\u003chex\u003e` (always) | `[redacted]` |\n| `hash` | — (omitted) | `sha256:\u003chex\u003e` (always) | `sha256:\u003chex\u003e` |\n| `full` | raw JSON | `sha256:\u003chex\u003e` (always) | `sha256:\u003chex\u003e` |\n\n`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.\n\n\u003e Use `full` only for local development — raw bodies in shared OTLP backends can leak secrets.\n\n**Agent correlation (optional):**\n\nIf the MCP client sends a `_meta` object inside `params`, heimdall-mcp will automatically extract and record these attributes:\n\n| `_meta` field | Span attribute |\n|---|---|\n| `conversationId` | `gen_ai.conversation.id` |\n| `turnId` | `gen_ai.turn.id` |\n| `agentRunId` | `gen_ai.agent.run.id` |\n\nAs a fallback, the env vars `MCP_CONVERSATION_ID`, `MCP_TURN_ID`, and `MCP_AGENT_RUN_ID` are used if set.\n\nOn 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.\n\nWhen 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.\n\nEvery span's `resource_attributes` column contains OTel resource metadata:\n- `service.name` — `@cardor/heimdall-mcp`\n- `service.version` — package version\n- `service.namespace` — `mcp-proxy`\n\nThe schema follows the [OpenTelemetry data model](https://opentelemetry.io/docs/concepts/signals/traces/) natively:\n- Timestamps stored as **Unix nanoseconds** (`BIGINT` in Postgres/MySQL, `INTEGER` in SQLite)\n- `kind` is an integer **SpanKind** (0=INTERNAL, 1=SERVER, 2=CLIENT, 3=PRODUCER, 4=CONSUMER)\n- `status` is an integer **SpanStatusCode** (0=UNSET, 1=OK, 2=ERROR)\n- JSON columns map directly to OTLP attribute bags\n\nThis means rows can be consumed directly by any OTel-compatible tool without transformation.\n\n---\n\n## Jaeger UI (OTLP)\n\nheimdall-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.\n\n![Jaeger UI showing heimdall-mcp traces](assets/jaeger.png)\n\n### 1. Start Jaeger\n\n```bash\ndocker run -d \\\n  --name jaeger \\\n  -p 16686:16686 \\\n  -p 4318:4318 \\\n  jaegertracing/all-in-one:latest\n```\n\n### 2. Add `--otlp` to your config\n\n```json\n{\n  \"mcpServers\": {\n    \"my-server\": {\n      \"command\": \"heimdall-mcp\",\n      \"args\": [\n        \"--store\",  \"sqlite://~/.mcp-traces/traces.db\",\n        \"--otlp\",   \"http://localhost:4318/v1/traces\",\n        \"--\", \"node\", \"my-server.js\"\n      ]\n    }\n  }\n}\n```\n\nFor the HTTP/SSE variant (e.g. the setup used during development of this project):\n\n```json\n{\n  \"mcpServers\": {\n    \"my-server\": {\n      \"command\": \"sh\", \"-c\",\n      \"args\": [\n        \"heimdall-mcp --store postgresql://user:pass@localhost:5432/db --out http --target http://localhost:3000/mcp --otlp http://localhost:4318/v1/traces\"\n      ]\n    }\n  }\n}\n```\n\n### 3. Open Jaeger UI\n\n```\nhttp://localhost:16686\n```\n\nSelect 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.\n\n**Dark mode** — append `?uiConfig={\"theme\":\"dark\"}` to the URL, or mount a config file:\n\n```bash\necho '{\"uiConfig\":{\"theme\":\"dark\"}}' \u003e jaeger-ui.json\n\ndocker rm -f jaeger \u0026\u0026 docker run -d \\\n  --name jaeger \\\n  -p 16686:16686 \\\n  -p 4318:4318 \\\n  -v $(pwd)/jaeger-ui.json:/etc/jaeger/ui-config.json \\\n  -e JAEGER_UI_CONFIG_FILE=/etc/jaeger/ui-config.json \\\n  jaegertracing/all-in-one:latest\n```\n\n\u003e The `--otlp` flag is additive — spans are saved to the database **and** exported to Jaeger at the same time.\n\n---\n\n## Custom interceptors\n\nThe `Interceptor` interface is public. You can add your own logic into the pipeline before the telemetry interceptor:\n\n```ts\ninterface Interceptor {\n  name: string\n  intercept(\n    request: JsonRpcMessage,\n    context: InterceptorContext,\n    next: () =\u003e Promise\u003cJsonRpcMessage\u003e\n  ): Promise\u003cJsonRpcMessage\u003e\n}\n\ninterface InterceptorContext {\n  startedAt: Date\n  traceId: string\n  spanId: string\n  bodyMode: 'redacted' | 'hash' | 'full'\n  transport: 'stdio' | 'http' | 'sse'\n  serverInfo: { name?: string; version?: string }  // populated after initialize\n  conversationId?: string                           // from _meta or env var\n  turnId?: string\n  agentRunId?: string\n  metadata: Record\u003cstring, unknown\u003e                 // shared bag between interceptors\n}\n```\n\nCalling `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.\n\nYou can use `context.metadata` to pass data between your interceptor and others in the same pipeline run.\n\n---\n\n## CLI reference\n\n### start\n\nThe default command. Starts the proxy.\n\n```\nheimdall-mcp start [options] [-- command [args...]]\nheimdall-mcp [options] [-- command [args...]]   # \"start\" is optional\n\nOptions:\n  --store \u003curl\u003e           Store connection string (required)\n                            sqlite://./traces.db\n                            postgres://user:pass@host/db\n                            mysql://user:pass@host/db\n\n  --out \u003ctransport\u003e       Transport to the real server (default: stdio)\n                            stdio | http | sse\n\n  --target \u003curl\u003e          Server URL when --out is http or sse\n\n  --in \u003ctransport\u003e        Inbound transport (default: stdio)\n                            stdio | http | sse\n\n  --in-port \u003cport\u003e        Port for --in http or --in sse\n\n  --otlp \u003curl\u003e            Export spans to an OTLP HTTP endpoint (e.g. Jaeger, Tempo)\n                            Additive — spans are also saved to the store\n                            Example: http://localhost:4318/v1/traces\n\n  --body-mode \u003cmode\u003e      Body capture mode for tool args and responses (default: redacted)\n                            redacted  → stores [redacted] + size (safe for production)\n                            hash      → stores sha256:\u003chex\u003e + size (safe for production)\n                            full      → stores raw JSON (local/dev only — may leak secrets)\n\n  --server-name \u003cname\u003e    Override server name for policy lookup and mcp.server.name OTel attribute.\n                            If not provided, falls back to serverInfo.name from the initialize response.\n\n  --out-port \u003cport\u003e       Port for outbound http or sse transport\n\n  --debug                 Write verbose logs to stderr (prints span names + trace IDs to stderr)\n\n  -V, --version           Print version\n  -h, --help              Print this help\n\n  --                      Separates proxy flags from the subprocess command\n                          (required when --out is stdio)\n\nExamples:\n  # stdio proxy → subprocess\n  heimdall-mcp --store sqlite://./t.db -- node server.js\n\n  # stdio proxy with policy enforcement\n  heimdall-mcp --store sqlite://./t.db --server-name filesystem -- npx @modelcontextprotocol/server-filesystem /tmp\n\n  # stdio proxy → remote HTTP server\n  heimdall-mcp --store sqlite://./t.db --out http --target http://localhost:3001\n\n  # stdio proxy → remote SSE server with Postgres\n  heimdall-mcp --store postgres://user:pass@host/db --out sse --target http://remote.com\n\n  # with OTLP export to Jaeger\n  heimdall-mcp --store sqlite://./t.db --otlp http://localhost:4318/v1/traces -- node server.js\n```\n\n`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.\n\n---\n\n### init\n\nScaffolds a `heimdall.config.ts` with commented examples.\n\n```\nheimdall-mcp init [options]\n\nOptions:\n  --global          Write to ~/.config/heimdall/ instead of the current directory\n  --format \u003cfmt\u003e    File format: ts | js | json (default: ts)\n  --force           Overwrite an existing config file\n```\n\n```bash\n# Create local config in current directory\nheimdall-mcp init\n\n# Create global config (applies to all projects)\nheimdall-mcp init --global\n\n# Create as JSON\nheimdall-mcp init --format json\n```\n\n---\n\n### health\n\nValidates the merged policy config and reports per-server policies. Does not connect to any MCP server.\n\n```\nheimdall-mcp health [options]\n\nOptions:\n  --config \u003cpath\u003e   Path to a specific config file (skips auto-discovery)\n```\n\nExample output:\n\n```\nConfig files loaded:\n  global: ~/.config/heimdall/heimdall.config.ts\n  local:  ./heimdall.config.ts\n\nDefault policy:\n    tools: allow=[*] deny=[none]\n\nServer policies:\n  filesystem:\n      tools: allow=[read_file, list_directory, search_files] deny=[write_file, create_file, delete_file, move_file]\n  database:\n      tools: allow=[query, describe_table, list_tables] deny=[execute, drop_table, truncate]\n\nNo conflicts detected.\n```\n\nIf the same entity appears in both `allow` and `deny`, `health` exits with code 1 and lists all conflicts.\n\n---\n\n## Tracing reference\n\nThe 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).\n\n---\n\n## Roadmap\n\n| Phase | Feature | Status |\n|---|---|---|\n| 1 | Auto-discovered `heimdall.config.{ts,js,json}` — local + global, allow/deny lists per server | ✅ Done (v1.2) |\n| 2 | `PolicyInterceptor` — blocked calls return JSON-RPC error -32001, recorded in OTel with `policy.blocked = true` | ✅ Done (v1.2) |\n| 3 | `heimdall-mcp init` + `heimdall-mcp health` CLI commands | ✅ Done (v1.2) |\n| 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) |\n| 5 | Filter by action type (`read` / `write` / `execute`) inferred from tool name or MCP metadata | 📋 Planned |\n| 6 | Runtime enforcement modes: `warn` (log + forward), `audit` (span with `policy.violation = true`) | 📋 Planned |\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fenmanuelmag%2Fheimdall-mcp","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fenmanuelmag%2Fheimdall-mcp","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fenmanuelmag%2Fheimdall-mcp/lists"}