Bouncer
The only tool that asks whether this destination should receive this value — not whether this tool is allowed to run.
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,
askfails 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):
- 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 todenysince a pinningaskhas no destination a human can vouch for. (Detecting a changed schema for an already-pinned name is a documented limit, not implemented in v1.) - Call budgets.
max_callsper 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 indocs/manual-smoke.mdfor exactly how this plays out (a constraint-denied write still consumes a budget slot). - Per-argument constraints. Path-prefix confinement (normalized,
traversal-safe —
./..are collapsed before comparison, so../../etc/passwdcan't escape an allowed prefix) and regex/allowlist matching on named arguments. - Sink gate — deny-unless-trusted (the headline contract). For tools
marked
exfiltrating, every declaredsink_paramsargument (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 tainted →
deny. (Taint is per-server — see documented limits.) - A destination that is neither trusted nor traceably tainted is
unproven →
ask. - List-valued sinks (e.g. multiple
ccrecipients) 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 decidesdenyvs.ask, neverdenyvs. silentallow— a missed match degrades to a human check, not a leak.
- A destination that traces back to untrusted output of a tool on the same
wrapped server (an email body, a document's contents, …) is tainted →
Layered policy
Policy for a tool is resolved in this order, first match wins
(bouncer/src/bouncer/policy.py):
- User YAML (
bouncer run --policy your.yaml) — your own overrides, allowlists, budgets, path prefixes. - Curated packs (
bouncer/src/bouncer/packs/*.yaml) — small, named-tool policies for popular servers: filesystem, gmail, slack, github, gdrive. - 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 anddelete/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_emailto add atrusted_destinationsallowlist, you must also restateexfiltrating: trueand itssink_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. Seeexamples/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, declarativepolicy.yamltailored 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
# The MCP proxy, CLI and standalone server
uv tool install bouncer-mcp # or: pipx install bouncer-mcp / pip install bouncer-mcp
# Only the contract engine, for use as a library (depends on pyyaml alone)
pip install bouncer-core
# From source
git clone https://github.com/Ezed9/mcp-bouncer.git && cd mcp-bouncer
uv sync --all-packages
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
curldata 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-scanor 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 runwraps one server with its own taint tracker, so a cross-server flow — read a poisoned file via one server, then send via another — surfaces asask(unproven destination), notdeny. Deny-unless-trusted still holds (nothing is silently allowed), but the harddenyonly 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_paramsyou 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 declaretoand the server also accepts an undeclaredrecipients), 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'stools/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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file bouncer_core-0.1.2.tar.gz.
File metadata
- Download URL: bouncer_core-0.1.2.tar.gz
- Upload date:
- Size: 554.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e75934a6a643af7c10b20951ad8c9d5ee453fd400aa52f9ba49caf8c99079283
|
|
| MD5 |
841352841f1202070411507f96ba1b90
|
|
| BLAKE2b-256 |
805cec52a69164bfec9d688ed76b3e7b7bf014e304d0bbc91e3300be7fa290d6
|
Provenance
The following attestation bundles were made for bouncer_core-0.1.2.tar.gz:
Publisher:
release.yml on Ezed9/mcp-bouncer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
bouncer_core-0.1.2.tar.gz -
Subject digest:
e75934a6a643af7c10b20951ad8c9d5ee453fd400aa52f9ba49caf8c99079283 - Sigstore transparency entry: 2828749788
- Sigstore integration time:
-
Permalink:
Ezed9/mcp-bouncer@3943e5b1151be7fef553b6d4c8788d88b0247412 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/Ezed9
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3943e5b1151be7fef553b6d4c8788d88b0247412 -
Trigger Event:
push
-
Statement type:
File details
Details for the file bouncer_core-0.1.2-py3-none-any.whl.
File metadata
- Download URL: bouncer_core-0.1.2-py3-none-any.whl
- Upload date:
- Size: 22.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e267ac01e9dbfcec8609380103e196ed725c975cb73bcf25d8fff9c6b97221dc
|
|
| MD5 |
3d8d7cd64a8192ae0519ff8700b0ef98
|
|
| BLAKE2b-256 |
57d142856e5efd0dac5c491a8690d39dc010300219f70a242816be1cac9d088b
|
Provenance
The following attestation bundles were made for bouncer_core-0.1.2-py3-none-any.whl:
Publisher:
release.yml on Ezed9/mcp-bouncer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
bouncer_core-0.1.2-py3-none-any.whl -
Subject digest:
e267ac01e9dbfcec8609380103e196ed725c975cb73bcf25d8fff9c6b97221dc - Sigstore transparency entry: 2828749859
- Sigstore integration time:
-
Permalink:
Ezed9/mcp-bouncer@3943e5b1151be7fef553b6d4c8788d88b0247412 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/Ezed9
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3943e5b1151be7fef553b6d4c8788d88b0247412 -
Trigger Event:
push
-
Statement type: