Bastion — one MCP server, running once, for every client

Bastion

15 min read Original article ↗

1.24.0 · signed, notarized, and sold direct

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 · 30-minute trial, then $14.99

The shopify entry in a client's mcp.json, before and after wiring: a command, its arguments and three environment variables — a store domain, a client id and a Shopify client secret in plaintext — all removed, and one url pointing at the Bastion gateway added in their place. The same file in all 4 · the credential moves out of it

prod/shopify · one process, all 4 running

Keychain · every call recorded

A drawing of the menu bar popover listing 35 servers' worth of profiles, and the calls the gateway has recorded.

The window

Two profiles, one installed server.

The panel above is drawn, and the line in it addresses a profile. This is what a profile is, photographed: one installed server, two named credential sets, serving two accounts without either knowing about the other.

Bastion's Shopify pane: the package name badged read-only and building with provenance, the installed package at 1.4.2, a running prod profile and a staging profile reading “cannot start — missing SHOPIFY_CLIENT_SECRET”, above the server's environment variables with required and secret badges.
prod is running; staging names the one variable it is waiting for and has therefore never started — which is the state the popover above draws it in. The environment table underneath is the server's own, read from its manifest rather than typed here, and the values behind the secret badges are in the Keychain: this pane can set one and cannot show you one.

Every client spawns its own 4 processes

Claude Code

own process · own secret

Cursor

own process · own secret

VS Code

own process · own secret

Terminal

own process · own secret

One supervisor holds them 1 process

Claude Code Cursor VS Code Terminal

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 context cost

85 tool definitions, on every connect.

Every client wired to a profile is sent every tool definition that profile's server exposes before it can call one, and holds them for the whole conversation. That is the largest fixed charge a shared gateway imposes: prod/appstore-connect is 85 tools and about 26.2k tokens, in every editor that opens it, every time one starts.

Load tools on demand serves 3 tools in their place — a search, a describe and a call — and the same profile connects at about 0.4k. The full searchable index costs 3.2k, and only if something asks for it. Nothing becomes unreachable, and Bastion measures both numbers itself: the profile row carries the figure rather than leaving it to be inferred.

What it costs is on the client's side, and the switch says so: every call reaches the editor as bastion_call_tool, so one approval rule there now covers every tool on that server. Bastion's own write gate is unaffected — a write tool is still absent from the index and still refused by the dispatcher. The editor's gate is the coarser one, which is why this ships off and why a profile can disagree with the app-wide answer.

Claude Code and Claude Desktop already load a tool's schema on demand, so Bastion serves them the real list whatever a profile says — fronting a client that defers by itself would buy back the names and take its own tool search with it. The figures above are what every other client pays.

MCP has no method for fetching a schema later — inputSchema is required in a tools/list entry — so an index and a dispatcher is the only shape lazy discovery can take. Doing it in the gateway is what keeps it honest: the call is opened back up into the ordinary tools/call it stands for before the audit chain, the Activity window or the write gate sees the frame, so all three go on naming the real tool. A facade bought as a server cannot do that.

On connect

prod/appstore-connect

Every tool, as any gateway serves it 85 tools · ~26.2k

Tools on demand 3 tools · ~0.4k

67× fewer tokens to hand a client the same server. The index it can ask for costs 3.2k.

Counted from the listing as it arrived on the wire, at four bytes to the token, with the profile's write gate already applied.

What the client is served instead

bastion_search_tools
Names and one-line summaries, filtered by a query. An empty query lists everything.

bastion_describe_tool
One tool's full input schema, by exact name.

bastion_call_tool
Runs one, by name and arguments.

One switch in Settings moves every profile at once; a profile that disagrees — one wired to a client that already defers tool schemas by itself, and so gains nothing here — overrides it in its own sheet.

The record

A record of requests, not a sandbox.

Bastion sees the JSON-RPC frames crossing the gateway, down to what each tool was called with. Credentials are never recorded, and no argument and no result is written to disk unless you ask for an audit log — which is chained, so an edited or missing record shows. 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
  • arguments
  • results
  • outcome

It does not

  • credentials
  • egress
  • file reads

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

The app

A window, not a background process.

Holding the servers in one signed process rather than spawning them inside whichever editor asked first is what makes any of this visible: there is somewhere to look, and it says what happened. These are captures of that window, taken from a real build by make screenshots and checked against committed goldens, so they cannot quietly fall behind the app.

Bastion's Running pane: prod/shopify up two hours with two clients attached, acme/keycloak up 47 minutes, home/unifi-network stopped, and lab/unifi-network with a writes badge and restarted twice.

What is running, and who is attached

A row per profile that has started — four for five, because staging/shopify is still short a credential and has never had a process at all. Each row names its clients twice over: the identity its bearer token was issued to, and whatever the client calls itself, because the interesting case is when those disagree.

Bastion's Stats pane: a ranking of what each server's tool list costs on every connect, UniFi Network at 12.2k tokens, Keycloak at 8k and rising, Shopify at 6.1k, above a bar chart of calls a day for the last thirty days with one day's failures stacked in red.

What the tool lists cost you

Each server pane quotes its own cost, and each one looks survivable on its own. This adds them up: a tool list is sent to your editor before it can call anything and held for the whole conversation, so it is a charge you pay on every connect whether or not you use the server. The bar that fades out is a listing read one page at a time, where the figure is a floor and the sentence under it says «at least» rather than «about». Everything above the range picker is what a connect costs; everything below it is what actually happened.

Bastion's Log pane: timestamped calls across prod/shopify, acme/keycloak and home/unifi-network, each with its JSON arguments and result beneath it, and one row whose client_secret argument reads «redacted».

The log, with the arguments

Not just which tool — what it was called with, and what came back for a profile that opts in. Credentials are redacted before the row is written rather than filtered out of it afterwards, which is why one entry here reads «redacted» where a client secret would have been. No argument and no result is written to disk unless you ask for an audit log.

Bastion's Claude Code pane: the path to .claude.json, five entries pointing at 127.0.0.1:8720 with configured and not-written badges, and a card listing two hand-configured servers the file already held.

The file it is about to write

One entry per profile, each carrying a bearer token issued to that client and never a credential — that split is what makes writing into somebody else's config defensible at all. The servers you configured by hand are listed underneath, untouched, with the note that nothing they do reaches the log.

Bastion's Chat pane: two questions answered against prod/shopify, each showing the tool called, its arguments and its JSON result, with six of fourteen tools loaded at 213 of 1800 tokens.

Try a tool before wiring it up

One profile's tools loaded into the on-device model, with the arguments it invents and the results it gets shown inline. The header counts what is loaded against the context it costs, because a server with forty tools does not fit and the pane would rather say so than quietly drop some.

Bastion's Licence settings: a trial with 28 minutes left, the two sentences explaining what runs during it and what stops after, and an empty licence key field.

A trial that is the whole app

Thirty minutes, every server relaying and every write gate obeying its own switch. It answers whether this works on your Mac against your servers, which is a different question from whether it was worth the money — and that one is what the refund is for.

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.

Servers

Thirty-five to start with. Add your own.

Bastion ships with nothing installed. The thirty-five below are a catalog you install from; anything else on npm you add by package name. The twenty-three packages are fetched on demand into Bastion's own directory and run with the Node runtime in the app. The eleven endpoints are servers their own vendors operate: nothing is installed and no process is started, and what Bastion adds is the credential in the Keychain, one identity per profile, and the audit line. Xcode's own server is the exception to both: a command that ships inside Xcode, which Bastion runs and supervises without installing anything, and which Xcode will not answer until you approve Bastion.

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.

30 behind 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.

5 read-only

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

  • ✓ appstore-connect write gate
  • ✓ cloudkit write gate
  • ✓ reddit write gate
  • ✓ x write gate
  • ✓ unifi-protect write gate
  • ✓ unifi-network write gate
  • stripe write gate
  • ✓ shopify read-only
  • ovh write gate
  • ✓ keycloak write gate
  • ✓ npm write gate
  • github write gate
  • notion write gate
  • linear write gate
  • sentry write gate
  • atlassian write gate
  • figma write gate
  • vercel write gate
  • cloudflare write gate
  • cloudflare-docs write gate
  • cloudflare-observability write gate
  • ✓ ios-device write gate
  • ✓ mongodb write gate
  • ✓ dbhub write gate
  • ✓ kubernetes write gate
  • context7 read-only
  • firecrawl read-only
  • ✓ exa read-only
  • tavily read-only
  • playwright write gate
  • ✓ supabase write gate
  • netlify write gate
  • ✓ apify write gate
  • ✓ ios-simulator write gate
  • xcode write gate

✓ marks the seventeen packages npm can tie to the repository they link to: a signed attestation says GitHub Actions built that exact version from that exact source. It is a smaller claim than a review — nobody here read the code — but it is the one claim a typosquat cannot copy, and it is checked against the registry rather than typed. The rest are not suspect; they publish the older way.

None of the thirty-five are modern. The twelve children written here run 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, and that was Bastion's own pin masquerading as a fact about the servers. The eleven published by somebody else carry the same revision as a seed rather than a measurement — their SDK is a dependency of a package this repo does not own — and so do the remote entries; the first real handshake through a profile is what measures each one. 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.

Known gaps

What it does not do yet.

Everything above this line is built and runs today — the README lists it row by row. This is the other column, and it is here rather than somewhere you find out after paying.

A different kind of limit — what the checks themselves do not prove — is on the checked page.

Server-initiated
Sampling, elicitation and roots are refused with an explanatory error, not routed — a shared instance has no single client to ask.

Write gate
A remote server's write gate is a filter, not a boundary: Bastion refuses to forward the named tools, but anyone holding the credential can call the same API directly. Its own scopes are the real limit.

Rate limits
A remote server's rate limit is shared. Behind one profile every client spends one budget upstream, so a client in a loop can exhaust it for the rest.

list_changed
A call that asks for progress is answered with an SSE stream, so notifications/progress reaches the client that asked. list_changed and subscriptions/listen still cannot: they name no request, and a shared instance has no one client to send them to.

Mcp-Param-*
Headers are forwarded but not validated; no server in the manifest annotates a parameter yet.

Price

One price, one major version, every Mac you own.

There is no subscription and no per-seat tier. Bastion is a tool you install once and forget about; charging rent for a background process that holds your own credentials is not a business this wants to be in.

$14.99 once, not a subscription

€14.99 in the EU, VAT included.

  • Every 1.x release, on every Mac you own The key is issued to you, not to a machine, and nothing counts your installs. It is checked against the major version, so 2.0 will be a new purchase.
  • Verified offline, on your Mac A key is an Ed25519 signature the app checks locally. No activation call, no licence server, and nothing that can refuse to start because a host is down.
  • Thirty days, full refund, no reason needed The trial answers whether this works against your servers. The refund answers whether it was worth the money, which is a different question and is asked second.
  • The source stays public What is sold is the signed, notarized build. The code that holds your credentials is readable and compilable by the people trusting it.
  • Nothing else to install, and no account No container runtime, no Docker Desktop, no sign-up. The bundle carries its own Node, binds loopback, and ships with no entitlements file at all — spawning children and binding loopback need none.

A 30-minute trial runs the whole app, not a crippled version of it. No account, nothing to cancel, and the window lives in memory — quitting and reopening starts another one. See the terms and the reasoning behind them .

Also from Magenta Creations

Cupertino

Your Apple apps, as MCP servers.

Bastion supervises the servers you bring it. Cupertino is a set of them — Mail, Notes, Calendar, Messages and the rest of the Apple apps already on your Mac — behind a single Full Disk Access grant, held by a signed menu-bar app you can watch.

Armada

Every coding agent, on one screen.

A menu bar app for the agents on the other end of those servers: every Claude Code and Codex session on your Mac, what each one is doing, how full its context window is, and how much of each plan is left, read locally from what the agents already write to disk.

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 source is public and stays public; what is sold is the signed build.

$14.99 — every 1.x release, on every Mac you own. Thirty days, full refund, no reason needed. Buy a licence · terms