The server-config interface
The core ships with zero MCP servers. Attaching one is a single entry under
servers: in your config. That entry is a discriminated union on kind, and
there are exactly four kinds. This is the whole extension surface of strad.
What every server shares
Section titled “What every server shares”Every server, regardless of kind, has these base fields (from
src/config/schema.ts):
| Field | Default | Meaning |
|---|---|---|
slug | (required) | Identifier, ^[a-z0-9]+(-[a-z0-9]+)*$, max 63. Goes in ?servers=, prefixes every tool, and names this server’s variables on the container — which is why a dash may not double up, lead or trail. |
kind | (required) | remote-http | supplementary-image | builtin. |
entitlements | [] | Roles allowed to reach this server. Empty = nobody (fail closed). |
enabled | true | Set false to configure without exposing. |
tools | — | The capability policy: { allow?: [...], deny?: [...] }. Omitted = every tool. |
timeoutMs | 120000 | Per-hop upstream timeout. The MCP SDK’s own default is 60s at every hop. |
oauth | — | This server authenticates as a person, and strad’s console runs the consent. See below. |
mayBeUnseeded | [] | ${NAME}s this server may deploy without, staying inactive until they land. See below. |
title, description | — | Human-facing metadata. |
tools is .strict() and must set at least one of allow / deny. allow is
exhaustive, deny subtracts and wins over allow, and the gateway enforces the
result on tools/list and on tools/call — a withheld tool is refused with
JSON-RPC -32600 before the adapter is touched. This is what makes a read-only and
a read-write variant of one upstream two entries against the same container; see
Adding a server.
mayBeUnseeded: — deploy the server, seed the token later
Section titled “mayBeUnseeded: — deploy the server, seed the token later”An unseeded ${NAME} used to fail the deploy for the whole fleet, so adding a
server meant seeding its credential before the config PR could merge. Naming the
reference here waives that, for that name only:
- slug: apify kind: remote-http url: https://mcp.apify.com/mcp entitlements: [admin] headers: Authorization: "Bearer ${APIFY_TOKEN}" mayBeUnseeded: [APIFY_TOKEN]It is not a claim that the server works without the value — it is permission for
the deploy to proceed without it. The slug comes up unconfigured, says so on
/healthz and in the console, refuses a named tools/list with a message naming
the credential, and starts serving on its own once a value is seeded.
Three rules keep it from becoming the silence it replaces:
- a name no referencing server waives still fails the deploy, exactly as before;
- a name is waived only when every enabled server referencing it waives it;
- an entry naming a
${NAME}this server references nowhere is a hard error — which is why this is a list of names and not a boolean. A flag beside a typo’d${APIFY_TOKN}would degrade forever in silence; a list leaves the typo unwaived and reports the useless entry.
Full behaviour, including how fast a seeded value takes effect, is in Secrets.
oauth: — a credential a human consents to, in strad’s console
Section titled “oauth: — a credential a human consents to, in strad’s console”Some upstreams authenticate as a person rather than as a service account, and the
artifact is an OAuth refresh token somebody has to mint in a browser. oauth: is
what lets that happen inside strad — Connect Google on the slug’s console
page — instead of through an OAuth Playground round trip and a hand-seeded
parameter.
- slug: google-sheets-tadas412-ro kind: supplementary-image image: ghcr.io/tadasant/strad-bundle:latest bundle: bundle path: /google-sheets-tadas412-ro entitlements: [zimmer] oauth: provider: google account: tadas412@gmail.com scopes: - https://www.googleapis.com/auth/spreadsheets.readonly| Field | Default | Meaning |
|---|---|---|
provider | (required) | google. A literal, not an open enum: a config cannot name a provider strad has no implementation for. |
account | (required) | The Google account this slug is pinned to. Checked against the verified email claim on the ID token, and a mismatch is refused with nothing written. |
scopes | (required) | The exact capability scopes to request, at least one. strad adds openid and email itself, and the config gate refuses an identity scope written by hand. |
clientIdVariable | GOOGLE_SHEETS_OAUTH_CLIENT_ID | Names a variable in this slug’s own /strad/{env}/mcp/{slug}/static/ namespace. |
clientSecretVariable | GOOGLE_SHEETS_OAUTH_CLIENT_SECRET | Same. |
refreshTokenVariable | GOOGLE_SHEETS_OAUTH_REFRESH_TOKEN | The one the console flow writes, on connect and on reconnect. |
The three are variable NAMES, never paths: core composes
/strad/{env}/mcp/{slug}/static/{VARIABLE} from the slug and these strings and
from nothing else. The read/write tier is derived from the scopes rather than
declared — every capability scope ending in .readonly is read-only, anything
else is read-write — because a label would be a second source of truth for
something the scopes already say.
Do not reference these variables as ${NAME}. The renderer injects all three
from the slug’s own namespace, each one only if the store holds it, and the config
gate refuses a reference by name. The reason is a genuine ordering cycle:
render-spec fails the whole app spec on one unresolvable ${NAME}, the refresh
token cannot exist until a human has consented in the console, and the console
page does not exist until the slug is deployed. Injection breaks it — an
unconnected slug deploys, mounts and reports itself degraded, costing its own
capability and nobody else’s. See Connectors.
The four kinds
Section titled “The four kinds”1. remote-http
Section titled “1. remote-http”Proxy an MCP server that already exists on the internet. strad holds the credential; the client never sees it. No container is deployed.
Fields: url (required), headers (a map of header name → value, where values
may reference secrets as ${NAME}).
servers: # remote-http — proxy an MCP server that already exists on the internet. - slug: granola kind: remote-http url: https://mcp.granola.ai/mcp entitlements: [admin] headers: Authorization: "Bearer ${GRANOLA_TOKEN}" # ${...} = a secret reference${NAME} resolves through the deployment’s secret
provider — from the environment by default, or from the
parameter store at runtime. A remote-http server’s url and headers are
re-resolved on every operation, so rotating GRANOLA_TOKEN in a store-backed
deployment takes effect on the running gateway: the adapter notices the endpoint
changed and rebuilds its upstream connection.
2. local-tunnel
Section titled “2. local-tunnel”Proxy an MCP server running on a machine you own — a laptop, a desktop —
reached over a tunnel that machine dials out to (Cloudflare Tunnel, ngrok,
Tailscale Funnel). On the wire it is Streamable HTTP, exactly like
remote-http; strad never dials in and holds no inbound route to your machine.
Use it for servers that are machine-bound by nature: an application’s own MCP add-on, computer use, browser automation driving a real logged-in profile.
Fields: url (required), headers, connectTimeoutMs (default 5000),
recovery.
servers: # local-tunnel — an MCP server on a machine you own. - slug: anki kind: local-tunnel url: https://your-tunnel-host.example/ entitlements: [admin] recovery: machine: "the MacBook" commands: - "launchctl kickstart -k gui/$(id -u)/com.cloudflare.cloudflared" notes: "Anki itself must be open — the MCP server lives inside the app."It is offline most of the time, and that is the point
Section titled “It is offline most of the time, and that is the point”This is the only kind whose upstream is expected to be absent. A closed laptop is not an outage, so three things behave differently:
- The handshake is bounded by
connectTimeoutMsrather than by the MCP SDK’s 60-second default. An offline machine fails in about five seconds instead of a minute. The bound is applied both as a request timeout and as an abort on the underlying socket — only the second covers a host that accepts a connection and then says nothing, which is how a dead tunnel usually presents. - The failure names the machine. A client gets
the machine hosting "anki" is not reachable … it works only while that machine is running the server and its tunnel is up, rather than a generic upstream error — followed by yourrecovery:block, so the commands are in the error too. - It does not take its neighbours down, and it does not vanish either.
?servers=anki,slackwith the laptop asleep returns Slack’s tools, none of Anki’s, and one tool calledanki__troubleshoot_hostexplaining the gap. Every other kind fails the whole listing when a named server cannot be listed — see architecture — and this is the one exception, because for this kind there is no fault being hidden. Selecting it alone returns that one tool rather than an error.
Get the path right — a wrong one is the failure the exemption skips
Section titled “Get the path right — a wrong one is the failure the exemption skips”url carries the path as well as the host, and the exemption above covers
only a machine that is not answering. A machine that answers 404 because the
path is wrong is an ordinary upstream error, so it fails a named listing and
takes its neighbours with it — the exact outcome the kind exists to avoid, from
a typo rather than from a closed laptop.
Take the path from the server you are tunnelling to, not from habit. /mcp is a
common convention and plenty of servers use it, but the Anki add-on above serves
streamable HTTP at the root of its port — its http_path setting defaults to
the empty string — so its URL ends at the hostname. When in doubt, read the
server’s own docs for the URL it tells clients to connect to, and use that.
troubleshoot_host: the tool a machine that is off still serves
Section titled “troubleshoot_host: the tool a machine that is off still serves”When the machine behind a local-tunnel is not answering, strad replaces that
slug’s tools with exactly one of its own:
anki__troubleshoot_host (no arguments)Calling it re-checks the machine and answers with which machine is down, which
of the two failures it is, and the commands to run there. It is listed only
while the host is unavailable — in a ?servers= listing and in an unfiltered
“everything I’m entitled to” one alike — but it is answerable whenever, so
somebody who has just run the commands can ask again and be told the machine is
back. On a local-tunnel slug it therefore shadows an upstream tool of the same
name; see limitations.
recovery: is what fills the answer in:
| Field | What it is |
|---|---|
machine | Whose machine, for a human reading the result — "the MacBook". |
commands | Exact commands to run on that machine, in order. |
notes | Anything else its owner needs to know (a GUI app to open, …). |
All of it is optional, and the tool exists without it: with no recovery: block
strad falls back to generic guidance built from the slug and the tunnel hostname,
plus a link to Running a local-tunnel host. It
will not guess a command — a tunnel name inferred from a hostname is right
often enough to be trusted and wrong often enough to waste an afternoon.
recovery: is returned verbatim to every client entitled to the slug, so it is
for commands and machine names — not credentials. Nothing in it is
secret-resolved, deliberately: a ${NAME} written there reaches a caller as
those six characters, not as a value.
Two failures reach this surface, and the tool tells them apart because their remedies have nothing in common:
- offline —
502/503/504, Cloudflare’s52x, a refused connection, or a handshake that does not finish insideconnectTimeoutMs. The machine is not there. Go and start it. - refused — a
401or403from the far side. The machine is there and said no, which means a credential went stale: an access service token, or the bearer the server on that machine checks. Restarting the tunnel does nothing.
Anything else a machine answers with — a 404 on a wrong path, a 500 from a
server that ran and threw — is an ordinary broken upstream and still fails a
named listing. Nobody at that machine can fix those, so sending them there would
be a lie.
A machine that is off does not read as a broken deployment. On /healthz
that slug’s state is offline — its own word, not unreachable — and it is left
out of the degraded list, so a fleet with a sleeping laptop in it still reports
nothing to go and look at. The state is still there to read; it has simply
stopped being filed as a problem.
A host that refuses is filed as a problem, deliberately: it is answering, a
credential has gone stale, and it will not clear until somebody rotates it. Same
for a 404 or a 500. Only “the machine is off” is exempt.
strad deploys nothing for it — no component, no bundle, no env:. ${NAME}
references in url and headers resolve in strad’s own process, so a tunnel
hostname that rotates can live in the secret store instead of the config.
Those refs resolve from this server’s own namespace first —
/strad/{env}/mcp/{slug}/static/{NAME} — and the shared gateway namespace only as
a fallback, so a tunnel’s credentials live under the server they belong to and can
be rotated without a redeploy. See Secrets.
Running the machine at the other end — a named tunnel, a login item, Cloudflare Access in front of it — is its own page: Running a local-tunnel host.
3. supplementary-image
Section titled “3. supplementary-image”A server served by a container image. strad deploys the image as an internal-only component and proxies to it over the private network.
One bundle runs one image, but that image may host many servers — each on its
own path. strad’s own bundle image mounts twenty-two MCP servers in one process,
and staging’s slugs land on twenty paths of one container.
Two servers may also share a path in one bundle — that is the capability-variant
pattern (gmail-ro and gmail-rw, told apart by their tools: policy). What they
cannot share is a value: a bundle is one process.env, so two servers in one
bundle declaring the same env: key with different values is a schema error,
and two whose ${NAME} is spelled the same but resolves differently out of
their own namespaces is a
render error — for servers that share a path. A slug on a path of its own
(path: /<its own slug>) is a separate instance in the container, so it reads
its own per-slug names
and can hold its own value. Those names exist for every server either way.
Fields: image (required), bundle (required), path (defaults to /mcp, must
start with /), env and headers (maps whose values may be ${NAME} secrets),
plus two that shape how this server gets its values:
-
consoleEnv— env for the core gateway only, the human console’s half of this server.env:lands on the component that answers/mcp;consoleEnv:lands on core, which serves the SSO-gated/uiand nothing an agent can reach. That asymmetry is what lets thesecretsserver hold a viewer key on the bundle and a stronger admin key on core, with the two never meeting. See Adding a server.It has a second, duller use: if this server has a browser view in
/ui, that view renders in core and needs the credential too. Repeat the same${REF}underconsoleEnv:— the same value in both scopes, not a stronger one. Omit it and you get a confusing pair of symptoms: the MCP tools work fine while the console reports the credentials as unavailable, because the key is on the bundle and the page is not.gcsininfra/strad.staging.yamlis the worked example.The deploy layer resolves a
consoleEnv:${REF}at render time and lands the resolved value on core under the declared KEY — the ref name itself is never injected anywhere, which is what keeps the admin credential off the bundle. The console therefore reads the key, not the ref.The two scopes have different collision rules, and this is the trap.
env:is per-bundle;consoleEnv:is merged into the onecorecomponent along with every other server’s. So two servers can safely use the sameenv:key in different bundles, and cannot safely use the sameconsoleEnv:key anywhere. A second server of a kind that already exists (a secondsecretsslug, say) therefore keeps bare names underenv:and slug-namespaces anything it adds underconsoleEnv:—ZIMMER_SECRETS_SECRETS_ADMIN_SERVICE_ACCOUNT_KEY_JSON, which the console maps back down toSECRETS_ADMIN_SERVICE_ACCOUNT_KEY_JSONfor that slug alone. The mapping is a fallback (??=) applied after the two maps are merged, so a namespaced key whose bare formenv:already sets is inert — declare underconsoleEnv:only what the console alone needs.check-configenforces the collision rules; see Two stores, one gateway for the worked example. -
consoleStores— the parameter stores this server’s console page can switch between. One page, N GCP projects, selected with?project=<id>.Absent (the default) the page resolves a single store out of
SECRETS_PROJECT_ID/SECRETS_NAMESPACES/ theSECRETS_*flags, exactly as before. Present, it replaces that reading: the page renders a store picker and every list, create, rotate, note, reveal and delete is scoped to the selected entry. The two shapes are not merged — a config that says what its stores are does not also get an env-derived one.consoleStores:- project: strad-secrets-prodlabel: strad prod # optional; defaults to the project idnamespaces: [/strad/prod/mcp/, /strad/prod/gateway/static/]adminKeyVar: SECRETS_ADMIN_SERVICE_ACCOUNT_KEY_JSONnamespacesStrict: true- project: zimmer-secrets-prodlabel: zimmer prodnamespaces: [/zimmer/production/mcp/]adminKeyVar: ZIMMER_SECRETS_ADMIN_SERVICE_ACCOUNT_KEY_JSONreveal: trueEvery store carries its own credential.
adminKeyVaris required per entry, andcheck-configrejects two entries that name the same one: two projects behind one identity is a strictly larger blast radius than the two identities it replaces, so the collapse cannot be written as one shared key name.Two entries whose different names resolve to one
${REF}is the same collapse, and it is a warning rather than a refusal — because that shape is sometimes right. One service account granted onstrad-secrets-prodandstrad-secrets-stagingis two projects inside one system, and a ban would reject it.check-configandrender-specname the projects and the ref, and a reviewer decides whether they are one system or two. ThemcpStores:twin below refuses the same shape, deliberately: a shared viewer key collapses the per-store capability probe, and there is no correct form of that.Two entries may not declare overlapping namespaces. A path both stores could claim is a console write — create, rotate, delete — landing in whichever project was declared first;
storeForPathrefuses such a path at request time, so without this rule the first symptom is a write failing on a deployment that rendered and deployed clean.A
SECRETS_PROJECT_IDleft over from the single-store shape is warned about when it names a project other than the first store.storeProject()prefers it over this server’sconsoleStores:, so the roster’s copy-pasteablegcloudcommands describe one project while the store page defaults to another. Both the shared-${REF}warning and an overlapping-namespace warning also span the whole deployment, because two secrets slugs are two components andstoreForPathresolves a write against all of their stores at once.adminKeyVarnames aconsoleEnv:key, not a${REF}of its own, andcheck-configenforces three things about it. The key must be declared under this server’sconsoleEnv:. It must not be a key of any server’senv:. And the${NAME}it resolves from must not appear in any server’senv:either, under any key — the key name is the variable, but the ref is the credential, and a name-only check would wave through the same admin key baked onto a bundle asGOOGLE_APPLICATION_CREDENTIALS_JSON.consoleEnv:is the one scope the renderer lands on core and stops, so naming it is what keeps a store-admin credential off the component that answers/mcp.namespacesStrictandrevealare per store, not per page. A page-levelnamespacesStrictwould be checked against the union of every store’s namespaces, so a namespace belonging to a sibling project would pass while this one is selected — a bound that does not bind.namespacesStrictbounds create and not rotate/note/delete, which is the single-store flag’s existing behaviour, unchanged.This is a console-only field. The
/mcphalf has a list of its own —mcpStores:below — because the credential each carries is different. See One console page, many stores. -
mcpStores— the parameter stores this server’s/mcphalf can address. One container, N GCP projects, selected by the namespace a parameter path starts with.Absent (the default) the container resolves a single store out of
SECRETS_PROJECT_ID/SECRETS_NAMESPACES/SECRETS_SERVICE_ACCOUNT_KEY_JSONunderenv:, exactly as before. Present, it replaces that reading:renderAppSpecemits a non-secretSECRETS_STORESdescriptor onto this server’s bundle — project ids, namespaces, and the NAME of each store’s key — and the container reads that. Declaring both shapes is refused rather than resolved to one of them.env:SECRETS_SERVICE_ACCOUNT_KEY_JSON: "${SECRETS_VIEWER_KEY_JSON}"ZIMMER_SECRETS_VIEWER_SERVICE_ACCOUNT_KEY_JSON: "${ZIMMER_SECRETS_VIEWER_KEY_JSON}"mcpStores:- project: strad-secrets-prodlabel: strad prod # optional; defaults to the project idnamespaces: [/strad/prod/mcp/]viewerKeyVar: SECRETS_SERVICE_ACCOUNT_KEY_JSON- project: zimmer-secrets-prodlabel: zimmer prodnamespaces: [/zimmer/production/mcp/]viewerKeyVar: ZIMMER_SECRETS_VIEWER_SERVICE_ACCOUNT_KEY_JSONEvery store carries its own VIEWER credential.
viewerKeyVarnames anenv:key — the scope rendered onto this server’s bundle, which is the component that answers/mcp— andcheck-configrejects two entries that name the same key or whose keys carry the same${REF}. That is a stronger rule here than on the console side: the secrets server derives what each store may do from aprojects:testIamPermissionsprobe of that store’s own credential, so one identity on two projects is two stores that cannot have different capabilities. The container checks the same thing at boot byclient_email.It also rejects two entries naming one project id (the store’s address in every refusal and in the capability report), two entries with overlapping namespaces (a path both could claim is a path whose capability depends on declaration order), a hand-written
SECRETS_STORESunderenv:(it is derived from this field, and one written by hand passes none of these rules), and a second server on the same bundle declaringmcpStores:(one container, one bareSECRETS_STORES, so one list would answer under both slugs).An admin key cannot be borrowed here.
viewerKeyVarmust be a key of this server’s ownenv:, and theconsoleStores:rules above already refuse anadminKeyVarwhose key or whose${REF}appears in any server’senv:.There is no
revealand nonamespacesStrict./mcphas no UI to offer a reveal in, and it has no lax namespace mode to turn off: a path outside every store’s namespaces is always refused. See the/mcphalf for what the tool surface does when two stores disagree about whether a secret value may be read. -
params(defaultfalse) — opt into parameter resolution. Whentrue, everything under/strad/{env}/mcp/{slug}/static/*in the secrets and parameters system is resolved and injected as this server’s env — for a supplementary image at deploy time, scoped onto its bundle. It injects the namespace wholesale: the variable names come from the store, not from the config.It is not the only thing that reads that namespace. A
${NAME}underenv:resolves from/strad/{env}/mcp/{slug}/static/before the shared/strad/{env}/gateway/static/, with no flag and for every server — which is what lets two servers write the same${NAME}and be given different values. See two servers, one variable name. The two paths differ in what they inject:params:injects everything it finds, substitution injects only names the config already references.Every supplementary server’s
env:lands under<SLUG>__<NAME>as well as the bare names, unconditionally. Whether two servers on one bundle can hold two values for one name is decided bypath:, not by the namespace: a slug that is the only one onpath: /<its own slug>is its own instance in the container and reads its own names; two slugs on one path are one instance, and the render fails rather than pick a winner. -
paramsWritable(default[]) — the variables this server may write back to its own/strad/{env}/mcp/{slug}/static/namespace, and read fresh at boot rather than trusting the value the deploy baked.For credentials that rotate on use — an OAuth refresh token that is single-use, a session token the upstream reissues — where holding the baked value across a restart means holding one the upstream has already invalidated. The server persists each new value through core, which owns the store-admin credential; the container itself still holds none.
It requires
params: true, and each entry is a variable name, never a path: core composes the store path from the config’s own slug and name, so there is no spelling of one that reaches another server’s namespace. A component hosting a server with a non-empty list may not haveinstanceCountabove 1 — a rotating credential has one valid value at a time, and the render refuses the topology rather than let two replicas invalidate each other.supplementary-imageonly.- slug: x-twitterkind: supplementary-imageparams: trueparamsWritable: [X_OAUTH_REFRESH_TOKEN]See a credential that rotates on use for the transport, the failure modes and what the grant actually is.
-
paramsRefresh(default[]) — the variables this server re-reads at runtime, so rotating one takes effect on the running container instead of on the next deploy.This is the answer to the one credential path in strad that was still deploy-time. A
${NAME}on aremote-httpserver, abuiltin’sparams:, and a gateway reference all resolve while the process runs; asupplementary-image’s managed parameters are resolved by the renderer and baked onto the component as environment, and process environment is fixed at spawn. So rotating the token behind such a slug meant redeploying by hand.Listing a name here puts it on a manifest the renderer bakes onto the component. The container polls core for those names on
gateway.secrets.paramRefreshSeconds(default 300, floor 30) and updates its own environment when a value has moved. It requiresparams: true— which is also what makes it disclose nothing, since that flag already baked the whole namespace onto this component; what the re-read adds is freshness.- slug: strad-fetchkind: supplementary-imageparams: trueparamsRefresh: [BRIGHTDATA_API_KEY]A listed name may not be another server’s variable on the same bundle. One bundle is one container with one environment, so a refresh that wrote a name a sibling owns would overwrite that sibling’s credential — and it would do it when a human seeds the value, not when anything deploys.
renderAppSpecrefuses that config, whether or not the name is seeded yet.A listed name that nobody has seeded yet is included on purpose: it bakes no variable, and the first poll after somebody seeds it is what puts it on the container — so “seed a credential after the deploy” stops needing a redeploy too. What still needs one is adding a name to this list, which keeps the rendered spec the record of every variable the component may ever hold.
Whether the upstream notices is a property of that upstream. One that reads its key when it builds a client per request picks the new value up on the next call; one that snapshots a credential at mount time and memoises a connection keeps the boot value until the next restart. The bundle host says which at boot rather than leaving it silent.
supplementary-imageonly.See
paramsRefresh:— a rotation that lands without a deploy.
servers: # supplementary-image — a server served by a container image, on one path of it. - slug: good-eggs kind: supplementary-image image: ghcr.io/tadasant/strad-bundle:latest bundle: bundle path: /good-eggs entitlements: [admin]Two entries may even share a path — that is the capability-variant case, where
acme-ro and acme-rw are the same upstream behind different tools: policies.
At deploy time this component gets internal_ports: [8080] and no
http_port — which is what keeps it off the public edge (see Deploy
model for why setting both is a hard API rejection).
4. builtin
Section titled “4. builtin”A module compiled into a strad image. This is how private / internal servers
ship: tadasant-internal builds its own image with its modules and registers them
with entries exactly like this. The same config interface as the public kinds —
nothing special.
Fields: module (a specifier resolved at boot; must default-export a
BuiltinModule = { tools, call(name, args, ctx) }), bundle (defaults to
core = in the gateway process; any other value runs the strad image in bundle
mode as a separate component), options (an arbitrary map passed to the module;
may contain ${SECRET} refs), and params (default false — when true, the
server’s /strad/{env}/mcp/{slug}/static/* parameters are resolved and merged into
options at launch; for a core builtin that happens in-process at runtime).
servers: # builtin — a module compiled into a strad image (for private/internal # servers). The same interface as the public kinds; nothing special. - slug: echo kind: builtin module: "./servers/echo.js" bundle: core entitlements: [admin]Validation refuses to boot on
Section titled “Validation refuses to boot on”strad validates the whole config strictly at boot (a Zod superRefine in
src/config/schema.ts). It refuses to start if any of these hold:
- Two servers share a
slug. - A server grants a role that isn’t defined under
roles:. - Two servers in the same bundle name different
images. One bundle is one App Platform component, and a component runs one image. Many servers per bundle is fine — many images per bundle is not. - A bundle holding a
supplementary-imagealso holds a server of another kind. - A
supplementary-imageis placed in thecorebundle. - A server references a
bundlethat isn’t declared underbundles:. - A
tools:policy is present but sets neitherallownordeny. - Two enabled servers declare the same env key with different values, in a scope where one would silently overwrite the other. See below — the scope is not the same for the two maps.
- A
paramsWritable:orparamsRefresh:entry is not a legal environment variable name (letters, digits and underscores; not starting with a digit; at most 200 characters). Those entries compose a store path, so the rule is checked here rather than at the route — it is the same rule the console applies to a variable a human creates, fromsrc/config/env-name.ts. - An
oauth:connector names a variable that is not a legal environment variable name, or points two of its three fields at names that fold to one parameter (a path lowercases before it hashes, soTOKENandtokenare one stored value — connecting would overwrite one of the connector’s own inputs). - An
oauth:connector lists an identity scope (openid,email,profile) underscopes:, or lists a scope twice. strad requests the identity scopes itself;scopes:is for capability. - An
oauth:connector on a kind nothing injects for (anything butsupplementary-image), or on a path other than/<its own slug>. The second is what makes the credential deliverable: a connector’s variables are emitted under the per-slug name alone, and the bundle host reads those only for the server that is alone on its own path. - A server references one of its own connector’s variables as a
${NAME}. Those three are injected from the slug’s own namespace, and a reference to the refresh token would fail every server’s deploy until a human had completed a console flow that needs the deploy to have happened. See Connectors.
Note what is deliberately not an error: many servers in one bundle, and two
servers on the same path of one bundle. Both are load-bearing — the first is how
twenty-two MCP servers fit in one container, the second is how a read-only and a
read-write variant share it.
Nor is repeating one env key across servers with the same value. That is the
capability-variant pattern working as designed: gmail-ro and gmail-rw repeat
their whole env: on purpose, because putting the credential on only one of them
means disabling that one strips it from its sibling. The rule is same key,
different value.
Failing loudly at boot is the point: a misconfigured gateway never comes up half-wired.
env: may be bare; consoleEnv: must be slug-namespaced
Section titled “env: may be bare; consoleEnv: must be slug-namespaced”The two maps land on different components, so they collide in different scopes — and getting the asymmetry backwards is the easy mistake.
| Map | Lands on | Collides | So a bare name is |
|---|---|---|---|
env: | the server’s own bundle | within that bundle only | fine, unless a sibling on the same bundle declares it differently |
consoleEnv: | the one core component | globally, whatever bundle each server is on | wrong as soon as a second server wants the same key |
A bundle is a component is a container is one process.env — and bundles: is a
list. So two servers on two bundles may both read a bare SECRETS_PROJECT_ID and
mean different stores: the gateway resolves the private URL per bundle
(registry.ts) and the renderer keys its env map by server.bundle
(appspec.ts), so even an identical path: on each reaches its own container.
core has no such fence. consoleEnvVars() merges every enabled server’s
consoleEnv: into one flat environment, so a second server declaring a bare
SECRETS_PROJECT_ID there makes /ui/<one-of-them> render one store’s project
while holding the other store’s admin service account — a working-looking page
pointed at the wrong identity. Slug-namespace it
(ZIMMER_SECRETS_SECRETS_PROJECT_ID); withNamespacedFallback() in
src/ui/resolve.ts maps it back down to the bare name for that slug alone.
Both halves are enforced, and the error names both servers, the key, the scope, and the namespaced spelling that fixes it:
servers "secrets" and "zimmer-secrets" both declare SECRETS_PROJECT_ID underenv: with different values, and they share the "bundle" bundle — one bundle isone container with one environment, which is the union of its servers' env:maps. One silently overwrites the other. Give one of them a slug-namespacedname (e.g. ZIMMER_SECRETS_SECRETS_PROJECT_ID).What boot does not catch: a key that isn’t a key
Section titled “What boot does not catch: a key that isn’t a key”An unknown key is not an error at boot. Zod strips what it does not recognise,
so consoleEnvs: where you meant consoleEnv: parses clean, renders clean, and
deploys clean — and does nothing. That is not a hypothetical; it is the shape of a
misconfiguration that has shipped.
Boot stays lenient on purpose (an image that rejected a key from a newer config
would turn a rollback into an outage), so the strictness lives one layer up:
check-config treats
an unknown key as a hard failure and is meant to run on every PR that touches a
config. If you edit a config anywhere, run it:
npm run config:check -- --config infra/strad.staging.yamlNor an instanceSizeSlug that names no plan — and that is the same split
Section titled “Nor an instanceSizeSlug that names no plan — and that is the same split”bundles[].instanceSizeSlug is a plain string in the schema, and renderAppSpec
copies it into instance_size_slug verbatim. So a plausible-but-nonexistent
apps-s-1vcpu-512mb — the 512 MiB plan is really apps-s-1vcpu-0.5gb — parses
clean, renders clean, merges, and fails at the very last step:
error validating app spec field "services.instance_size_slug":unknown instance size "apps-s-1vcpu-512mb"That is how prod deploys went down on 2026-08-30, and it fails late twice over: a bundle carrying no server renders no component, so it is never sent to DigitalOcean at all, and a wrong slug on one sits dormant until some unrelated change places a server there — weeks later, past the merge.
So the closed set lives at the deploy gate, in src/deploy/instance-size.ts, and
both halves of that gate check the declaration rather than a rendered spec —
which is what makes an empty bundle answer for its slug on the pull request that
writes it:
check-configmakes it a hard failure, naming the bundle, the slug and every accepted value.render-specrefuses before the deploy does any work, beside the check that refuses a mutable core image.
Accepted are the fourteen current App Platform plans plus the ten legacy
basic-* / professional-* ones, from the two tables on the
pricing page.
A legacy plan is a warning, not a refusal: App Platform accepts one only from
an app created before 7 May 2024, and nothing in a config says when its app was
created — refusing would make a valid config for such an app undeployable. Both
strad apps were created 2026-07-12, so on either one a legacy slug would fail the
deploy, and the warning says so.
Why the schema stays lenient is the same argument as for unknown keys, with one
addition: nothing at runtime reads instanceSizeSlug. Only the renderer does. A
field with no runtime meaning must not be able to stop a container from starting,
and a schema that refused a plan it had not heard of would do exactly that to
every container running an image older than the config.
gateway.secrets
Section titled “gateway.secrets”Where ${NAME} resolves from, and when. One block, one provider, per deployment.
gateway: name: strad env: prod secrets: provider: gcp-parameter-store # default: env ttlSeconds: 3600 # how long one reading of the store is reused per process; bounds a ROTATION made outside the console negativeTtlSeconds: 600 # bounds how long an ADDITION made outside the console can go unnoticed envFallback: true # keep process.env behind the store, for migrationprovider: env is the default and involves no vendor. gcp-parameter-store
reads /strad/{env}/gateway/static/ at runtime, so a secret added or rotated in
the store reaches a running gateway without a deploy. Setting it to
gcp-parameter-store without a store credential is a hard boot failure, not a
silent downgrade to env vars. Full model: Secrets.
auth.staticTokens
Section titled “auth.staticTokens”The credential agents present to /mcp.
auth: staticTokens: enabled: true # default store: env # default, and the only valueenabled: false builds an empty token store rather than turning the check off:
/mcp still demands a token, and no token can satisfy one. Fail closed.
store is a reserved field, and no code reads it. env — a JSON array of token
records in STRAD_TOKENS — is the only implementation, and the gateway builds it
from enabled alone. The field is declared so that a database-backed token store
lands as a second enum value instead of as a new key in the config contract, and
it is a single-value enum so a config cannot name a store strad does not have.
That datastore is the subject of known limitation #6.
The token model is in Auth.
Where the config comes from
Section titled “Where the config comes from”Config is loaded from two sources, in priority order (src/config/load.ts):
STRAD_CONFIG_YAML— an inline YAML document. This is how the stock OSS image is configured with zero rebuild: hand it a config via env and it runs.STRAD_CONFIG— a path to a file. This is how an image that bakes its config in works (e.g.tadasant-internal).
STRAD_CONFIG_YAML is the config document, not the config file: a spec
rendered by render-spec carries it re-emitted without its comments, because an
App Platform spec has a size the API refuses past and a strad config is mostly
prose. The values are identical — the round trip is checked before it is used —
so nothing about how a config is written changes. See the deploy
model.
The full, annotated example lives at
strad.config.example.yaml
in the repo. To actually wire a server end-to-end, see Adding a
server.