Skip to main content

Bouncer

MCP Badge CI License: MIT Python 3.12+ No LLM in the decision path

The only tool that asks whether this destination should receive this value — not whether this tool is allowed to run.

Bouncer allows an email to a trusted teammate but denies the same tool sending to an attacker address that only appeared in a poisoned inbox — decided by provenance, no LLM

Same tool, opposite verdicts — decided only by where the address came from. Reproduce it yourself: uv run python scripts/demo.py (no API key).

Bouncer is a local stdio MCP proxy. It sits between your MCP client (Claude Code, Cursor, or any MCP-speaking agent) and your existing tool servers, re-exports every tool 1:1, and deterministically enforces contracts on each tool call — provenance/taint rules, per-argument constraints, and call budgets. Violations are blocked or escalated to a human; nothing is scored by a classifier. There is no LLM in the enforcement path — the whole decision is plain Python running against a recorded schema, a policy, and a taint log.

Status: v1, early. Deterministic core is 82 tests green and the proxy has been exercised live, end-to-end, against the reference filesystem MCP server (see docs/manual-smoke.md). It is MCP-only — read Documented limits before you rely on it.

How it decides

Every tool call runs the same deterministic gauntlet — no model, no scoring. The sink gate is the novel part: it is deny-unless-trusted on the destination, keyed to where the value came from.

flowchart TD
    call[Tool call from agent] --> pin{Known / pinned tool?}
    pin -- no --> ask[["ASK a human"]]
    pin -- yes --> bud{Within call budget?}
    bud -- no --> deny[["DENY (never forwarded)"]]
    bud -- yes --> con{Arg constraints pass?}
    con -- no --> deny
    con -- yes --> sink{Exfiltrating tool?}
    sink -- no --> allow[["ALLOW → forward upstream"]]
    sink -- yes --> trust{Where did the destination come from?}
    trust -- "tainted (untrusted tool output)" --> deny
    trust -- "unproven" --> ask
    trust -- "trusted (pack allowlist or remembered approval)" --> allow

The three verdicts

Every tool call resolves to exactly one of:

  • allow — forwarded to the upstream server unchanged.
  • deny — never forwarded. The client gets [bouncer blocked] <reason>.
  • ask — the proxy asks the human via MCP elicitation for a one-time approval of this specific destination. If the client doesn't support elicitation, or the human declines, ask fails closed to deny. An approval is remembered for the rest of the session, so a benign read-then-reply flow only asks once per destination.

A deny verdict never calls into the upstream server — a blocked call has no side effects (bouncer/src/bouncer/proxy.py's route_call / _route_async; enforced by tests/test_proxy.py).

What it enforces

Four deterministic contract types, checked in this order (ContractEngine._decide, bouncer/src/bouncer/engine.py):

  1. Schema pinning (rug-pull guard). Every upstream tool is recorded at startup. A tool call for a name that was not pinned at startup (e.g. a tool the server added mid-session) is unknown — ask, which fails closed to deny since a pinning ask has no destination a human can vouch for. (Detecting a changed schema for an already-pinned name is a documented limit, not implemented in v1.)
  2. Call budgets. max_calls per tool per session. Budgets count attempts, not successes — the counter increments on every call that reaches the budget check, including one later denied by a different contract. This is a deliberate fail-safe choice: an attempted destructive call is a real attempt, and it keeps a denied call from being retried for free. See the live-verified walkthrough in docs/manual-smoke.md for exactly how this plays out (a constraint-denied write still consumes a budget slot).
  3. Per-argument constraints. Path-prefix confinement (normalized, traversal-safe — ./.. are collapsed before comparison, so ../../etc/passwd can't escape an allowed prefix) and regex/allowlist matching on named arguments.
  4. Sink gate — deny-unless-trusted (the headline contract). For tools marked exfiltrating, every declared sink_params argument (recipient, channel, url, share-target, …) must resolve to a provenance-trusted destination: a pack/YAML allowlist entry, or a remembered human approval. An exfiltrating tool with no declared sink args treats every argument as a sink (fail-closed — a forgotten declaration can't leave a hole).
    • A destination that traces back to untrusted output of a tool on the same wrapped server (an email body, a document's contents, …) is tainteddeny. (Taint is per-server — see documented limits.)
    • A destination that is neither trusted nor traceably tainted is unprovenask.
    • List-valued sinks (e.g. multiple cc recipients) are classified element-by-element, so one tainted address hidden in a list of otherwise fine ones still denies the whole call.
    • This is a value-level taint check (bouncer/src/bouncer/taint.py): normalize case/whitespace, then substring-match against everything the session has seen returned from an upstream tool. It decides deny vs. ask, never deny vs. silent allow — a missed match degrades to a human check, not a leak.

Layered policy

Policy for a tool is resolved in this order, first match wins (bouncer/src/bouncer/policy.py):

  1. User YAML (bouncer run --policy your.yaml) — your own overrides, allowlists, budgets, path prefixes.
  2. Curated packs (bouncer/src/bouncer/packs/*.yaml) — small, named-tool policies for popular servers: filesystem, gmail, slack, github, gdrive.
  3. Schema heuristics (bouncer/src/bouncer/heuristics.py) — for any tool with no user or pack entry, a conservative fallback that flags destination-shaped params (to, cc, url, channel, …) as sinks and delete/remove-verb tools with a default call budget.

A malformed policy file (top-level YAML that isn't a tool-name mapping, or an invalid regex in arg_patterns) fails closed: load_policies / _policy_from_dict raise PolicyError rather than silently ignoring the file.

A user entry replaces the pack entry for that tool — it does not merge. If you override send_email to add a trusted_destinations allowlist, you must also restate exfiltrating: true and its sink_params, or you silently turn the sink gate off for that tool. Copy the tool's block from the pack (bouncer/src/bouncer/packs/*.yaml) and add to it. See examples/bouncer.yaml.

MCP Tools (Skills)

Bouncer exposes native MCP tools that allow AI agents and developers to inspect security postures, evaluate hypothetical tool calls, and validate policy contracts:

Tool Description Key Arguments
bouncer_check_verdict Evaluates a hypothetical tool call against deterministic contracts and returns the verdict (allow, deny, or ask) without executing it. tool (string, required), args (object), untrusted_context (string[]), user_policy_yaml (string)
bouncer_verify_policy Validates a declarative YAML contract policy against Bouncer's schema, reporting syntax validity and covered tool rules. policy_yaml (string, required)
bouncer_get_active_policies Lists active security packs (filesystem, email, bash, http) and user policies currently enforced. pack_name (string, optional)
bouncer_audit_summary Returns a summary and recent log entries of security decisions from the local audit trail (~/.bouncer/audit.jsonl). limit (integer, default 20)

MCP Prompts

Interactive prompt templates for security review and policy authoring:

  • review_mcp_security: Guided evaluation of an MCP server's exposed tools to uncover dangerous data exfiltration sinks, unsanitized parameters, and excessive privileges.
  • generate_bouncer_policy: Interactive assistant that generates a hardened, declarative policy.yaml tailored to your MCP servers and desired risk tolerance (standard, strict, paranoid).

MCP Resources

Direct access to Bouncer's security state and configuration:

  • bouncer://policies/builtin (application/json): Catalog of active built-in security packs and rule schemas for filesystem, email, bash, and http tools.
  • bouncer://audit/summary (application/json): Aggregated audit metrics and recent enforcement decisions from the local audit log.

Install and use

Installation

# Using pip from GitHub
pip install git+https://github.com/Ezed9/mcp-bouncer.git

# Or using uv
uv tool install git+https://github.com/Ezed9/mcp-bouncer.git

# Or from local source
git clone https://github.com/Ezed9/mcp-bouncer.git
cd mcp-bouncer
pip install -e .

1. Standalone MCP Server Mode

You can run Bouncer directly as an MCP server in Claude Desktop, Cursor, or LobeHub to access its policy validation and security auditing tools:

{
  "mcpServers": {
    "bouncer": {
      "command": "bouncer",
      "args": ["serve"]
    }
  }
}

2. Proxy Mode (Wrapping Existing MCP Servers)

# Point bouncer at your existing MCP client config (Claude Code, Cursor, ...)
# and wrap the servers you want gated:
bouncer init --config path/to/mcp-config.json

# bouncer init rewrites each server entry in place: the original launch
# command is stashed under an `x-bouncer-upstream` sentinel, and the entry is
# repointed at `bouncer run`. Re-running init is a no-op (idempotent) once a
# server is already wrapped. Nothing else in your config changes.

The wrapped entry looks like:

"filesystem": {
  "command": "bouncer",
  "args": ["run", "--config", "/abs/path/mcp-config.json",
           "--upstream-name", "filesystem"],
  "x-bouncer-upstream": {
    "command": "npx",
    "args": ["-y", "@modelcontextprotocol/server-filesystem", "/abs/path"]
  }
}

Your MCP client now launches bouncer run, which relaunches the real upstream server as a subprocess and gates every call between them. To layer your own contracts on top of the packs, add --policy:

bouncer run --config mcp-config.json --upstream-name filesystem \
  --policy bouncer.yaml

See examples/bouncer.yaml for a starter policy (sink allowlist, path confinement, a call budget).

Audit log

Every verdict — allow, deny, and ask — is appended as one JSON line to ~/.bouncer/audit.jsonl (bouncer/src/bouncer/audit.py):

{"tool": "write_file", "args": {"path": "/etc/x", "content": "nope"}, "verdict": "deny", "reason": "path='/etc/x' outside allowed prefixes ['./out']", "contract": "constraint"}

This is a real line captured during the manual smoke test — see docs/manual-smoke.md for the full run. When a human approves an ask, the same client call re-evaluates and immediately writes its allow line, so an approved send appears in the log as an ask line followed by an allow line for the same call.

Demo: allow, then deny, for real

This is real output, not a mockup. It's the exact transcript captured during the live smoke test against the official @modelcontextprotocol/server-filesystem reference server on 2026-07-06 (see docs/manual-smoke.md for the full run, environment, and audit log).

To reproduce on your machine, create a work dir with an MCP config and a user policy, wrap it, and drive it (the helper scripts under scripts/ are the exact ones used — point them at your paths):

cd bouncer && uv sync
mkdir -p smoke_work/out

# an MCP config that runs the reference filesystem server over your work dir:
cat > smoke_work/mcp-config.json <<'JSON'
{"mcpServers": {"filesystem": {"command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-filesystem", "<ABS>/smoke_work"]}}}
JSON

# a user policy: confine writes to ./out and cap write_file at 2 calls:
cat > smoke_work/user-policy.yaml <<'YAML'
write_file:
  write_params: [path]
  allowed_path_prefixes: ["<ABS>/smoke_work/out"]
  max_calls: 2
YAML

# replace <ABS> with the absolute path to bouncer/, then:
uv run bouncer init --config smoke_work/mcp-config.json
uv run python scripts/smoke_driver_policy.py   # drives it as a scripted client
cat ~/.bouncer/audit.jsonl

The smoke_work/ dir is intentionally not committed (its paths are machine-specific); create it as above.

Real per-call output from that run, with a user policy (allowed_path_prefixes: [".../smoke_work/out"], max_calls: 2 on write_file) layered over the filesystem pack:

--- b write_file to /etc: write_file({'path': '/etc/bouncer_should_not_write.txt', 'content': 'nope'}) ---
ERROR/DENY: [bouncer blocked] path='/etc/bouncer_should_not_write.txt' outside allowed prefixes ['.../smoke_work/out']

--- c write #1 into out ---  OK: Successfully wrote to .../out/note1.txt
--- c write #2 into out ---  ERROR/DENY: [bouncer blocked] call budget 2 for 'write_file' exceeded
--- c write #3 into out ---  ERROR/DENY: [bouncer blocked] call budget 2 for 'write_file' exceeded

Confirmed live: only note1.txt exists on disk afterward — the two budget-denied writes never reached the upstream server. The constraint denial is Bouncer's own contract firing (contract: constraint), not the upstream server's error.

The sink-gate (deny-unless-trusted) contract can't be demonstrated against the filesystem server — it exposes no send/share tool — so it's demonstrated by driving the ContractEngine directly against a real, recorded tainted output (also in docs/manual-smoke.md, and covered by tests/test_engine_sink_gate.py):

d1 send to trusted ok@corp.com:        ALLOW [default]
d2 send to tainted attacker@evil.com:  DENY  [sink_gate] destination to='attacker@evil.com' came from untrusted data
d3 send to unproven new@random.com:    ASK   [sink_gate] destination to='new@random.com' is not a vouched recipient

Want a recorded GIF/asciinema of this against Claude Code or Cursor? None exists yet — record your own with the commands above rather than trust a canned capture.

Documented limits

Where Bouncer is not the authority.

  • MCP-only scope. Bouncer governs MCP tool calls. An agent with raw shell access can curl data out around the proxy entirely — pair Bouncer with your client's own permission system for non-MCP tools.
  • Content egress, not content DLP. The sink gate protects destination integrity, not content confidentiality. Tainted content may still flow to a trusted destination (e.g. "email this doc to alice@corp.com" is allowed even if the doc's contents came from an untrusted source). If that's a concern for your use case, Bouncer is the wrong layer for it.
  • Doesn't vet malicious servers. Bouncer constrains a benign agent that's been hijacked by malicious data. It does not vet upstream MCP server behavior — pair it with mcp-scan or similar for that.
  • Doesn't make the agent's plan smart. Contracts bound what the agent may do, not whether its plan is a good idea. A denied call is safe, not necessarily corrected.
  • Taint is per wrapped server. Each bouncer run wraps one server with its own taint tracker, so a cross-server flow — read a poisoned file via one server, then send via another — surfaces as ask (unproven destination), not deny. Deny-unless-trusted still holds (nothing is silently allowed), but the hard deny only fires when the tainted data and the send go through the same wrapped server.
  • Path confinement is lexical. Prefix checks collapse ./.. but do not resolve symlinks, so a symlink placed inside an allowed prefix could point outside it. Don't rely on path confinement alone against an adversary who can create symlinks in the allowed directory.
  • No mid-session schema-change detection (v1). Schemas are pinned once at startup; a server that changes an already-pinned tool's schema mid-session is not re-checked. Only names absent from the startup snapshot are treated as unknown.
  • Sink params must be complete for your server. The gate inspects the sink_params you declare (plus a fail-closed sweep of all args when a tool's declared sinks are entirely absent from a call — the schema-mismatch guard). But if a tool exposes a destination-bearing argument you did not declare alongside one you did (e.g. you declare to and the server also accepts an undeclared recipients), the undeclared field is treated as content, not a destination, and a value placed there is not gated. Undeclared extra params can't be auto-classified as sink-vs-content without guessing, so enumerate every recipient/destination argument your actual server accepts in your pack. The curated packs aim to be complete for the servers they name; verify against your server's tools/list.

Benchmark

Bouncer is benchmarked against AgentDojo's v1 workspace suite under the important_instructions prompt-injection attack, scored by AgentDojo's own security scorer (did the injection actually reach the attacker's address?) — not a Bouncer-internal count — and compared against a no-Bouncer baseline so the number reflects defense, not a model too weak to attack.

In the first measured run (user_task_8, three exfil injection tasks, gemini-flash-lite-latest): attack success 0.33 → 0.00 with Bouncer, while user-task utility rose from 0.33 → 1.00 — blocking the injection kept the agent on its real task — and benign utility stayed 1.00 (no false positives). Read this as a proof of mechanism, not a headline statistic: only one of the three injections actually succeeded against the unprotected model (the other two the base model resisted on its own), so the aggregate rests on a small sample. The single case that isolates Bouncer's effect is clean on every axis, and benchmark/RESULTS.md gives the full per-case breakdown and the honest caveats, including what would strengthen it (more user tasks, a stronger attack model).

An earlier run caught a real bypass in Bouncer itself — the sink gate allowed an exfiltration because a pack's sink params were written for a different server's schema — which was then fixed fail-closed in the engine (commit 3d9c904) with regression tests. That is the benchmark doing its job. The headline metric was subsequently rebuilt (after two Opus code reviews) to use AgentDojo's per-injection security verdict, because the first metric could credit collateral-blocking the user's own task as an "attack block". RESULTS.md documents both, honestly.

Pre-registered kill criteria (from the design spec), checked against — not explained away — once numbers land:

If after approval-memory the benign suites still show ≥10% utility loss or a median of >3 asks per benign task, the deterministic-only thesis is wrong for this layer — stop, or pivot to a hybrid (lightweight ML screener) approach.

To reproduce (a free Gemini key, no credit card, suffices):

cd bouncer
GEMINI_API_KEY=... uv run --extra benchmark python -m benchmark.run_agentdojo

How it was built

v1, deterministic core only — no LLM anywhere in the decision path. 82 unit and integration tests (uv run pytest -q from bouncer/) cover the engine, policy resolution, taint tracking, approvals, audit log, packs, and the proxy's routing logic. The proxy has additionally been live-smoke-tested end-to-end against the real @modelcontextprotocol/server-filesystem reference server — see docs/manual-smoke.md for the full transcript, including two real bugs that were found and fixed during that exercise — and has an AgentDojo benchmark harness (see above). It is early: MCP-only, stdio-only, single-machine.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

bouncer_core-0.1.1.tar.gz (552.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

bouncer_core-0.1.1-py3-none-any.whl (22.2 kB view details)

Uploaded Python 3

File details

Details for the file bouncer_core-0.1.1.tar.gz.

File metadata

  • Download URL: bouncer_core-0.1.1.tar.gz
  • Upload date:
  • Size: 552.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for bouncer_core-0.1.1.tar.gz
Algorithm Hash digest
SHA256 cd55b552eab8ae0978398bde8877c391965c8a8020f7512a56680b432f13ab02
MD5 279489507839e9c363f7b7dc1ae4568a
BLAKE2b-256 6474282accdac09a925c77fa6a252ce4d3dede35629ea1413dd526f445f75283

See more details on using hashes here.

Provenance

The following attestation bundles were made for bouncer_core-0.1.1.tar.gz:

Publisher: release.yml on Ezed9/mcp-bouncer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file bouncer_core-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: bouncer_core-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 22.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for bouncer_core-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 b5bd85cd8b9890a6ee94c0ce9f53945156e351503a9b445fd1b97f94ff7c553e
MD5 a4269cf5e6959432e5b27b967f40a6da
BLAKE2b-256 9829fc2dcc3459a2341d2eaa491e689dd5240e36a88bb38c48e3129c8ff91281

See more details on using hashes here.

Provenance

The following attestation bundles were made for bouncer_core-0.1.1-py3-none-any.whl:

Publisher: release.yml on Ezed9/mcp-bouncer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.1.2

2 files

This release

0.1.1 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page