MCP (Model Context Protocol) is how AI agents connect to tools. Claude Desktop uses it, Cursor uses it, and thousands of developers are building MCP servers to give AI access to their APIs, databases, and infrastructure.
There's one problem: MCP has no security model.
The protocol defines how a client talks to a server, but says nothing about what that server is allowed to do. No authentication between client and server. No authorization on which tools can be called. No audit trail of what happened. The spec assumes you'll handle all of that yourself.
Most people don't.
What Actually Goes Wrong
I run a self-hosted server with Prometheus, Grafana, Ollama, Gitea, and a handful of other services. I wanted Claude Desktop to query all of them through MCP. The standard approach is to write a Python FastMCP server for each one — a few dozen lines per service, hardcode the API key, register the tools, done.
That works until you think about what you've actually built:
Every MCP server has full access to whatever its process can reach. Your Prometheus tool can also hit your Grafana API, your Gitea API, and anything else on localhost. There's no scoping.
API keys live in environment variables or config files. If you have 9 MCP servers, you have 9 places where credentials sit in plaintext with no access policy.
Nothing is logged. If Claude calls a tool that restarts a service or deletes data, there's no record of which tool was called, with what parameters, by which agent, at what time.
There's no concept of read-only vs. write. A tool either exists or it doesn't. MCP doesn't know that query_prometheus is safe to call freely but restart_service should require approval.
Tool composition creates emergent risks. When Claude has access to multiple MCP servers, it can chain calls across them. Server A reads sensitive data, Server B posts to an external API — Claude could combine them in ways neither server was designed for.
These aren't theoretical risks. During development, I declared an agent as read-only (Trust Tier 1) but gave it a tool that used HTTP POST. The system I built caught it — blocked the call, logged a trust violation, and forced me to either fix the config or explicitly upgrade the trust level. Without that enforcement, the tool would have silently worked and I'd never have known my security model was wrong.
What I Built
Heddle is a runtime that sits between your YAML config and the MCP protocol. You define your tools in a config file, and Heddle validates, secures, and serves them — with policy enforcement on every call.
Here's a complete tool server for Prometheus:
agent:
name: prometheus-bridge
version: "1.0.0"
exposes:
- name: query_prometheus
access: read
description: "Run a PromQL query"
parameters:
query: { type: string, required: true }
- name: get_alerts
access: read
description: "List active Prometheus alerts"
http_bridge:
- tool_name: query_prometheus
method: GET
url: "http://localhost:9090/api/v1/query"
query_params: { query: query }
- tool_name: get_alerts
method: GET
url: "http://localhost:9090/api/v1/alerts"
runtime:
trust_tier: 1
Run heddle run agents/prometheus-bridge.yaml and Claude can query Prometheus in natural language. But every call goes through a six-layer dispatch pipeline before it reaches the API:
Rate limiting → Access mode check → Escalation rules → Trust tier enforcement → Input validation → HTTP bridge execution
Each layer can independently block the call and log why.
The Security Controls
The dispatch pipeline enforces these controls on every tool call:
Trust Tiers (T1—T4). Each config declares a trust level. T1 (observer) can only use GET — any POST/PUT/DELETE is blocked at runtime, not just warned. T2 (worker) allows scoped writes. T3 (operator) allows cross-agent invocation. T4 (privileged) requires human approval. I caught a real misconfiguration with this — a T1 agent tried to POST and the enforcer blocked it before the request ever left the process.
Access Mode Annotations. Every tool is declared as access: read or access: write. T1 configs with write tools are rejected at load time — before the server even starts. This is the schema-level version of least privilege.
Credential Broker. API keys are stored in ~/.heddle/secrets.json with per-config access policies. Configs reference them as {{secret:prometheus-token}} — resolved at runtime, never written to the YAML file. A config can only access secrets it's been explicitly granted. Unauthorized access is denied, logged, and the call aborts before any HTTP request is constructed — fail-closed. (It didn't start out that way: the launch version handed back a placeholder string and let the request proceed anyway. An external security review caught it. More on that below.)
Escalation Rules. Declarative conditions that hold a tool call for review instead of executing it. For example, my VRAM orchestrator has a rule that holds any smart_load call if the model name contains "27b" — because loading a 27-billion parameter model consumes most of my 24GB GPU memory. The rule triggers, the call is held, and the audit log records why.
escalation_rules:
- name: large-model-load
reason: "Loading a model that will consume most of the 24GB VRAM"
tool: "smart_load"
param_contains:
model_name: "27b"
Input Validation. Type checking, length limits, and injection pattern detection on every parameter. The validator catches shell injection (; rm -rf /), SQL injection (' OR 1=1), path traversal (../../etc/passwd), and LLM prompt injection (ignore previous instructions). In strict mode, these are blocked. In permissive mode, they're logged and passed through.
Hash-Chained Audit Log. Every tool call, trust violation, credential access, and escalation hold is logged as a JSON Lines entry. Each entry includes a SHA-256 hash of the previous entry — if anyone modifies or deletes a log entry, the chain breaks and verification fails.
Config Signing. All YAML configs are signed with HMAC-SHA256. If a config is modified after signing, the runtime detects the tampering. AI-generated configs (from Heddle's natural language generator) are automatically quarantined in a staging directory until explicitly promoted.
What It Looks Like Running
I'm currently running 46 tools from 9 active configs through a single MCP connection to Claude Desktop (11 configs total; two are excluded for incompatible transports). The configs cover Prometheus, Grafana, Ollama, Gitea, an RSS aggregator, a RAG search API, a multi-model AI platform bridge, a GPU VRAM orchestrator, and a daily operations briefing agent.
Every one of those 46 tools goes through the same dispatch pipeline. The Prometheus tools are T1 (read-only, five tools). The Ollama bridge is T2 (can POST for text generation). The VRAM orchestrator is T3 (can invoke other agents, has escalation rules on destructive operations).
The trust tiers aren't just labels — they're enforced. A T1 config physically cannot make a POST request, even if the HTTP bridge URL is correct and the API would accept it. The enforcer blocks it before the request is constructed.
Then I Invited the Audit
Shipping a security tool comes with an obligation: you have to let people try to break it. After v0.2.0 I handed the repo to two independent AI-assisted security reviews — different frontier models, no shared context — plus a manual pass of my own, and asked for the worst.
They delivered. Four findings, all real:
The most-used path bypassed the pipeline. The custom stdio handlers — the exact transport Claude Desktop uses — dispatched tool calls directly, skipping the six-layer pipeline entirely. The security model was airtight on the HTTP server and absent on the path that mattered most. Every execution path now routes through a single ToolPolicy.guard(), and 13 adversarial tests exist to prove the bypass stays closed.
Credential denial failed open. As above: a denied secret produced a placeholder and the request went out anyway. Against a permissive upstream, an unauthenticated call could silently succeed. CredentialDenied now aborts the call before a request object is even constructed.
Redaction had gaps a hash chain makes permanent. The audit redactor didn't recurse into nested structures, and it scrubbed URLs by token pattern instead of parsing query parameters by key. That matters more here than in an ordinary log: a hash-chained entry can't be scrubbed after the fact without breaking verify_chain(). A leaked secret would be tamper-evidently permanent. Redaction now recurses, and URLs are parsed structurally.
Handler codegen used exec(). HTTP-bridge handlers were generated as source strings and exec()'d. No config-influenced string reaches exec() anymore — handlers are real typed callables built with inspect.Signature.
All of it shipped as v0.2.1, an assurance release: no new features, every change closes a finding, and the test count went from 237 to 273 almost entirely on regression tests that pin these fixes down.
The uncomfortable part: the credential fail-open was described in the first draft of this very post as a feature. That's the strongest argument I know for external review — the author is the one person guaranteed to read the design intent instead of the behavior.
Framework Mapping
Every security control maps to at least one industry framework. (OWASP renumbered agentic risks in December 2025 — this table uses the current ASI identifiers from the Top 10 for Agentic Applications.) This matters if you're in an organization that needs to demonstrate compliance, or if you're building a portfolio that shows applied security architecture (which is why I built this):
| Control | OWASP Agentic (2026) | NIST AI RMF |
|---|---|---|
| Trust tiers | ASI03 Identity & Privilege Abuse | GV-1.3 |
| Credential broker | ASI03 Identity & Privilege Abuse | MAP-3.4 |
| Audit logging | ASI10 Rogue Agents | MS-2.6 |
| Input validation | ASI01 Agent Goal Hijack | MS-2.5 |
| Config signing | ASI04 Agentic Supply Chain | GV-6.1 |
| Escalation rules | ASI02 Tool Misuse & Exploitation | GV-1.3 |
The full threat model is in the repo at docs/threat-model.md.
Getting Started
git clone https://github.com/goweft/heddle.git
cd heddle
python -m venv venv && source venv/bin/activate
pip install -e ".[dev]"
# Try a starter pack
cp packs/prometheus.yaml agents/
heddle validate agents/prometheus.yaml
heddle run agents/prometheus.yaml --port 8200
Heddle ships with 6 starter packs — Prometheus, Grafana, Gitea/GitHub, Ollama, Sonarr, and Radarr — that you can drop into agents/ and run immediately. All read-only (T1) except Ollama (T2 for text generation).
Or generate a config from natural language:
heddle generate "agent that wraps the Home Assistant API" --model qwen3:14b
Works with Claude Desktop, Cursor, and any MCP client that supports stdio transport.
What's Next
The next release makes Heddle's own policy exceptions mortal. ADR 005 proposes expiry horizons that scale inversely with privilege (a T4 grant lives 30 days, not forever), credential grants bound to config signatures so a re-signed config invalidates its approvals, and revoke_when predicates that revoke a grant at dispatch time the moment its justifying assumption stops being true. The invariant: every exception carries exactly one collector — a date, a signature pin, a predicate, or a reconciliation pass. A promise nobody can collect on is not a control.
Heddle is open source (MIT) at github.com/goweft/heddle. 273 tests, 15 security controls, and a threat model mapped to the OWASP Top 10 for Agentic Applications (2026) and NIST AI RMF. If you're exposing APIs to AI agents, I'd like to know what security controls you wish existed.
Top comments (0)