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.
The whole system
Section titled “The whole system”This is strad’s own deployment — two App Platform components, which is the whole of it.
Core and bundles
Section titled “Core and bundles”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 three 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.
And the bundle host asks it which
store-served instances
to run, and what each one holds. All three go over the same private network,
authenticated with the component’s own STRAD_INTERNAL_TOKEN, because core is
the component that holds the store-admin credential and the bundle deliberately
holds none. Core reads the caller’s component off that token and answers only
for servers on it. 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 thirty-one MCP servers in
one Node process, assembled from twenty-two independently-built dependency trees.
The staging config maps thirty-nine of its forty-one supplementary-image
entries onto twenty-seven paths, because capability variants of one upstream are two
entries pointing at the same path. Twenty-six of those are baked mounts;
/x-twitter, /google-sheets, /grafana and /aws-device-farm are baked with no staging slug
pointing at them. The twenty-seventh, /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 other two entries run on bundles of
their own, below.
Seven 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, /grafana, /google-sheets, /gmail,
/google-docs and /onepassword. What counts as “its own” is per tree:
telegram wants the whole credential, the artifact store wants a bucket, grafana
wants an origin and a token, 1Password wants a service-account token, and
google-sheets, gmail and google-docs want 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 — and
what lets one bundle hold several 1Password accounts, each at its own
/onepassword-<name>, without one token answering for another’s vaults
(how).
gmail and google-docs are the two with two auth modes, and the collapsed
bundle carries both.
The conventional /gmail mount authenticates as a service account impersonating
a Workspace user — gmail-ro, gmail-rw and gmail-rw-external are three slugs
over it — while a per-slug /gmail-<account> mount authenticates as a person,
through an OAuth refresh token minted by that person in the console. The two sets
of variables sit on one container, so a per-slug gmail mount reads every
per-account name from its own slug prefix or not at all: inheriting the bare
service-account trio would serve a Workspace mailbox under a slug named for a
personal account, and nothing about that would read as broken. google-docs is
the same shape: /google-docs impersonates a Workspace user for the
google-docs-ro / -rw / -rw-external slugs, and a per-slug
/google-docs-<account> mount drops that service account the same way.
A hosted server can also be a CLIENT of core. With a flow-through
grant (reaches: on it, reachableFrom: on the target), it opens an MCP
session at /_strad/reach/{its slug}/mcp with its component’s own internal token,
and core serves it buildGatewayServer over exactly the granted tools, with the
grant’s pinned arguments checked on every call. The target’s credential never
leaves the target’s container. The console is a client in the other direction:
/apps/{slug} renders a server’s MCP App in a sandboxed frame
and runs its tool calls as the signed-in human, through the same builder.
A store-served instance is the same per-slug mount with nothing on the
container. A server on path: /_instance/{tree}/{slug} is listed to its bundle
by core (GET /_strad/instances), and the host builds a telegram, google-sheets
or grafana instance from that slug’s own namespace, which it reads through core
(GET /_strad/instances/{slug}) rather than from its environment. It re-syncs on
STRAD_INSTANCE_SYNC_SECONDS (default 60, floor 15) and on the first request for
an instance it does not hold yet, so a slug a runtime write creates is served on
that request, with no deploy.
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.
A third, bundle-whatsapp, holds the one WhatsApp
linked-device socket for whatsapp-zimmer, at one instance: a second replica
would open a second socket on the same device. It becomes a fourth component
once that slug is enabled; while it is disabled the bundle renders nothing.
Three numbers, three different things:
| Number | What it counts |
|---|---|
| 3 | App Platform components (core, bundle, bundle-zimmer-secrets) |
| 31 | MCP servers mounted inside the bundle image |
| 44 | server slugs in infra/strad.staging.yaml (41 supplementary + echo + deepwiki + anki) |
The two modes
Section titled “The two modes”The strad image itself runs in one of two modes, selected by STRAD_MODE:
coremountsremote-httpservers andcorebuiltins in-process, and proxies to every non-core bundle over private HTTP.bundlemounts only thebuiltins assigned to itsSTRAD_BUNDLEand serves them back to core. A bundle component only runs the strad image in this mode if it holdsbuiltinservers; a bundle ofsupplementary-imageservers runs their image instead, andSTRAD_MODEnever 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 the component’s own
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 <token>, where the token
is the one core derives for that component.
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.
A supplementary-image component gets the same header, on the same terms — the
gateway adds it to every such adapter rather than asking each config entry for a
headers: block. A configured Authorization wins, because that one names an
upstream’s credential rather than strad’s.
Adapters
Section titled “Adapters”All four server kinds are unified behind a single ServerAdapter interface
(listTools, callTool, close):
HttpServerAdapterbacksremote-http,local-tunnel,supplementary-image, and core→bundle builtin proxying — all of which are Streamable HTTP under the hood.BuiltinServerAdapterruns 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=. A tool call also carries a small per-request
CallContext — the client’s advertised capabilities, and the principal that
authenticated the request. It grants nothing, since whether this caller may reach
these slugs was settled before any adapter ran; it is there for an in-process
server whose answer depends on who is asking. One does: the roster editor
bounds a write by what its author already reaches
(runtime config).
The third thing it carries is the caller’s confirmation channel: the
X-Elicitation-Url a /mcp request named, checked against
gateway.elicitation.allowedUrlPrefixes. The HTTP adapter forwards it as core’s
own _meta, and only to a supplementary-image slug that declares
confirmations: true, where gmail’s send_email asks a human before it sends (confirmations). With it goes that slug’s
confirmationHeaders:, resolved by core. For such a slug the context carries
two more things. One is the caller’s abort signal: when the caller goes away,
or the hop’s timeout runs out, core aborts that call’s own HTTP request to the
bundle, so a stateless bundle sees the drop and never sends. The other relays
progress, so the bundle’s keepalives reach a caller that asked for them. 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.
Events: a feed polled through the same door
Section titled “Events: a feed polled through the same door”An adapter may also serve MCP Events (draft SEP-3415, poll mode):
optional supportsEvents, listEvents and pollEvents. The HTTP adapter reads
the first from the upstream’s handshake and gates listEvents on it, the way it
gates resources. On initialize, the router asks every selected slug and
advertises io.modelcontextprotocol/events if any of them serves events. Event
names are slug-prefixed like tools, and a poll is checked against the same
selection a tool call is. Core holds no event state; the cursor is the
upstream’s. See MCP Events.
The raw data plane: files that do not fit in a tool result
Section titled “The raw data plane: files that do not fit in a tool result”MCP carries text. A tool result is a message in a conversation, so anything travelling inside one is paid for in context — which makes it the wrong place for a file. And a mount cannot fall back to naming a path instead: it runs in its own container, so a path it writes names nothing the caller can open.
So two routes sit beside /mcp, and they are the same idea in both directions:
| Route | Adapter method | Body | For |
|---|---|---|---|
POST /mcp-upload/<slug>/<tok> | postRaw | buffered | getting bytes into a mount |
GET /mcp-download/<slug>/<tok> | getRaw | streamed | getting bytes out of a mount |
Both are generic: core resolves the slug to a supplementary image, forwards to
that container’s /<mount>/upload/<tok> or /<mount>/download/<tok> on that
component’s internal token, and otherwise stays out of the way. Nothing about either route
knows which mount is behind it.
Neither is authenticated by a header, and that is the design. The caller is an agent session that holds no strad API key and cannot be given one, so the token in the path is the whole credential. The mount that minted it is the only thing that can check it, and does.
The asymmetry between them is deliberate. An upload is bounded — the artifact
store caps it at 100MB — so postRaw may buffer. A download is not bounded, so
getRaw pipes: the response body streams through core without ever being
resident, and the gateway’s memory does not grow with the file. getRaw’s
timeout differs for the same reason. postRaw aborts the whole operation after
timeoutMs; applied to a download that would be a size limit wearing a clock’s
clothes, so getRaw resets its timer on every chunk instead. It bounds a
transfer that has stopped moving, and lets a large, healthy one run as long
as it keeps going. That bound applies at both ends: the timer is driven from
inside the pipeline, so a caller that stops reading for longer than timeoutMs
is dropped too, which is what stops a stalled reader holding an upstream
connection open indefinitely.
A body that fails partway is destroyed, not ended. A stream ended tidily
after a failure is a truncated file delivered under a 200, and a half-written
archive that looks whole is worse than an obvious error. See streamFailure in
src/http/async-route.ts.
The four users of this today are the artifact store’s get_upload_url (in),
Slack’s slack_download_file (out), described in
the Slack attachment flow, WhatsApp’s
whatsapp_get_attachment (out), described under
WhatsApp attachments, and LiveOak’s
get_statement (out), described under the link.
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.
A minted bearer is an endpoint that changes
Section titled “A minted bearer is an endpoint that changes”The adapter resolves its endpoint — URL plus headers — on every operation, and
rebuilds the connection when the digest of that endpoint changes; that is how a
rotated ${SECRET} reaches a running gateway. A remote-http server with
oauthRefresh:
rides the same seam: its endpoint thunk asks an AccessTokenMinter
(src/gateway/adapters/oauth-refresh.ts) for the current access token and puts
it in the Authorization header, so a refresh is just an endpoint that changed.
One minter per slug, built by the registry and reachable from that slug’s thunk
alone — there is no cache shared between servers to key wrongly — and everything
a minter holds is tied to the refresh token the store handed it, so a re-seed to
another account drops the old one’s token before the next request.
The adapter adds one thing for it: a 401 from the upstream is reported back to
the minter with the endpoint that was refused, the connection carrying it is
retired, and the operation is tried once more against whatever the thunk
resolves next. A 401 is the upstream refusing the request before running it,
so the retry cannot run a tool twice.
A listing gets a second look; a tool call does not
Section titled “A listing gets a second look; a tool call does not”tools/list and resources/list retry a transient upstream failure: a
429 or 500/502/503/504, a connection that closed under the request, or
a socket reset or refused connect. Three tries, with jittered exponential
backoff from 250ms — under a second of sleep in all — and no new try once 5s
have gone, which keeps a retry inside the 10s deadline the reachability probe
puts around a listing. Each try is still bounded by timeoutMs. Intermediate failures log
nothing beyond the per-hop error counter and span; mcp.list_tools_failed and
mcp.list_resources_failed fire at ERROR only once the budget is spent. One bad
second at a hosted server’s edge therefore does not page, and an upstream that
is still failing a moment later still does.
Everything else fails on the first answer: any other 4xx, -32601, a
timeout, a DNS or TLS failure, a slug waiting on an unseeded credential, and a local-tunnel machine
that is off. A 401 on a minted credential keeps its own single retry above.
tools/call is never retried, because a 5xx does not say whether the tool
ran. Each listing retries on its own, so an upstream answering 429 to every
request sees up to three per listing.
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/listfails. 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 deadsecretsslug 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.
A listing that works is also not a call that works. A mount whose login went stale
lists every tool and fails every call, and only the bundle knows — its /healthz
names each mount it holds and whether that mount is degraded. So in core the
monitor also reads each bundle’s /healthz on the same interval
(src/gateway/mount-health.ts), one request per bundle, and carries the answer
beside the surface state: a mount word (ok / degraded) on each
supplementary-image slug it read, mountDegraded and mountUnread lists on
/healthz, the
strad.mcp.mount_degraded
gauge, and strad.mount_state on change, at Info. Beside and never
inside: degraded stays “serves no tools”, which is what the deploy gate and the
fast retry read. What crosses from the bundle is one bit per mount, matched onto
the config’s own slugs by path; the bundle’s reason for it is upstream text and is
dropped where it is parsed.
The bundle’s /healthz also answers for the process itself. Once its event loop
has been saturated for a minute it answers 503, which core reads as the bundle
unreachable. A liveness_health_check restarts the container on that 503 or on
a probe that times out. The host’s own watchdog exits after three minutes even
without the check. See what a strad-bundle component is rendered
with.
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.
The config is not necessarily fixed for the life of the process
Section titled “The config is not necessarily fixed for the life of the process”A deployment with
gateway.configStore.enabled merges a stored
overlay over the checked-in servers: list, which means the roster core serves
can change while core is running. Every component below the config still gets
one config for its whole life, because a change is a rebuild rather than a
mutation.
ReloadableGateway (src/gateway/reload.ts) owns that. http.createServer is
handed a stable dispatch function, and what sits behind it — the registry, the
Express app, the tool-surface monitor — is built fresh on the new config and
installed with one pointer assignment. The listening socket outlives the rebuild,
so nothing re-binds a port and no connection is dropped; the next request routes
on the new roster.
The outgoing registry drains rather than closing. Its adapters hold live MCP
connections, and a request that was mid-tools/call at the moment of the swap is
still holding one, so closing underneath it would turn a successful call into a
transport error for no reason. It is closed 30 seconds later — long enough to
outlast a request already in flight, short enough not to leak a connection per
reload. A call still running at the end of that window gets its transport closed,
which is what a redeploy would have done to it.
A rebuild that throws leaves everything exactly as it was: the new instance is never assigned and the old registry is never drained, which is what lets a bad roster edit be a refusal rather than an outage.
Both of strad’s doors can make that edit. The console at /connectors is the
human one; the connectors builtin (src/servers/connectors.ts) is the same
roster as MCP tools, so an agent can add a remote-http server and select it on
its next request. They share one implementation of the rules and one rebuild —
RuntimeConfig.assertAcceptable decides what may be written, RuntimeConfig.apply
validates the whole merged document — because two answers to “may I add this” is
one answer too many. The builtin ships in the image and is mounted only by a
deployment that declares it.
Capability (read-only vs read-write)
Section titled “Capability (read-only vs read-write)”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 hasallow 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.
One MCP server object, two doors
Section titled “One MCP server object, two doors”/mcp owns authentication, ?servers= and the 424. Authentication there is
two credentials and exactly two: a static strad_<env>_<pubid>_<secret> token,
and — on a deployment that configures auth.mcpOAuth — an access token from
strad’s own OAuth 2.1 authorization server, which authenticates the human behind
it with Google. The prefix on the presented credential decides which, and it
decides exclusively, so a deployment running no authorization server takes the
code path it always did. Everything downstream is identical: both resolve to a
Principal whose roles come from the server side, and ?servers= narrows the
entitled set the same way for each. The authorization server itself is
src/auth/mcp-oauth/ behind src/http/oauth.ts, mounted on core only and
only when the flow can complete end to end; the model is in
Auth. 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
/mcphandler, 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.
What /mcp answers when strad itself fails
Section titled “What /mcp answers when strad itself fails”A failure strad does not otherwise classify — anything that throws at or below
the authentication step, including the MCP SDK driving a caller-supplied body —
is a 500 {"error":"internal_error"} and nothing more. No message, no class,
no upstream text: the same rule the rest of this endpoint keeps
(telemetry). The class is written to the container’s
stderr under strad.route_failed and exported as that event’s error.kind, and
the request is counted with an internal_error outcome so the failure shows up
in the request metrics rather than only in a log.
That answer exists because the alternative is the container. Express 4 drops the
promise an async handler returns and routes its rejection nowhere, and this
process treats an unhandled rejection as fatal — so without a guard, a defect
that costs one caller one response costs every concurrent request on the
component. Every async route in strad is therefore mounted through one wrapper,
asyncRoute in src/http/async-route.ts, which awaits the handler and hands
the failure to that route’s own renderer: JSON for /mcp and the machine
routes, an HTML page for the console.
One consequence is worth stating, because it is a narrowing. These failures are
no longer fatal, so they no longer reach the exception
reporter, which saw them as unhandled
rejections and carried a stack. What replaces the stack is the metric — an
internal_error outcome is countable and alertable, where a restart was neither
— plus the event’s kind and the class on stderr.
Sizing bundles
Section titled “Sizing bundles”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 (31 mounted servers,
one shared Chromium, the
opCLI) in oneapps-s-1vcpu-2gbcomponent: 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 whatservers/bundle/host/src/browser.tsdoes. - CPU-bound mounts work off the event loop. image-diff and svg-tracer run each call in a worker thread, one at a time per process, so a large diff (up to ~400MB at its 10-megapixel limit) neither stalls the other mounts nor stacks a second peak beside Chromium.
- Data a mount needs is baked at build time, not fetched at boot. nutrition-facts downloads its USDA and MenuStat releases in its Dockerfile stage, pinned by SHA-256, and loads the ~2 MB result lazily on first use; the bundle has no disk that outlives a deploy.
Platform, images, and secrets
Section titled “Platform, images, and secrets”- Deploy target: DigitalOcean App Platform.
src/deploy/appspec.tsturns 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.providerand pluggable behindSecretProviderinsrc/secrets/provider/. Withenv(the default) secrets are injected as App Platform SECRET env vars at deploy time and the runtime container holds no cloud credentials. Withgcp-parameter-storethey 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-partysupplementary-imagecomponent cannot read the store, so its ownenv:and its two boot keys are still read from the store at render time and baked onto it. See Secrets.
The secrets and parameters system
Section titled “The secrets and parameters system”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 names where the value came from — static for a value a human typed,
oauth for one strad’s consent callback minted — and both resolve identically,
oauth first. 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
secretsMCP 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 lackssecretmanager.versions.access, so Google answers403to the only call that would return one. Its four read tools arelist_managed_namespaces,list_parameters,get_parameter(non-secret values only; it refuses a secret), andopen_secrets_manager(a link that lands a human on the console page the value lives on); 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 viaconsoleEnv:, 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 abundle: corebuiltin and for the gateway’s own${NAME}references under/strad/{env}/gateway/static/whengateway.secrets.providerisgcp-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/* and /strad/{env}/mcp/{slug}/oauth/* are
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}/oauth/ and /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.