Early · the gateway serves a real server end to end

One MCP server, running once, for every client.

Bastion is a menu-bar app that supervises your MCP servers instead of letting each editor spawn its own. One process per profile, credentials in the Keychain, and every tool call recorded.

macOS 26 or later · loopback only · not shipped yet
Activity Monitor
mcp-shopify58 MB
mcp-shopify61 MB
mcp-shopify57 MB
mcp-shopify60 MB
4 processes · 4 plaintext secrets · 0 logs
Activity Monitor
mcp-shopify61 MB
1 process · Keychain · every call recorded
Claude
Thu 09:41
Bastion2 profiles running
prod/shopifyrunning
acme/keycloakrunning
staging/shopifyidle · stopped
home/unifi-networkwrites off
lab/unifi-networkwrites on
Gateway127.0.0.1:8720
Writesper profile
Copy .mcp.json snippet
Open Activity…
Quit Bastion
.mcp.json"url": "http://127.0.0.1:8720/s/prod/shopify"
tools/call · shopify_get_orderread · 84 ms
tools/call · shopify_list_productsread · 39 ms
A drawing of the menu bar popover listing 9 servers' worth of profiles, and the one line a client's config carries.
The credentials

A brokerage refresh token, sitting in a file beside the code.

Every MCP client spawns its own copy of every stdio server, each with its own environment. Four servers kept a .mcp.json holding real secrets in plaintext — readable by anything running as you.

One careless git add from being published. Nothing was auditable, and nothing was shared. Those four sets are in the Keychain now and their configs point here instead; the shape of the problem is why.

Every client spawns its own4 processes
Claude Codeown process · own secret
Cursorown process · own secret
VS Codeown process · own secret
Terminalown process · own secret
One supervisor holds them1 process
Claude CodeCursorVS CodeTerminal
Bastion.app
one child
Keychain-held

What leaks if a .mcp.json leaks is a revocable loopback token, not a Shopify secret.

The runtime

HTTP to the clients. stdio to the child.

Each client points at a URL instead of a command. Bastion answers it, and keeps exactly one child per profile alive behind it.

http://127.0.0.1:8720/s/<profile>/<server>

Profiles

A named credential and configuration set — prod/shopify, staging/shopify, acme/keycloak. One global instance would be one identity, and one identity is unusable: every repo here already carries different credentials for the same server.

One handshake

The MCP handshake happens once, at spawn. Every client's initialize is answered from that one result, which the 2026-07-28 stateless-first revision makes correct rather than a hack.

Id remapping

Each client's request ids are rewritten into the supervisor's own numbering and back again — two clients using id 1 at the same time cannot receive each other's answers.

One process, N clients

This inverts the one-connection-one-process design on purpose. Three reasons are given for that choice, and each has to be answered rather than waved at.

What is genuinely given up is isolation between two clients of the same profile — the same identity with the same permissions, which is exactly the case where a second process bought nothing but memory.

state
The handshake happens once, at spawn, and is replayed to every client.
crashes
Backoff, a circuit breaker, and the blast radius said out loud: when a child dies every client waiting on it is told, with a count.
write permissions
Per profile, not per process — which a process boundary could never express.
The record

A record of requests, not a sandbox.

Bastion sees the JSON-RPC frames crossing the gateway. It does not see what a server then does over the network or on the filesystem — and the page says so rather than implying otherwise.

It sees

  • profile
  • method
  • tool name
  • duration
  • outcome

It does not

  • arguments
  • results
  • egress
  • file reads

A server that reads a file it was never asked about does so out of Bastion's sight.

The five rules

A loopback daemon holding every credential you own is a known shape of hole.

CVE-2025-49596 was a listener plus no CSRF protection: a page you visited could reach it and execute code. The rust-sdk and FastMCP rebinding advisories had the same root cause — no protection by default, because it is only localhost. All five rules landed in the first commit that opened a socket, not a hardening pass afterwards.

GET /s/prod/shopify · Origin: https://evil.examplenull (loopback client)
  1. refusal order · load-bearing
  2. 1Bind 127.0.0.1 explicitlyNever 0.0.0.0, and not configurable.alwaysalways
  3. 2Validate OriginThe page-you-visited rule.refusepass
  4. 3Validate HostThe anti-rebinding rule.not reachedpass
  5. 4Per-client bearer tokenMinted at wiring time, kept in the Keychain.not reachedchecked
  6. 5Secrets never in a config fileThe token goes in the client config; the credential stays in the Keychain.alwaysalways
response · refused at rule 2response · rule 4 still applies
403 ForbiddenRefused before the token is even read. This is the ordering that CVE-2025-49596 lacked: a page you visited could reach the listener, and there was nothing in front of the handler to stop it.
200 OKOrigin and Host pass, so the request reaches the bearer-token check. A valid per-client token minted at wiring time is still required — the order matters because a rebinding attempt must be refused whether or not it also guessed a token.
$ make auditok loopback-only via lsofok foreign Origin refused · rebound Host refusedok refusal order · wrong token refusedok profiles.json holds no secret value

The app ships with no entitlements file at all — the audit asserts the setting is absent, because an empty permission set that is true by construction is checkable and one arrived at by deletion is not.

Servers

Nine to start with. Add your own.

Bastion ships with nothing installed. The nine below are a catalog you install from; anything else on npm you add by package name. Each one is fetched on demand into Bastion's own directory and run with the Node runtime in the app.

What stays closed is how a request picks one. A client names a profile and a server id — never a path, never a command line. The child inherits your credentials and runs unsandboxed, so "run whatever the config names" is the same shape of hole as CVE-2025-49596, and it is the half worth keeping. The reference has the argument in full.

8behind a write gate

Off until a profile turns it on. lab/unifi-network with writes on beside home/unifi-network with them off is expressible; a process boundary never made it so.

1read-only

No mutating tool registered at all, so a host cannot call what it was never told about.

  • appstore-connectwrite gate
  • redditwrite gate
  • x-apiwrite gate
  • unifi-protectwrite gate
  • unifi-networkwrite gate
  • stripewrite gate
  • shopifyread-only
  • ovh-apiwrite gate
  • keycloakwrite gate

None of the nine are modern — every one runs an SDK whose newest protocol is 2025-11-25, which is what they negotiate. The catalog said 2025-06-18 until a live handshake was run against one; that was Bastion's own pin masquerading as a fact about the servers. A server you add yourself is fronted the same way. The catalog is one file, and the reference table has every variable each entry reads.

Status

The gateway serves real servers end to end and the credentials that used to sit in plaintext are in the Keychain. There is no client wiring, no bridge, and no notarized build.

Known gaps

  • The repointed repos need Bastion running: four .mcp.json files now call the gateway instead of spawning anything, and there is no login item yet.
  • Sampling, elicitation and roots are refused with an explanatory error, not routed — a shared instance has no single client to ask.
  • Responses are a single JSON object, never an SSE stream: no notifications/progress on a long call.
  • Mcp-Param-* headers are forwarded but not validated; no server in the manifest annotates a parameter yet.
Gateway
Loopback HTTP with Origin, Host and bearer checks, hand-written so they stay auditable.
built
Supervisor
One child per profile, id remapping, backoff, circuit breaker, idle stop.
built
Dialect
Dual-era: modern 2026-07-28 and legacy initialize, onto legacy children. 24 conformance checks.
built
Keychain
Per-profile credentials, per-client tokens. profiles.json holds no secret value.
built
Activity window
What is running, who is attached, and every tool call, live.
built
Migration
Four .mcp.json credential sets moved into the Keychain, and their configs repointed here.
built
make smoke
Four concurrent clients, colliding ids, exactly one child, kill -9 recovery.
green
Client wiring
Minting a token per client and writing it into their configs, instead of doing it by hand.
next
Release
bastion-bridge for stdio-only clients like Claude Desktop, then the signed and notarized path. Nothing licensed yet.
not started

One process. One grant of trust. Read the source.

A program that holds every credential you own should be readable and compilable by the people trusting it. The licence is not settled yet, and that is a gap rather than a position.

Read the source ↗make app && make audit

No release yet — the app is code-signed for development only.