Skip to main content
Glama
mgcrea
by mgcrea

Bastion

One MCP server, running once, for every client on the machine.

A macOS menu bar app that supervises your MCP servers instead of letting each editor spawn its own: one process per server, credentials in the Keychain, and every tool call recorded.

Shipping. Signed, notarized, and sold direct at bastion.mgcrea.iodownload the current release, or buy a licence for $14.99 (€14.99 in the EU). A 30-minute in-memory trial runs the whole app. The source is public and stays public; what is sold is the build. Status lists exactly what runs today, and CHANGELOG.md what each release changed.

The problem

Every MCP client spawns its own copy of every stdio server. Open three projects in Claude Code and you have three mcp-shopify processes, three credential sets in three environments, no logs, and no way to see what tool was called with what argument. Nothing is auditable and nothing is shared.

The credentials are the sharp end. Right now mcp-shopify, mcp-keycloak, mcp-tastytrade and mcp-appstore-connect each keep a .mcp.json holding real secrets in plaintext — readable by anything running as you, and one careless git add from being published. A brokerage refresh token sits in a file beside the code.

Bastion runs each server once, holds its credentials in the Keychain, and fronts it over loopback HTTP so several clients share one instance.

Related MCP server: mcp-computer-use

How it works

Each client points at a URL instead of a command:

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

A profile is a named credential and configuration set — prod/shopify, staging/shopify, rgis/keycloak. It is the answer to the obvious objection to a shared gateway: one global instance is one identity, and one identity is unusable. Every repo in mgcrea-ai already carries different credentials for the same server, so a true singleton would be unusable for its own author on day one.

Bastion speaks HTTP to the clients and stdio to the child. The MCP handshake happens once, at spawn — every client's initialize is answered from that one result, and each client's request ids are rewritten into the supervisor's own numbering and back again, so two clients using id 1 at the same time cannot receive each other's answers.

One process, N clients

This inverts cupertino's design, which maps one connection to one process on purpose. Three reasons are given there for that choice, and each one has to be answered rather than waved at:

Cupertino's reason

Bastion's answer

state

The handshake happens once, at spawn, and is replayed to every client. The 2026-07-28 spec went stateless-first, which is what makes this correct rather than a hack.

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. unifi-network/lab with writes on beside unifi-network/home with writes off is expressible; a process boundary never made it so.

What is genuinely given up is isolation between two clients of the same profile. That is the trade, and it is the right one: they are the same identity with the same permissions, which is exactly the case where a second process bought nothing but memory.

For a remote server none of this applies, because there is no process to share. Three of the four reasons to put a gateway in front of a server survive — the Keychain, the audit line, and per-profile identity — and one does not. The write gate is the one that weakens: a child gets an environment variable that switches its destructive tools off inside the server, and a remote server has no environment, so the gate becomes a list of tool names Bastion refuses to forward. That filters Bastion, not the server. Anyone holding the credential can call the same API directly, and the credential's own scopes are the real boundary — which is why the Stripe entry says to use a restricted key.

Servers

Bastion ships with nothing installed. It ships with a catalog of thirty-three, listed in servers.json and documented in docs/servers.md; the list an install actually runs lives in Application Support, starts empty, and the user edits it. Install from the catalog, or add any other MCP server by npm package name. Code is fetched on demand into Bastion's own directory and run with the Node runtime in the app — the bundle carries node and npm and no servers at all.

The transport, its rules, and everything measured rather than assumed about it are in docs/remote-servers.md.

For a remote server there is a second way to authenticate, and it is the one worth having. Bastion runs the OAuth dance once, per profile, and every client shares the result without ever seeing a token — nothing is typed into a config file, and access is revoked from the provider's own dashboard rather than hunted for across four repos. Press Sign in with Stripe on a profile; Bastion discovers the authorization server from the 401, registers itself dynamically, runs PKCE S256 in an ephemeral browser window, and keeps the token in the Keychain, refreshing it behind every client's back. A restricted key still works and is not going away — it is the only thing that works for a Connect platform acting on a connected account, where Stripe does not support OAuth.

One entry in the catalog is not a package. Stripe runs its own MCP server, and Bastion fronts that rather than shipping a client for it — the entry used to be a placeholder for @mgcrea/mcp-stripe, a package nobody ever wrote. This is the same position the catalog already takes: what is worth building is the runtime underneath a server, not another server. A remote entry has no package, no process and nothing to install; what it still has is the credential in the Keychain instead of in every repo's .mcp.json, one identity per profile, and every call in the audit log. What it does not have is the headline — see Status.

What is still closed is how a request selects one, and that is the half that was ever a security property. The child inherits the user's credentials and runs unsandboxed, so "run whatever the config names" is the same shape of hole as CVE-2025-49596. A client names a profile and a server id; the id resolves against the list the user installed, or it 404s. Nothing arriving over the wire can name a package, a path or an argv, and a custom entry supplies a package and a bin name rather than a command line.

Bastion curates lightly, and only to fill the first screen. The catalog seeds thirty-three entries — eleven servers written here, eleven somebody else publishes, and eleven endpoints their own vendors operate — because a catalog that opens with nothing recognisable in it teaches nobody what the app is for. The middle group is named rather than folded into the first: Bastion installs those from npm and runs them on your machine with a profile's credentials in their environment, and what it adds to them is the supervision, the Keychain and the audit line, not a review of the code. The app says so on each one. It is still not trying to be a directory: Docker MCP Toolkit ships hundreds of curated servers, Anthropic ships MCPB double-click install and an official registry, and anything not seeded here is one npm package name or one URL away. The part worth building is the runtime underneath.

Adding an entry to the catalog is a manifest edit and make servers; every generated copy is checked in CI.

Bastion, as one of its own servers

Every other entry in the list is an npm package Bastion downloads and relays to. One is the app. bastion answers MCP in-process — no package, no child, no credentials of its own — and its tools are the window: list and describe servers, install one from the catalog or add any npm package, keep an installed one up to date, create profiles, put credentials in the Keychain, wire clients, and read what is running.

Updating goes through the same two steps the server pane does, and in the same order. check_server_update asks npm what it would install and changes nothing; update_server resolves the package at latest again and re-downloads it, which is equally the retry for a download that failed and the repair for a dependency tree npm would rebuild. Neither runs on a timer — Bastion reaches the registry when something asks it to and not otherwise — so what get_server reports under update is only ever as fresh as the last time somebody asked, and it says unchecked rather than guessing.

It is the one server that cannot be removed. Removing a server takes its profiles, their Keychain entries and its downloaded code with it, which was far too much to mean "not right now" — so every server now has a switch instead. A disabled server stops its children and refuses requests with its own sentence; its profiles, credentials and installed code are untouched, and client configs are not rewritten behind your back.

Three rules hold whatever the profile's write gate says, and they are the reason this is safe to ship:

  1. No tool returns a secret. set_credential has no counterpart, and list_profiles says which variables are set, never what to.

  2. It cannot switch itself off, which would leave no way to switch it back on from there.

  3. It cannot delete itself.

Everything that changes anything sits behind the same per-profile write gate as every other server: with Allow writes off, the mutating tools are absent from tools/list entirely rather than offered and refused, so a model never plans around a tool it cannot use. And every call it serves goes through the same audit line as a relayed one — the server that can change everything is not the server that leaves no trace.

It ships disabled. It is a control plane for a daemon holding every credential you own, so it takes three deliberate acts to reach: switch it on, give it a profile, wire a client.

The licence gate does not apply to it, and that is the gate's own rule rather than a hole in it: what is sold is the relay. This server relays nothing, spawns nothing and holds no credential. It also means an unlicensed user can have their agent set Bastion up, and meets the licence sentence at the point they have something to lose by not having one.

make builtin        # the write gate, the two self-refusals, and the secrets wall

What the audit log sees, and what it does not

Bastion sees the JSON-RPC frames crossing the gateway: which profile, which method, which tool, and the arguments it was called with. A profile can opt into recording what came back as well; that is off by default, because a result is the unbounded half.

A credential is never recorded. set_credential takes a secret as an argument, so its arguments are dropped whatever the setting says, and any value under a key the server's manifest marks secret is blanked. That is a rule in one place — CallCapture — rather than a habit at three call sites, and make builtin plants a canary through set_credential and asserts it never comes back out.

Nothing recorded is written to disk unless you ask for it. By default the log is a bounded ring in memory, cleared when Bastion quits. This is not a matter of intent: every ordinary log line is mirrored to stderr, which for an app started by LaunchServices outlives the process, so payloads take a separate path that never reaches it. make builtin asserts that too, against the real bundle.

Settings › Activity turns on a durable audit log: append-only JSONL under Application Support, 0600, in segments, with retention by age and size. Whether that file carries arguments and results is a second switch, off on its own — keeping a record of which tools ran is a smaller thing to leave on disk than keeping what they were called with.

Each record carries a hash of the one before it, so an edited field, a removed record or a truncated file can be detected. That is the whole claim, and it is worth being exact about: it catches tampering by something that does not know it is a chain, and corruption by something that was not trying. It is not proof against anyone who can write the file, because they can recompute it. Retention drops whole segments for the same reason — a chain cannot lose a record from the middle and still verify.

Export writes the segments plus a manifest.json naming each one, its record count and its digest, with the chain head. The count matters: a chain cannot detect its own truncation, because lopping off the tail leaves a shorter valid chain. Signing is optional and goes in signature.json beside the manifest rather than inside it — a signature written into the bytes it signs is what makes half the signed-JSON formats in the world ambiguous. A signature proves the export came from this Mac and was not altered afterwards; it does not prove the log was not curated before it was signed, and it means nothing to a recipient who has not been given the key some other way.

An agent asking Bastion for recent activity is answered with its own profile's lines — which it already sent and received, so it learns nothing it did not have. Another profile's lines never carry arguments or results.

It does not see what a server then does over the network or on the filesystem. A server that reads a file it was never asked about does so out of Bastion's sight. This is a record of requests, not a sandbox, and the website has to say so in these words too.

Security

A loopback daemon holding every credential you own is the exact shape of CVE-2025-49596 — Anthropic's own MCP Inspector, where a listener plus no CSRF protection meant a page you visited could reach it and execute code — plus the rust-sdk and FastMCP DNS-rebinding advisories, whose root cause was "no rebinding protection by default, because it is only localhost".

Five rules, in the first commit that opened a socket rather than a hardening pass afterwards:

  1. Bind 127.0.0.1 explicitly. Never 0.0.0.0, and not configurable.

  2. Validate Origin on every request.

  3. Validate Host on every request — this is the anti-rebinding rule.

  4. A per-client bearer token, minted at wiring time, kept in the Keychain.

  5. Secrets never written to any config file. The token goes in the client config; the credential stays in the Keychain. What leaks if a .mcp.json leaks is a revocable loopback token instead of a Shopify secret.

The order of 2, 3 and 4 is load-bearing: a rebinding attempt must be refused with 403 whether or not it also guessed a token, or the 401 tells an attacker their Host was accepted.

A remote server adds a sixth, and it is the same rule pointed outward. The five above constrain what may reach Bastion; a URL in the installed list constrains where Bastion may reach, and it is the analogue of a command line — fetch(whatever_you_typed) beside spawn(whatever_you_typed).

  1. A remote endpoint is https to a public host, checked on every request rather than once when it was added, because a name that passed yesterday can resolve somewhere else today. Loopback, private, link-local and the cloud metadata address are refused; so is Bastion's own gateway, which is the sharp one — a "remote server" pointed at 127.0.0.1:8720 would be a way to replay one client's bearer token against every other profile in the app. A cross-origin redirect is refused rather than followed without the credential.

make audit

scripts/audit-listener.sh launches the built bundle and asserts all of it: loopback-only via lsof, a foreign Origin and a rebound Host both refused, the refusal order, unauthenticated and wrong-token requests refused, and that profiles.json holds no value for any variable the manifest marks secret. It replaces cupertino's audit-network.sh, whose claim — no network at all — Bastion cannot make. The property worth keeping was "a claim CI can check", not the particular claim.

scripts/remote-check.swift asserts rule 6 as a pure function of a URL — 45 checks, no app and no network — and make remote-live-check asserts the whole path against Stripe's real server, which needs no credential because an unauthenticated initialize is answered with a 401 that exercises every step but the last.

The app ships with no entitlements file at all. Spawning children and binding loopback need none, and with the sandbox off com.apple.security.network.server is unnecessary. An empty permission set that is true by construction is checkable; one arrived at by deletion is not, so the audit asserts the setting is absent rather than empty.

Status

Built and verified:

Gateway

loopback HTTP, Origin / Host / bearer, hand-written so the checks are auditable

Supervisor

one child per profile, id remapping, backoff, circuit breaker, idle stop

Dialect

dual-era: modern 2026-07-28 and legacy initialize, onto legacy children

Catalog

thirty-three seeded servers, a generator, and a CI drift check

Server store

the user's own list, on-demand npm install, add, remove, and a per-server switch

Remote servers

an https endpoint fronted like any other server — eleven of them in the catalog

OAuth 2.1

discovery, dynamic registration, PKCE and refresh — one consent, every client

Bastion's server

Bastion as one of its own servers, so an agent can manage it — off by default

Keychain

per-profile credentials, per-client tokens

Activity window

what is running, who is attached, and every tool call with its arguments, live

bastion-bridge

stdio hosts reach the gateway over HTTP; starts Bastion if it is not up

Migration

four .mcp.json credential sets moved into the Keychain, configs repointed

make smoke

four concurrent clients, colliding ids, exactly one child, kill -9 recovery

make audit

the five security rules, against the real bundle

make dialect

24 conformance checks across both eras

make builtin

the write gate hides the write tools, and no tool returns a secret

make unit

dialect, HTTP parser, streamed replies, call capture and the audit chain — 513 checks

make audit-check

an export signature through a round trip, key loss included — 18 checks

make remote-check

where a remote server may live, the SSE reader, and the OAuth client — 100 checks

Bastion is what the 2026-07-28 spec calls a dual-era server. A modern client declares its protocol version, identity and capabilities in each request's _meta and needs no handshake at all; a legacy client opens with initialize and is served that way. Both land on the one handshake Bastion took with the child at spawn, and server/discover — mandatory in the modern revision, and implemented by none of these servers — is synthesised from it.

None of the thirty-three catalog servers are modern. The eleven children written here run an SDK whose newest protocol is 2025-11-25, which is what they negotiate. The manifest said 2025-06-18 until a live handshake was actually 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, and declares its own dialect when you add it.

The eleven third-party children were measured, not seeded, and measuring them took asking the right question. A handshake that requests 2025-06-18 gets 2025-06-18 back from every one of them, which measures nothing; asking for 2026-07-28, a revision none of them supports, makes each one answer with its own newest instead. All eleven say 2025-11-25. Two of them, DBHub and Context7, build on the 2.0 SDK that knows 2026-07-28 and still negotiate legacy — the newest revision an SDK knows is not the one a server speaks, which is the whole reason this is measured rather than read off a dependency.

Ten of the eleven remote entries carry a seeded dialect, not a measured one. They all refuse initialize without a credential, so 2025-11-25 is a starting point in the manifest and the first real handshake is what will measure them — RemoteInstance logs dialect drift and Activity shows what was actually negotiated. Cloudflare Docs is the exception: it answers unauthenticated, and was measured at 2025-11-25. Vercel advertises 2026-07-28, which would make it the first modern server in the file, but proposing a version a server does not take fails the connection, so that number goes in after somebody measures it and not before.

Stripe's is the oldest dialect in the file, and it took a credential to find out. mcp.stripe.com refuses initialize without one — it answers 401 with a WWW-Authenticate naming its protected-resource metadata — so for a while the entry carried the default an unmeasured server gets. A live handshake negotiates 2025-03-26, two revisions behind that default, which is exactly why the default is never left in place: the manifest would have claimed a version this server does not speak, and nothing would have looked wrong.

Not built yet: a Homebrew cask, and a login item — which is what would let a type: http client reach Bastion from cold the way a bridge-spawning one already can. The signed release path itself (Developer ID signing, notarization, Sparkle and the appcast) has shipped since 1.0.0; see Working on it.

Seven limitations worth knowing now:

  • Bastion has no login item yet. A stdio client's bridge starts it on demand, so a Claude Desktop entry works from cold. A client configured with a plain type: http URL has no such path and needs Bastion already up — which is the case for the four repos below.

  • The repointed repos need Bastion running. mgcrea-ai/mcp-{shopify,keycloak,appstore-connect}/.mcp.json now call http://127.0.0.1:8720/... instead of spawning anything, so with Bastion stopped those servers are simply unreachable. There is no login item yet; that lands with the release path.

  • A remote server's write gate is a filter, not a boundary. A child gets an environment variable that switches its destructive tools off inside the server. A remote server has no environment, so the gate becomes a list of tool names Bastion will not forward — plus any tool the server annotates as not read-only. Anyone holding the credential can call the same API directly, so the credential's own scopes are the real boundary. For Stripe that means a restricted key, which is what its catalog entry says.

  • A remote server's rate limit is shared. With one process per client each client spent its own budget upstream. Behind one profile they spend one, so a client in a loop can exhaust the limit for every other client of that profile. That is what sharing an identity means, and there is no fix for it at this layer.

  • Server-initiated requests are refused, not routed. Sampling, elicitation and roots get a JSON-RPC error explaining why: a shared instance has no single client to ask, and picking one would hand one project's agent a prompt raised on behalf of another's. The 2026-07-28 spec replaces all three with Multi Round-Trip Requests and puts them on a ~12-month offramp. No server in the catalog uses any of the three, so the full MRTR resume path is unbuilt rather than broken — though a server somebody adds could use one, which is the first thing the open list makes reachable that the closed one did not.

  • subscriptions/listen and list_changed have nowhere to go. A POST that asked for progress is now answered with an SSE stream, so notifications/progress from a child reaches the client that asked for it — a request must carry a progressToken and an Accept: text/event-stream, and anything else is answered exactly as before. That is a per-REQUEST channel, though, and the two remaining gaps need a per-client one: a list_changed names no request, so on a shared instance there is no client it belongs to, and subscriptions/listen needs a channel outliving a request, which is the GET stream the 2026-07-28 revision removed. Both are optional in the spec, and both are still real gaps. A remote server's stream is also still collapsed rather than forwarded, for a reason that is not about routing: RemoteEndpoint.verify refuses a rebinding answer only because the body is buffered until the addresses are known.

  • Mcp-Param-* headers are forwarded but not validated. Doing it needs a cached per-profile tool list to read x-mcp-header annotations from. No server in the catalog annotates a parameter, so it is unreachable with the seeded list and reachable with a server you add.

Working on it

Requires macOS 26.0 and Xcode 26. The Swift half is xcodebuild, named by the Makefile rather than wrapped by it.

make app            # build Bastion.app (Debug)
make run            # build and launch the menu bar agent
make stop           # quit it
make audit          # assert the listener is loopback-only and refuses foreign Origin/Host
make dialect        # assert both protocol eras against a running build
make builtin        # assert Bastion's own server: the write gate, and the secrets wall
make unit           # assert the dialect translation and the HTTP parser
make remote-check   # assert the endpoint rules, the SSE reader, and the OAuth client
make remote-live-check  # assert the remote transport against two real servers: Stripe's 401, Cloudflare Docs' tools/list
make install        # install the Debug build to /Applications

make bundle         # build, stage node + npm, verify the install path, and sign a Release
make build-release  # bundle, then notarize and staple (needs AC_* credentials)
make wiring-check   # assert the config merge leaves other people's files alone
make wiring-check-real  # the same, against your actual client configs (read-only)
make smoke          # prove one supervised server end to end

The manifest and the JavaScript half:

make servers        # regenerate every copy of the server list from servers.json
make servers-check  # fail if any generated copy has drifted
make catalog-check  # fail if servers.json disagrees with the servers themselves
make lint
make format

servers-check and catalog-check point in opposite directions and neither substitutes for the other. The first asserts every generated copy matches the manifest; the second asserts the manifest matches the servers it describes — that a declared variable is one the server actually reads, that a write gate is read as a boolean, and that a variable typed as a switch states the same default its own schema does. Only the second can catch a rename upstream, and only it can catch a boolean.default of false on a setting the server defaults to true, which the profile editor would present as the safe choice. It reads the sibling checkout named by MCP_ROOT (default ~/Projects/mgcrea/mgcrea-ai) and skips, passing, when there is none.

A Debug build carries its own bundle identifier, io.mgcrea.bastion.debug. That is not cosmetic: Keychain items are scoped by app identity, so a shared identifier means a debug build reads, overwrites and deletes the credentials the real app is holding.

docs/keychain.md covers the rest: which scope holds what, why keychain prompts happened and what actually fixed them, and what adopting the data protection keychain would cost — including why the entitlement is worth having for iCloud sync and not worth having to stop prompts.

Seeding a Debug build from a file

The app has a profile editor; a Debug build can also import profiles from a file, which is faster when a checkout is rebuilt often — see DevSeed.swift. Drop this at ~/Library/Application Support/io.mgcrea.bastion.debug/import.json:

{
  "token": "dev",
  "profiles": [
    {
      "name": "prod",
      "server": "shopify",
      "allowWrites": false,
      "values": {
        "SHOPIFY_STORE_DOMAIN": "your-store.myshopify.com",
        "SHOPIFY_CLIENT_ID": "…",
        "SHOPIFY_CLIENT_SECRET": "…"
      }
    }
  ]
}

Launching the Debug app once moves every secret into the Keychain, writes the non-secret values to profiles.json, leaves a bearer token in dev-token, and consumes import.json so it cannot be re-imported. Debug-only by design: a release build that imported credentials from a file anyone could drop in its Application Support directory would be a way to add a profile to somebody else's gateway.

In a Debug build a checkout wins over an install, so dogfooding wants ~/Library/Application Support/io.mgcrea.bastion.debug/dev.json pointing at the checkout:

{
  "node": "/opt/homebrew/opt/node@24/bin/node",
  "repo": "/Users/you/Projects/mgcrea/mgcrea-ai"
}

Licence

Source-available for the app, so a program that holds every credential you own can be read and compiled by the people trusting it; the notarized build is sold.

apps/apple/

Source-available. Read it, modify it, compile it, run your own build. Binary redistribution is the reserved part.

apps/api/, apps/website/, scripts/, docs/

MIT. The audits in particular are more useful copied than reserved.

The signed build

EULA. $14.99 (€14.99 in the EU), every 1.x release, every Mac you own. Thirty days, full refund.

The check is offline and cannot become anything else: scripts/audit-listener.sh asserts on every build that the app binds loopback and nothing else, and an activation call would be the second exception after Updates.swift. What that costs — a refunded key keeps working until the next release — is written down in the EULA rather than left to be discovered.

docs/licensing.md has the reasoning: what is gated and what is deliberately not, why there is a trial as well as a refund, and why hardening the check is refused by construction.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    B
    maintenance
    A macOS computer-use MCP server that grants AI agents mouse, keyboard, and screen control with a robust security model including permission profiles, app deny-lists, and audit logging.
    22
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    An MCP server that exposes macOS-native device trust signals — the facts about a Mac that cannot be gathered from a Linux container or a cloud runner. All tools are strictly read-only.
    22
    MIT

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mgcrea/bastion'

If you have feedback or need assistance with the MCP directory API, please join our Discord server