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

I am a computer science engineer, I have also been a member of CERN’s Summer Students program in 2022. I am constantly learning and reviewing new web development technologies Frontend, Backend, DevOps
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
// 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:
{
"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:
{
"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
# 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
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,.jsonwithout pre-compilation (same loader Vite/Nuxt use) - Schema validated by
valibotat startup — descriptive errors for invalid configs PolicyInterceptorsits betweenTelemetryInterceptorandForwardInterceptor— blocked calls are recorded but never forwarded
Links
- GitHub: https://github.com/enmanuelmag/heimdall-mcp
- npm:
npm install -g @cardor/heimdall-mcp

