# You Shall Not Pass — Allow/Deny Policies for MCP Tools Are Now in Heimdall

## What shipped

Heimdall MCP is a transparent MCP proxy — and v1.2 adds the piece that was missing: a two-level policy config that lets you define exactly which tools, prompts, and resources each MCP server is allowed to expose to the agent.

No changes to the server. No custom middleware. A single config file next to your code.

**Website:** https://stack.cardor.dev/heimdall

## The config: two levels, one merge

| Scope  | Path | Purpose |
|--------|------|---------|
| Local  | `{project-root}/heimdall.config.{ts,js,json}` | Per-repo rules |
| Global | `~/.config/heimdall/heimdall.config.{ts,js,json}` | User/org-wide rules |

Both are optional. If neither exists, Heimdall stays fully transparent — backward compatible.

When both exist, they merge with **security-first semantics:**
- **Deny → union:** denied by either = denied. Global deny cannot be overridden locally.
- **Allow → intersection:** must pass both. Wildcard `*` means "defer to the other side."
- **Deny beats allow** within any single config.

This means your global config enforces a floor the team can't accidentally loosen. Local configs can only add *more* restrictions, never fewer.

## Config format

```typescript
// heimdall.config.ts
import type { HeimdallConfig } from '@cardor/heimdall-mcp';

export default {
  // default: applies to servers without an explicit entry
  default: {
    tools: { allow: ['*'], deny: [] },
  },

  // servers: keyed by --server-name flag (or serverInfo.name from initialize)
  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;
```

Full TypeScript with type checking. Also works as `.js`, `.mjs`, or `.json`.

## What happens when a tool is blocked

The call never reaches the real server:

```
[TelemetryInterceptor] → [PolicyInterceptor] → [ForwardInterceptor]
                                ↑
                         blocks here, returns JSON-RPC error
```

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`, `mcp.error.code = -32001`, and the tool name — full audit trail of what was attempted, blocked, and when.

## List responses are also filtered

`tools/list`, `prompts/list`, and `resources/list` responses are filtered before the client sees them — denied entries are removed. The agent doesn't even know a denied tool exists.

## Server name matching

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 `serverInfo.name` from the `initialize` response — for both policy lookup and the `mcp.server.name` OTel attribute. Consistent everywhere.

## Two new CLI commands

```bash
# Scaffold a local config with commented examples
heimdall-mcp init

# Scaffold a global config
heimdall-mcp init --global

# Validate config, show merged policy, detect allow+deny conflicts
heimdall-mcp health
```

`health` 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 name appears in both allow and deny, `health` exits 1 and lists all conflicts.

## Library API

```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();
```

## Implementation notes

- Config loaded via `jiti` — supports `.ts`, `.js`, `.mjs`, `.cjs`, `.json` without pre-compilation (same loader Vite/Nuxt use)
- Schema validated by `valibot` at startup — descriptive errors for invalid configs
- `PolicyInterceptor` sits between `TelemetryInterceptor` and `ForwardInterceptor` — blocked calls are recorded but never forwarded

## Links

- **GitHub:** https://github.com/enmanuelmag/heimdall-mcp
- **npm:** `npm install -g @cardor/heimdall-mcp`
