Skip to content

Architecture

strad is a small gateway process with an unusual deployment shape: the server list in your config decides how many containers exist and how they are wired to each other.

This is strad’s own deployment — two App Platform components, which is the whole of it.

There is exactly one public component — the core gateway. Everything else is a bundle: an internal-only App Platform component that lives on the private network and is never reachable from the internet. The core reaches a bundle at ${bundle.PRIVATE_URL}, which resolves to http://bundle-name:8080 — a private, in-VPC hop (~4ms) with no public-edge hairpin.

Traffic runs core → bundle with one exception, and two things use it. A server that declares paramsWritable: calls back to core to persist a credential that rotated under it; one that declares paramsRefresh: calls back on an interval to re-read a credential somebody rotated in the store. Both go over the same private network and the same STRAD_INTERNAL_TOKEN, because core is the component that holds the store-admin credential and the bundle deliberately holds none. Every non-core component gets STRAD_CORE_URL for it.

A bundle runs one image, and any number of servers may share it, each on its own path. strad’s own deployment has one main bundle: a ghcr.io/tadasant/strad-bundle container that mounts twenty-two MCP servers in one Node process, assembled from fourteen independently-built dependency trees. The staging config maps thirty-one of its thirty-two supplementary-image entries onto twenty paths, because capability variants of one upstream are two entries pointing at the same path. Nineteen of those are baked mounts; /x-twitter, /google-sheets and /grafana are baked with no staging slug pointing at them. The twentieth, /grafana-registry, is a per-slug instance — a mount the host stands up only when the container carries that slug’s own credential (see below). The thirty-second entry runs on the second bundle, below.

Three of those mounts are also mounted again, once per slug, when a deployment gives a slug its own credential rather than sharing the container’s: /telegram, /remote-filesystem-tmp-public and /google-sheets. What counts as “its own” is per tree: telegram wants the whole credential, the artifact store wants a bucket, and google-sheets wants the OAuth client id and secret — never the refresh token, which a human mints later on the mount’s own console page. That is what makes six google-sheets-* slugs over three Google accounts one server rather than three — see Google Sheets.

It has a second bundle, bundle-zimmer-secrets, and the reason is worth stating because it is the one thing a bundle cannot share: process.env. That component runs the same image and is only ever routed /secrets, but it fronts a different parameter store. See Two stores, one gateway.

Three numbers, three different things:

NumberWhat it counts
3App Platform components (core, bundle, bundle-zimmer-secrets)
22MCP servers mounted inside the bundle image
35server slugs in infra/strad.staging.yaml (32 supplementary + echo + deepwiki + anki)

The strad image itself runs in one of two modes, selected by STRAD_MODE:

  • core mounts remote-http servers and core builtins in-process, and proxies to every non-core bundle over private HTTP.
  • bundle mounts only the builtins assigned to its STRAD_BUNDLE and serves them back to core. A bundle component only runs the strad image in this mode if it holds builtin servers; a bundle of supplementary-image servers runs their image instead, and STRAD_MODE never enters the picture.

Core also asks every bundle a second question, on POST /_strad/presence: which of the env keys this deployment expects does the process actually hold? The answer is one of five words per key — absent, empty, unexpanded, placeholder, present — and never a value. It exists because a supplementary server’s env: is baked onto its own component and core cannot read it, so unaided the console’s only honest answer is that it did not check. Both the strad image in bundle mode and the strad-bundle host image serve it, both behind STRAD_INTERNAL_TOKEN, and both fail closed when that token is unset — from one handler, presenceResponse in src/secrets/presence.ts, vendored into the host tree with the vocabulary it answers in. See Secrets.

When core proxies to a strad-image bundle, it calls ${base}/mcp?servers=<slug> with Authorization: Bearer ${STRAD_INTERNAL_TOKEN}. Because that bundle is itself a strad that already namespaces its tools, the adapter strips and restores the inner <slug>__ prefix so you never see bundled__bundled__whoami.

All four server kinds are unified behind a single ServerAdapter interface (listTools, callTool, close):

  • HttpServerAdapter backs remote-http, local-tunnel, supplementary-image, and core→bundle builtin proxying — all of which are Streamable HTTP under the hood.
  • BuiltinServerAdapter runs a builtin module in-process.

Adapters are built once at boot — they hold long-lived upstream connections. Only the toolset is resolved per request, from the caller’s entitlements intersected with ?servers=. Per-hop upstream timeouts are set explicitly to 120_000 ms, because the MCP SDK defaults to 60s at every hop and would silently kill long tool calls.

A local-tunnel adds one bound the others do not have: connectTimeoutMs (default 5s) caps the handshake specifically, as both a request timeout and an abort on the socket. Its upstream is a machine that is off much of the time, and without that cap a closed laptop costs a full minute per request — the run budget stays at timeoutMs, because driving a real desktop is legitimately slow. See the kind’s section.

One connection per slug, shared — so nothing closes it unilaterally

Section titled “One connection per slug, shared — so nothing closes it unilaterally”

HttpServerAdapter caches one MCP client per slug, and every concurrent request for that server runs on it. That is what keeps a full handshake off each tool call, and it is also why closing that client is not a decision one failing call gets to make: the close tears down the transport, and every request in flight on it dies at the same instant.

So the adapter separates retiring a connection from closing it. Each request takes a lease for its duration; a failure retires the connection — no later caller is handed it — but the socket closes only when the last lease is released. A resources/list that fails can therefore never cost an in-flight tools/list its connection.

Retirement is narrowed to failures that mean the connection is unusable: timeouts, closed connections, DNS failures, aborted fetches. An upstream that answers, even with a JSON-RPC error, has a working transport, and dropping it would be pointless churn. Retirement is also scoped to the exact connection that failed, so a slow failure cannot evict a fresh one another caller has since installed.

Resource proxying asks the handshake rather than the error text: if an upstream never advertised the resources capability — which is every tools-only server, i.e. most of them — listResources() answers [] without a round trip. A server that advertises it and answers -32601 is treated the same way. Nothing keys off the wording of somebody else’s error message.

A failing upstream is handled differently depending on who chose that server:

  • The request named it (?servers=secrets) — tools/list fails. A slug the caller asked for by name whose upstream cannot be listed would otherwise contribute an empty list, which is indistinguishable from a server that genuinely has no tools — and that is how a dead secrets slug went unnoticed on staging. This is the same posture ?servers= already had for entitlement, where a server you cannot have is a 424 and never a silently smaller toolset. Any named server failing fails the listing, not all of them.
  • The request did not (no ?servers=, meaning “everything I’m entitled to”) — the failing server contributes zero tools and a log line, rather than taking the other thirty down with it.

There is exactly one exception, and it is local-tunnel. The rule above exists because a silently smaller toolset hides a fault; for a server on a machine you own there is no fault to hide, since being offline is the advertised behaviour of the kind. So a machine that is not answering neither fails the listing nor vanishes from it: the slug keeps its place and contributes one tool, <slug>__troubleshoot_host, which names the machine and the commands to bring it back. ?servers=anki,slack with the laptop asleep is a working Slack plus that tool; ?servers=anki alone is that tool rather than an error, which matters because the console hands out one ?servers=<slug> per server. See the kind’s page.

Two failures qualify — a machine that is offline and one that refuses (401/403, a credential gone stale) — and the tool tells them apart. Anything else from a local-tunnel, a 404 on a wrong path or a 500, is an ordinary broken upstream and fails a named listing like any other kind. The exception is about machines somebody can go and switch on, not about the kind in general.

Either way it reports as mcp.local_machine_offline rather than mcp.list_tools_failed — an Info-severity record, because nothing failed — and the hop’s span and strad.mcp.upstream.errors label it error.kind: local_machine_offline rather than lumping it in with real faults. resources/list makes the same distinction, and has to: resources is advertised unconditionally, so a closed laptop throws there on every session that lists them. Failing a request and declaring a strad fault are different claims — see an offline local-tunnel is not a fault.

The trigger is a tools/list that throws. A server that answers with an empty list is a server with no tools, and no amount of error handling can tell those apart from the outside — which is why core also watches from the inside. A background tool-surface monitor (src/gateway/reachability.ts) probes every mounted slug on an interval and classifies it ok, empty (answered, offered nothing), unreachable (refused, 404’d or timed out), offline — a local-tunnel whose machine is simply switched off, which is that kind’s normal state, stays out of the degraded list and pages nobody — or unconfigured, a slug that declared mayBeUnseeded: for a credential nobody has seeded yet. The last is out of degraded for the opposite reason to offline: nothing is down and nothing is misrouted, the deploy shipped ahead of a token on purpose, and counting it as degraded would red the deploy the feature exists to let through. It is named on its own in the snapshot’s unconfigured list instead. It is tagged with the server kind so a third-party remote — or a laptop that is simply switched off — is not confused with a component we deploy. It logs strad.tool_surface_degraded when a slug’s state changes — or strad.tool_surface_offline / strad.tool_surface_unconfigured, both at Info and pageable by nothing, for the switched-off and awaiting-a-token cases — publishes the current answer on /healthz as degraded, and records strad.mcp.tool_surface. All three leave the container: the log line ships too, against the event allowlist, because a stdout line on App Platform is a line nobody reads. The staging deploy fails when a slug we ship is unreachable — which is neither a remote-http nor a local-tunnel, because neither is ours to run.

The monitor issues tools/list and nothing else, so it sees a slug the way a single-method client does. A failure that only appears when a client mixes methods is invisible to it by construction — see known limitations #40.

Selection is re-checked at tools/call time too: tools/list filters by the current ?servers=, but a client could call any tool name it saw on a prior request, so authorization is re-enforced on the action itself.

A server’s capability — which of its tools may actually be used — is a property of the slug, enforced by the gateway:

- slug: onepassword-ro
kind: supplementary-image
image: ghcr.io/tadasant/strad-bundle:latest
bundle: bundle
path: /onepassword
tools:
allow: [onepassword_list_vaults, onepassword_get_item, ...]
- slug: onepassword-rw # same image, same path, same container
kind: supplementary-image
image: ghcr.io/tadasant/strad-bundle:latest
bundle: bundle
path: /onepassword
# no `tools:` — every tool this server has

allow is exhaustive; deny subtracts and wins over allow; omitting tools: means no restriction. The policy filters tools/list and rejects a tools/call for a withheld tool with JSON-RPC -32600 (InvalidRequest) — before the adapter is touched, so there is no API call and no side effect. Hiding a tool from the listing alone would be decoration: a client can call any name it saw on a previous request.

unmatchedAllowEntries() logs mcp.policy_unmatched_tools when an allow entry names a tool the upstream doesn’t offer. That is the drift alarm: an upstream that renames a tool would otherwise quietly shrink a read-only tier to nothing.

The same predicate runs in CI, before a deploy: the job that boots the bundle image dumps every mount’s real tool names and check-tool-policy matches each config’s tools: entries against them. Anything it cannot enumerate — a remote server, a mount whose surface here is a floor — it skips and names rather than failing.

This is why the deployment is small. Capability used to be a container env var (GMAIL_ENABLED_TOOLGROUPS=readonly), and a component’s env is the union of its servers’ env maps — so a read-only and a read-write variant of one upstream could not share a container, and each variant cost a billed component. With the decision at the gateway, one container runs each upstream at full capability and the variants are just two config entries.

The trade is real and worth stating: read-only is now policy (a gateway that declines), not physics (a process that lacks the tool). See HACKS.md #22.

/mcp owns authentication, ?servers= and the 424. The protocol half — tools/list, tools/call, resource proxying, and the capability check on the action — is buildGatewayServer() in src/gateway/mcpServer.ts, and it has two callers:

  • the /mcp handler, per request, over Streamable HTTP;
  • the console playground (/playground/:server), per request, over an in-memory transport in the same process.

The playground is a human MCP client: a signed-in operator lists a server’s tools and calls them by hand, with no model anywhere in the path. It shares the builder rather than reaching into the registry, so a tool a tools: policy withholds is missing from the human’s list and refused on the human’s call, by the same code that refuses an agent. A second listing path would be a second opinion about what a policy means, and the first tools: rule that changed would leave the console showing a human something they cannot call.

Sharing the builder means sharing its wire, too. An adapter’s error crosses a JSON-RPC boundary on its way to the page, so it arrives as an McpError carrying a message and no class. Where the console has to know WHICH failure it is holding — a local-tunnel whose machine is off, which is expected rather than broken — it reads the degraded surface the gateway substitutes (one troubleshoot_host tool in place of the server’s own) instead of the error, and says so above the tool list. See what the console shows.

The console holds no /mcp token and mints none — see the console playground for why the session stops one layer short of the wire.

The server list drives how containers are grouped:

  • Lightweight builtins colocate in one bundle at roughly $0 marginal cost.
  • A bundle runs ONE image — but any number of servers may share it, on distinct paths, and capability variants may even share a path. Sharing a path means sharing an INSTANCE: one upstream process, one set of credentials, told apart only by the gateway’s tools: policy. Two slugs that need different credentials need different paths, and the bundle host then builds one instance per slug from its own variables.
  • A browser bundle needs real headroom. Chromium costs ~1.06s per launch and peaks ~545MB RSS, and CPU rather than RAM is what binds it, because 1 vCPU serialises rendering. strad’s own deployment puts everything (22 mounted servers, one shared Chromium, the op CLI) in one apps-s-1vcpu-2gb component: the measured working set is ~760MB with two browser servers logging in at once. Several Chromiums in one component is the thing to avoid — share one browser and open a context per call, which is what servers/bundle/host/src/browser.ts does.
  • Deploy target: DigitalOcean App Platform. src/deploy/appspec.ts turns your config into an App Platform spec — the server list decides the container topology. See Deploy model.
  • Images: published to ghcr (ghcr.io/tadasant/strad).
  • Secrets: one model per deployment, declared as gateway.secrets.provider and pluggable behind SecretProvider in src/secrets/provider/. With env (the default) secrets are injected as App Platform SECRET env vars at deploy time and the runtime container holds no cloud credentials. With gcp-parameter-store they are read at runtime from the parameter store, so a rotation of a request-path reference reaches a running gateway without a deploy, and the only secret in the spec on a component that runs strad is the credential for the store itself. A ${NAME} written on a server entry is read from that server’s own namespace before the shared one — which is how an unhosted kind, with no container and no deploy-time environment, gets a namespace of its own at all. A third-party supplementary-image component cannot read the store, so its own env: and its two boot keys are still read from the store at render time and baked onto it. See Secrets.

The strad-<env>-* secrets above configure the gateway itself. A separate, store-agnostic system manages the values a proxied server needs — a bucket name, a service-account email, a private key — as parameters addressed by one canonical path (src/secrets/parameters):

/strad/{env}/mcp/{server-slug}/{kind}/{VARIABLE_NAME}

kind is static today; an oauth kind is a future ticket (issue #32). A parameter is either non-secret (a value safe to read back) or secret (write-and-inject-only). GCP is the first backend behind the interface; a second store implements the same ParameterStore and nothing else changes.

Two stores (GCP). Parameter Manager is the index of everything. A non-secret parameter’s value lives directly in Parameter Manager. A secret parameter’s value lives in Secret Manager, and Parameter Manager holds a __REF__(...) pointer at the Secret Manager version; a :render dereferences it, for a credential that can reach Secret Manager. A secret value is stored base64url-encoded, because :render splices the bytes into the JSON payload as text and rejects anything that looks structurally damaged — see Secrets.

Three credentials, deliberately different. The whole design is that reading a secret value is a power the agent path does not have:

  • The viewer is the secrets MCP server, mounted on the bundle. It lists parameters, reads notes, and reads non-secret values — and cannot read a secret value, because its service account lacks secretmanager.versions.access, so Google answers 403 to the only call that would return one. Its four read tools are list_managed_namespaces, list_parameters, get_parameter (non-secret values only; it refuses a secret), and open_secrets_manager (a deep link that hands a human the console URL); a fifth, refresh_gateway, makes the gateway re-read the store now and touches nothing in it.
  • The admin is the SSO-gated console (/ui/secrets), on the core component only. It reads and writes both kinds. Its credential arrives via consoleEnv:, which the deploy layer puts on core and nowhere else — the bundle never sees it.
  • The resolver reads both stores’ resolved values and hands them to a child process, never returning a value over strad’s own MCP channel. It runs in one of two places depending on what it is resolving for: at deploy time for a supplementary-image (see Deploy model), because that is a container the gateway does not run; and at runtime, in the container, for a bundle: core builtin and for the gateway’s own ${NAME} references under /strad/{env}/gateway/static/ when gateway.secrets.provider is gcp-parameter-store. In that second case the container holds exactly one cloud credential — the resolver key — and no other secret at all. See Secrets.

A baked server opts into resolution with params: true. Everything under /strad/{env}/mcp/{slug}/static/* is resolved and injected as that server’s environment — for a core builtin in-process on every call, for a supplementary-image at deploy time onto its bundle. remote-filesystem-tmp-public is the worked example: it authenticates to GCS with a non-secret REMOTE_FILESYSTEM_TMP_PUBLIC_GCS_CLIENT_EMAIL and a secret REMOTE_FILESYSTEM_TMP_PUBLIC_GCS_PRIVATE_KEY, both set by a human in /ui/secrets and delivered by the resolver. What that server does with them — and why one boolean decides whether a link expires or demands a login — is The artifact store.

params: true is not the only reader of that namespace. A supplementary-image server’s env: ${NAME} references resolve from its own /strad/{env}/mcp/{slug}/static/ before the shared /strad/{env}/gateway/static/, with no flag, which is what lets two servers write the same ${NAME} and be handed different values — see two servers, one variable name. The two paths differ in what they inject: params: takes the namespace whole, substitution takes only names the config already references.