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 yettools/call · shopify_get_orderread · 84 mstools/call · shopify_list_productsread · 39 msA 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.
Keychain-held
What leaks if a .mcp.json leaks is a revocable loopback token, not a Shopify secret.
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.
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.
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)- refusal order · load-bearing
- 1Bind 127.0.0.1 explicitlyNever 0.0.0.0, and not configurable.alwaysalways
- 2Validate OriginThe page-you-visited rule.refusepass
- 3Validate HostThe anti-rebinding rule.not reachedpass
- 4Per-client bearer tokenMinted at wiring time, kept in the Keychain.not reachedchecked
- 5Secrets never in a config fileThe token goes in the client config; the credential stays in the Keychain.alwaysalways
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.
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.
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.
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.
make app && make auditNo release yet — the app is code-signed for development only.