mcp-bastion sits between your MCP client (Claude Code, Cursor, Cline, Windsurf, Zed, Claude
Desktop, or any MCP-compliant agent) and your MCP servers. It is client-agnostic — it works with
any compliant client through configuration alone, with zero client-specific code — and non-invasive:
your servers run unchanged, and removing Bastion is a one-line config revert.
📦 Package: mcp-bastion on npm · 🗂️ Official MCP Registry: io.github.Gowthaman90/mcp-bastion
🔒 Security, measured: on the open, vendor-neutral mcp-defense-bench v0.9.0, Bastion covers 66% of the MCP attack surface (24.6/37 vectors; 23 enforced) on development fixtures and 53% on the pre-registered held-out set, at zero false positives on matched controls — the broadest of the proxies measured.
📖 Launch story: Medium · dev.to
👤 Created & maintained by Gowthaman Arumugam — Independent Researcher. Companion benchmark: mcp-defense-bench.
Contents
Why
When an MCP server disconnects mid-session, the agent only sees a generic "No such tool available"
error — indistinguishable from a tool that never existed — and it cannot reconnect; only a human
can. Long agent sessions silently lose capabilities and fail in confusing ways.
Bastion closes that gap. It health-checks every server, auto-reconnects with backoff, and — crucially —
exposes control tools so the agent itself can inspect connection health and recover a dropped
server without human intervention.
Bastion now spans three layers: reliability (v0.1), runtime security (v0.2 — tool pinning /
rug-pull & poisoning detection), and audit & compliance (v0.3 — pluggable sinks mapped to NIST
AI RMF / OWASP LLM Top 10). See the roadmap.
How it works
Today your client connects directly to each server. With Bastion, your client connects to
Bastion, which connects to those same servers on your behalf — so it sits in the tool-call path
and can add reliability (and, later, security) transparently.
Bastion is a standard MCP server to your client and a standard MCP client to each upstream.
Because it speaks the protocol faithfully, it works with every compliant client automatically — the
only per-client difference is where you put a few lines of config.
Security model
Bastion's security rests on three independent mechanisms. Heuristics feed all three and replace
none of them: a heuristic finding is a risk signal, never a proof that a tool is safe.
Every finding carries a kind: deterministic (an exact comparison against a pin, a schema, a policy
or a token's claims) or heuristic (pattern matching that can misfire on benign text and can be
evaded by a novel payload). What each enforcement profile does with them:
Argument inspection blocks under balanced even though part of it is heuristic: a false positive costs
the agent a retry, while a miss can run a command. Inline secret redaction in results
(redactResponseSecrets) is on in every profile. Under observe nothing is blocked and each audit event
records wouldBlock: what balanced would have done.
Known limits. Poisoning and response heuristics can be evaded by payloads they have not seen.
Redaction covers known secret shapes, not every secret. Bastion does not sandbox the upstream server
itself. Authorization trusts the identities it is given: bearer clients it authenticates itself, and
delegation claims it verifies only in signature mode (see trustStructuralClaims).
Features
- 🔌 Client-agnostic — one binary, config-only integration; no per-client plugins.
- ♻️ Self-healing — health checks + capped exponential-backoff auto-reconnect for stdio servers.
- 🧭 Agent-recoverable —
bastion__status and bastion__reconnect let the agent detect and fix
drops itself, instead of hitting an opaque "no such tool" wall.
- 🧩 Transparent aggregation — merges many servers into one, with per-server tool namespacing to
prevent collisions and tool-shadowing.
- 💬 Legible failures — a dropped server yields an actionable message, not a crash.
- 🛡️ Runtime security (new in v0.2) — pins each tool's definition and blocks "rug pulls" (a
server changing a tool after approval); heuristically inspects descriptions for poisoning; detects
cross-server shadowing. See Runtime security.
- 🔐 Runtime authorization (new in v1.4, opt-in) — per-caller, per-tool policy decided before any
upstream contact: which client, user and agent may call which tool with which argument values,
deny-overrides, default-deny, and an observe mode to preview a policy. See
Runtime authorization.
- 📝 Audit & compliance (opt-in) — structured, hash-chained audit events to pluggable sinks
(console / file / webhook / OTLP), mapped to NIST AI RMF & OWASP LLM Top 10; since v1.4 with HTTPS-only
remote sinks, retrying delivery, per-sink redaction and Ed25519-signed checkpoints that make a shortened
log detectable. See Audit & compliance.
- 🪶 Non-invasive & reversible — your servers run unchanged; uninstall is a config revert.
- 🧱 Enterprise-grade codebase — strict TypeScript, layered architecture, ESLint + Prettier, and
unit + end-to-end tests.
Quick start
Bastion is published on npm as mcp-bastion — the
npx command below fetches it automatically, so there's nothing to install first.
1. Add Bastion to your client, pointing it at a config file:
2. List your real servers in bastion.config.json (moved verbatim from the client):
3. Restart your client. Your tools now appear namespaced (e.g. github__create_issue) alongside
Bastion's control tools. See bastion.config.example.json for the
full set of options.
🔒 Security is on by default. Out of the box, Bastion runs the balanced enforcement profile:
it blocks high-confidence attacks (rug-pulls, argument/command injection, cross-server
exfiltration) and warns on heuristic ones (description/response
poisoning), while redacting leaked secrets from tool results. Set security.enforcementProfile to
observe (warn-only) or strict (block-all), or tune any individual control — see
Runtime security.
Demo
See the whole thing in action — a server crashing mid-session and healing itself:
It boots Bastion in front of a server that crashes on command, shows the agent getting an actionable
"reconnect" message instead of a cryptic error, and then the connection auto-recovering with no human
involved. To record it as a GIF: asciinema rec demo.cast -c "npm run demo" && agg demo.cast assets/demo.gif.
Control tools
Bastion injects control tools so the agent can manage connections and review security itself, using
only standard MCP calls:
Re-approval is operator-only. Clearing a rug-pull block is a security authority, so it is not an
agent-callable tool — a prompt-injected agent must not be able to re-approve the very tool it was blocked
from. A changed tool stays blocked until an operator clears it out-of-band; bastion__approve is not
advertised and a client call to it is refused.
Configuration
Transports
Bastion runs on the MCP TypeScript SDK 2.0 and speaks both protocol eras — the stateless
2026-07-28 revision and the pre-2026 initialize handshake — on both faces, per upstream and per
client. When the protocol is stateless, the gateway is the only component that can still hold security
state, which is why v1.0 adds requestState custody and an input_required consent gate (see
CHANGELOG.md).
Bastion speaks two transports on both faces:
- stdio (default) — the client spawns Bastion, and Bastion spawns local servers.
- Streamable HTTP — connect to remote MCP servers (
servers.<name> with transport: "http",
a url, and optional auth headers), and/or serve Bastion over HTTP to multiple/remote clients
(listen.mode: "http", or --http <port>).
HTTP upstreams configured without an authentication header are flagged (authenticated: false) in
bastion__status and warned at connect time. A custom credential header counts once it is named in
authHeaders; a forwarded header always counts.
Per-user identity (gateway mode)
When Bastion sits between an agent and sub-agents that must act as the signed-in user, each
upstream call has to carry that user's identity, not one fixed token. forwardHeaders does this:
The map reads upstream header ← incoming header. For every upstream request made while serving a
downstream HTTP request (initialize, tools/list, tools/call, ping), Bastion copies the value of
the incoming x-delegate-files header onto the upstream request as x-user-identity.
Bastion holds no credential in this mode. It only passes on what each request carries.
- Bound to the request. The incoming headers live in an
AsyncLocalStorage for exactly the
lifetime of one downstream request. A custom fetch on the upstream transport reads them at send
time. Concurrent requests from different users share one upstream connection but never see each
other's headers.
- Only listed headers travel. No other incoming header is forwarded. Hop-by-hop and
transport-owned headers (
connection, mcp-session-id, content-type, …) are rejected as forward
targets at config load. While listen.authToken is set, the incoming authorization header (Bastion's
own bearer token) cannot be forwarded.
- A forwarded header overrides a static
headers entry of the same name.
- Missing header. By default (
onMissingForwardedHeader: "error") the upstream is not called. The
caller gets a tool error such as
files: the request carried no x-delegate-files header, so the call was not forwarded.
Use "omit" to call the upstream anyway and let it decide.
- On-demand connection. With
connect: "onDemand", nothing is sent at start-up. The first request
that needs the upstream (tools/list, or a direct tools/call of files__…) connects it with that
request's headers. The tool list is then cached as usual. Health checks skip on-demand upstreams,
which show as idle until first used. A failed connect returns to idle with no reconnect timer.
connect: "startup" with forwardHeaders connects at boot with the static headers only, so use
onDemand when the upstream authenticates initialize too.
- 401 is an authentication failure. It is reported as such in the tool result and in
bastion__status (lastError), never as a crash or an unknown tool. One user's 401 does not tear down
the connection other users share.
- Values are never recorded. Logs, audit sinks and
bastion__status never contain a forwarded
value. Audit events record only which headers were forwarded ("forwardedHeaders": ["x-user-identity"]).
- Pinning is shared. Tool definitions are pinned on first sight and a change is blocked, whichever
user's request saw it. Only the identity is per request.
forwardHeaders, onMissingForwardedHeader, connect and authHeaders are valid only on
transport: "http" upstreams. forwardHeaders also needs listen.mode: "http". Other combinations
are rejected at config load. See
examples/identity-forwarding.config.json for three
sub-agents behind one Bastion.
Delegation checks
Since v1.2.0. In gateway mode a caller (say, a master agent working for a signed-in user) sends one
short-lived delegate JWT per sub-agent, each in its own header. Each sub-agent sees only its own token.
Bastion sees the whole request, so it can check what none of them can, without holding any secret or
key:
Chain depth and cycles concern prior actors, which RFC 8693 §4.1 calls informational, so they are
medium severity and only warned on unless security.onDelegationChain (or the strict profile) says
block. Everything else follows security.onDelegation (warn under observe).
- Strict decoding. Exactly three base64url segments, no padding, canonical encoding, JSON objects
with unique member names (after escape decoding) and a string
alg; an optional Bearer prefix is
allowed. Anything else fails closed as delegation-malformed.
- One verdict per request. On first need Bastion decodes every delegation-configured token the
request carries, runs the per-upstream checks and the cross-token subject check, and caches the
verdict for the rest of the request. The checkers are exported as
checkDelegation(policy, carriedHeaders, now) (structural, synchronous) and
checkDelegationAsync(policy, carriedHeaders, now) (adds signature verification).
- Before any upstream contact. A blocked
tools/call is refused before Bastion connects (on-demand
connects included), with a tool error naming the rule, never a token or claim value, and an audit event
with decision blocked_delegation. tools/list leaves out an upstream the request is blocked from. As a
backstop, the upstream transport refuses to send a token whose verdict blocks (reconnects and other
sends made inside the request).
- Who acted for whom. Calls to a delegation-configured upstream carry an audit
delegation record:
subject, issuer, audience, tokenId (jti), expiresAt, actors (current first), chainDepth,
verified (structural, signature when the signature verified, or failed) and the findings. Never the token. In hash mode the subject is "hmac:" + the
first 16 hex characters of HMAC-SHA256(key, iss|sub): an operator with the key can recompute it to find
one user's records. The key is delegation.subjectKey, else MCP_BASTION_SUBJECT_KEY, else the audit
integrity key, else a random key per process, whose pseudonyms do not link across restarts or
across replicas. Set one shared key on every replica to link a user's records; Bastion logs a start-up
warning while none is set.
- Status.
bastion__status shows, per upstream, delegation: { mode, audience, lastViolation }, where
the last violation is a rule id and a time only.
- Config checks.
delegation needs forwardHeaders; header must be one of its keys; two upstreams
cannot share an audience, and no upstream audience may be a gateway audience; unknown keys are
rejected. Signature mode needs issuer, a non-empty algorithms list and exactly one key source.
Signature mode
When the issuer signs delegate tokens with an asymmetric key (ES256, EdDSA, RS256, PS256, …), Bastion
can verify them against the issuer's public keys:
- Keys come only from the configured source.
jku, x5u, x5c and embedded jwk headers are never
followed. An inline jwks with private or secret members (d, p, q, dp, dq, qi, oth, k)
is rejected at config load. jwksUrl must be HTTPS, except to a loopback host.
- The algorithm allow-list runs first, case-sensitively, before any key lookup:
alg: none (in any
casing) and HS256 signed with the public key are refused without a fetch. A critical header extension
(crit, e.g. an unencoded payload) is refused.
- Remote key sets are fetched on first use and cached. A token with an unknown
kid triggers at most
one refetch per 30 s cooldown. A fetch failure or a 5 s timeout fails closed: the call is blocked as
delegation-key-unknown, the upstream's lastError says the key set is unavailable, and further fetches
wait for the cooldown.
- The structural rules still run on a verified token, under the same names, and the audit record says
verified: "signature".
Honest limits.
- Structural mode authenticates nothing: a party that can forge tokens can forge consistent claims. It
stops misrouting, passthrough, user mixing, stale relay and chain abuse by honest-but-wrong or
prompt-injected callers. The upstream remains the verifier. Use signature mode (asymmetric tokens) for
authentication at the gateway.
- Signature mode holds public keys only, so Bastion stays keyless: it can verify tokens but never mint
one. It needs the issuer to sign with an asymmetric algorithm; HS256 tokens can only be checked
structurally, because verifying them needs the shared secret. It trusts whatever the configured key set
says, so the security of
jwksUrl (HTTPS, the issuer's own host) is the security of the check.
- The one-user-per-request check covers only tokens configured with
delegation.
- Replay inside the token's lifetime is not stopped (no
jti cache yet; possible later, opt-in, bounded).
See examples/delegation.config.json for three sub-agents.
Runtime authorization
New in v1.4, opt-in. Decide who may call which tool, with which arguments, on every call and
before any upstream server is contacted. Inactive unless authorization.enabled.
Who is calling. A call carries every identity Bastion has authenticated: the client (a named
listen.clients bearer token, default for listen.authToken, or stdio), plus the user and the
acting agent from the call's delegate token when there is one. Identities named
in arguments, headers, _meta or input responses are ignored: a request cannot promote itself by
claiming to be someone else.
How a call is decided.
- Every identity must be permitted. An agent acting for a user needs the user's grant and its own,
so an agent's broad service grant is never used for a user who lacks the right (the confused deputy),
and a user's right is never used by an agent that was not granted it. Earlier agents in a delegation
chain grant nothing; only the current one counts.
- Deny overrides allow. Per identity, any matching
deny refuses; otherwise an allow whose
argument constraints all hold is needed. With default: "deny", no matching allow means no access.
- Rules.
principal is "*" or one of client, user, agent (a name or a list). tools are
namespaced names, exact or ending in one *. args constrain top-level arguments with equals, in,
max, min and pathWithin. Matching is exact: no case folding, Unicode normalisation or type
coercion; max/min accept JSON numbers only; pathWithin resolves . and .. and checks the
boundary at a / (so /srv/docs-archive is not inside /srv/docs).
- Fail closed. A missing constrained argument fails an
allow. A deny with args applies unless
its constraints can be evaluated and do not hold, so a missing or wrongly typed argument cannot slip
past a deny.
- Control tools (
bastion__*) are authorized like any tool, under server name bastion. With
default: "deny", grant them to an operator client, as ops-console above.
Where it runs. In tools/call, after the delegation checks and before any upstream contact, so a
refused call never opens a connection carrying the caller's forwarded headers. Continuations
(input_required retries) are re-authorized. tools/list hides tools a caller could never be allowed.
Rolling it out. Start under enforcementProfile: "observe": nothing is refused, and each audit
event carries an authorization record (effect, deciding rule, violations such as
agent:argument, and rules, the deciding rule per identity, e.g.
{ "client": "orchestrator-files", "user": "files-run" }, so the record shows which grant admitted
each identity) plus wouldBlock: "blocked_authorization" where enforcement would have refused. Rule ids
appear in audit records, so keep personal data (user ids, e-mail addresses) out of them. When
the log shows only refusals you intend, switch to balanced. Under balanced a refused call is answered
Blocked by mcp-bastion: this caller is not authorized to call "<tool>" (rule <id>).; no other policy
content is revealed.
Trust in user and agent identities. They come only from a trusted delegate token: one verified in
signature mode, or, in structural mode, only with trustStructuralClaims: true. Set
that only when the upstream verifies the same token itself, so a forged claim that passes Bastion is still
rejected upstream. A rule naming a user or agent for a structural-mode upstream is a config error
otherwise, and a token that is present but untrusted refuses the call (delegation:unverified). While
trustStructuralClaims is on, Bastion logs a start-up warning naming the upstreams it applies to.
Measured. This family of checks was pre-registered as test cases in
mcp-defense-bench (tag
authorization-prereg-2026-10-26) before this code was written. Per-caller MCP authorization also exists
in other gateways (for example agentgateway's CEL rules and ToolHive's Cedar policies), and the
benchmark's pre-registration commits to scoring them side by side.
Runtime security
New in v0.2. Bastion adds a security layer in the tool-call path (an interceptor pipeline), enabled
by default:
- Rug-pull detection (tool pinning). Each tool's definition is pinned on first use. If a server
later changes that definition, the tool is blocked (
onRugPull: "block") until an operator reviews it
and re-approves it out-of-band (operator-only — not an agent-callable tool). This catches a server that
looks benign at install time and turns malicious afterward.
- Poisoning inspection. Tool names and descriptions are scanned for manipulation heuristics
(instruction override, secret access, data exfiltration, covert instructions, embedded directives,
hidden/zero-width characters). Because heuristics can false-positive, the default is
warn (logged
and reported, not blocked); set onPoisoning: "block" to enforce.
- Shadowing. When two servers expose a tool with the same name, it's surfaced in the report.
Review everything with the bastion__security tool. Each finding says whether it is heuristic or
deterministic; see Security model.
Audit & compliance
Opt-in. Enable audit to record a structured, versioned event for every tool call, including calls
refused by the security layer or by authorization:
Events. Each event names the server, tool, the authenticated client (a listen.clients name,
default, anonymous or stdio, recorded with or without authorization), decision (allowed,
blocked_rug_pull, blocked_authorization, …), outcome, findings with their kind, the authorization record (with the
deciding rule per identity) and the delegation record when present, a runId, and a mapping to
NIST AI RMF functions and OWASP LLM Top 10 categories. A call that passed the checks and then
failed before reaching the tool (an on-demand connect refused, an upstream not connected) is recorded
with outcome: "error". bastion__compliance returns aggregate totals.
Each sink is its own security boundary.
console (stderr), file (JSON lines, created 0600), webhook and otlp (OpenTelemetry logs).
OTLP records carry the caller (mcp.client), mcp.would_block, mcp.findings, the authorization
rule, audit.seq and the chain hashes as attributes, plus the whole event as audit.event, so a
collector export can be checked with mcp-bastion audit verify.
- Remote sinks must use HTTPS unless the host is loopback. Plain HTTP to a remote host is a config
error unless the sink sets
allowInsecureTransport: true, which logs a warning.
- Credentials come from the environment (
headersEnv), not the config file; a missing variable stops
startup.
- Webhook and OTLP sinks send a batch when
batchSize events are queued, or every flushIntervalMs
(default 5 s), whichever comes first.
- A failed delivery is retried with exponential backoff instead of being dropped. Events wait in a
bounded queue (
maxQueue, default 10 000); beyond it the oldest are dropped and counted. Receivers can
also spot gaps from each event's seq.
bastion__status shows each sink's health: healthy, delivered, dropped, queued, lastError.
- With
onSinkFailure: "block", calls are refused (blocked_audit_unavailable) while no sink can
deliver, for deployments where an unaudited call is not acceptable. Bastion's own control tools stay
usable so the sinks can be inspected and recovered.
Redaction is first-class.
- Arguments are not recorded by default. Each sink can record its own level (
includeArgs per sink):
for example full arguments to a local file and none to a shared SIEM.
redacted keeps structure and masks values under credential key names and values that look like
secrets: cloud and provider keys, GitHub, Slack, Stripe, Google and npm tokens, JWTs, Bearer
credentials, passwords in URLs, private-key blocks and NAME=secret assignments. The same patterns
redact secrets from tool results before the agent sees them (redactResponseSecrets, on by
default). Measured on a 45-case corpus (test/redaction-corpus.test.ts): 29/29 secrets caught, 0/16
benign look-alikes changed.
- Never written to any sink: tool results, delegate-token values, forwarded header values (names only),
and the raw user
sub when delegation.recordSubject is hash. Arguments are recorded only as
includeArgs allows, so a secret passed as an argument is kept under full.
- Redaction is pattern-based. A secret in an unrecognised shape under an unrecognised key can still be
recorded, so prefer
none for high-sensitivity sinks.
Tamper evidence, and its limits.
- With
tamperEvident, events form a hash chain. With an integrityKey the chain is HMAC-SHA256:
without the key, a party who rewrites the log cannot recompute it. Unkeyed, it only detects naive
edits.
- When sinks record arguments differently, the chain (v2) covers each event without its
args view
plus an argsDigest (HMAC of the full arguments), so every sink verifies the same chain.
- A hash chain cannot show that the newest records were cut off. Signed checkpoints can:
audit.checkpoint writes an Ed25519-signed statement of the chain head every everyEvents (100)
events, every everySeconds (60) seconds and at shutdown, and each run starts with a run-start
record linked to the previous run through stateFile. Send checkpoints to a witness sink the log's
host cannot rewrite.
mcp-bastion audit verify <log> --checkpoints <witness.jsonl> --public-key <pem> checks the chain,
seq continuity, checkpoint signatures, run links, a log cut short and a deleted run. Exit 0 intact,
1 tampered, 2 cannot verify. mcp-bastion audit keygen --out <dir> creates the key pair.
- Limit: a cut is detected only back to the last checkpoint that reached a witness the attacker
cannot rewrite; without a witness, cutting a log together with its checkpoints is not detectable. An
attacker holding the private key can forge checkpoints, so keep it off the log's host.
Shadow mode. Under security.enforcementProfile: "observe" nothing is blocked, and each event says
what enforcement would have done: wouldBlock holds the decision balanced would have recorded, a rug
pull appears in findings as tool-definition-changed, and responseFindings / redactedSecrets
record what the result scan and inline DLP found (or secret-in-result when redaction is off).
Standards alignment
Every security check maps to recognized frameworks — the NIST AI Risk Management Framework (a U.S.
federal standard), the OWASP Top 10 for LLM (2025) and Agentic (2026) Applications, and
STRIDE. The full per-check mapping (with reference links to the MCP-security literature the checks
are drawn from) is in docs/CHECKS-MAPPING.md.
Coverage is measured independently — bastion is scored against a 22-vector attack surface by the
vendor-neutral mcp-defense-bench benchmark, with a
public leaderboard and framework mapping.
Client setup
The steps are identical for every client — only the config file location differs:
Gradual adoption: you don't have to route every server through Bastion — put only your flaky or
untrusted servers behind it and leave the rest connected directly.
Rolling out to a team? docs/ADOPTING.md walks through a 10-minute trial, a
shadow pilot that never blocks, and enforcement, plus the evidence a security reviewer will ask for.
Architecture
Bastion is organized into clear layers with a one-directional dependency flow, so each concern is
independently testable and easy to evolve:
Design details — including the client-agnostic rationale, the interceptor pipeline, and the audit-sink
strategy — live in the project's design docs.
Development
Roadmap
Both stdio and Streamable HTTP transports are supported (see Transports).
Contributing
Contributions are very welcome — this project is built to be community-owned. Please read
CONTRIBUTING.md for the dev setup, project layout, and PR workflow, and our
Code of Conduct.
In short: open an issue for non-trivial changes, keep PRs focused with tests, and make sure
npm run check passes (CI runs it on Node 20/22/24). Good first areas: additional client setup
recipes and more upstream test fixtures.
Using mcp-bastion at your organization? Add it to ADOPTERS.md with a pull request.
Security
mcp-bastion is security-adjacent software, so we hold it to a high bar. Please report
vulnerabilities privately — do not open a public issue. See SECURITY.md for
the disclosure process and supported versions, and THREAT-MODEL.md for what
Bastion defends against and what it doesn't. Bastion collects no data: see
PRIVACY.md.
License
Apache-2.0 © Gowthaman Arumugam and mcp-bastion contributors