The fleet runtime
anvil serve mcp <dir> serves ONE bundle to ONE process. An estate of
dozens of bundles served to several agents needs more: many bundles on one
MCP surface, a name for who is calling, bounded rate/spend per caller, and a
way to notice when a deployed bundle’s behavior stops matching what it was
compiled to claim. The fleet runtime is those four pieces, layered onto the
same safety hot path every other Anvil surface already shares — nothing here
is a second execution primitive.
Single-bundle behavior is unchanged unless you opt in. Every piece below
defaults to exactly what a bundle already did before the fleet runtime
existed: unmounted (anvil serve mcp <dir> without --fleet), anonymous
with every scope (no principal directory configured), and unlimited (no rate
or spend config set).
Multi-bundle serving
Section titled “Multi-bundle serving”anvil serve mcp <workspace-root> --fleet<workspace-root> names a directory, not a single bundle — discovery is the
same discoverBundles function @anvil/console
uses to browse a workspace (lifted into @anvil/generators
so the two can never disagree about what counts as a bundle). A bundle is
any directory carrying a canonical air.yaml/air.json.
Every discovered bundle’s tools are mounted onto ONE MCP server, each under
a stable prefix derived from the bundle’s workspace-relative path (its id
from discovery) — once there are two or more bundles:
billing/ -> billing__list_invoices, billing__create_refund, ...shipping/v2/ -> shipping_v2__list_shipments, ...The prefix comes from the bundle’s own directory, folded to an MCP-safe
name — never from air.service.id alone, since two bundles can legitimately
share a service id across environments. An approved operation’s OWN tool
name (op.mcp.toolName, the anvil/operation_id in _meta) is never
renamed to make room for a fleet — only the wire name a caller dials gets a
prefix in front of it.
With exactly one bundle discovered, nothing is prefixed. There is
nothing to disambiguate, so --fleet against a workspace root that holds a
single bundle serves that bundle under its own tool names — the wire name,
_meta, annotations, and every CallToolResult are the same values the
bundle would produce served without --fleet. This is exactly what “single-
bundle behavior is unchanged unless you opt in” means for the two-writes-not-
one case where --fleet IS passed against a one-bundle workspace; it is
proven directly, not just documented, by a test that builds the same bundle
both ways and diffs the two responses
(fleet.test.ts, “a
single-bundle fleet answers tools/list and tools/call byte-identically to
the same bundle served alone”).
Collisions are refused, never silently resolved. If two bundles would
mount the same prefixed tool name, buildFleetServer
(packages/mcp-runtime/src/fleet.ts)
throws FleetToolCollisionError naming both bundle ids and the tool name —
the CLI reports it and refuses to start rather than dropping or aliasing
either tool.
Composition happens through the SDK’s own public wire contract: each
bundle’s McpServer is built exactly as anvil serve mcp builds it (same
function, same options, same execution semantics — see
buildMcpServer), connected to an
internal MCP Client over an in-process transport pair
(InMemoryTransport.createLinkedPair), and the fleet server re-registers
each discovered tool under its prefixed name, forwarding every call to that
internal client. A bundle served through the fleet answers tools/call
byte-identically to the same bundle served alone.
/readyz
Section titled “/readyz”The fleet starts a small HTTP listener (ANVIL_FLEET_READYZ_PORT, default
8787) serving /readyz, which lists every mounted bundle’s own certified
state:
{ "ready": false, "bundles": [ { "id": "billing", "serviceId": "billing", "toolCount": 12, "certifiedHash": "sha256:...", "certificationStatus": "passed", "ready": true }, { "id": "shipping", "serviceId": "shipping", "toolCount": 8, "ready": false } ]}certifiedHash/certificationStatus come from each bundle’s own
certification.json (anvil certify), read AND re-verified against the
bundle’s current content by the CLI (verifyCertification,
@anvil/generators — the same freshness gate anvil deploy checks a plan
against) — @anvil/mcp-runtime never touches the filesystem or depends on
@anvil/generators (that dependency runs the other way, so the deployed
serving path stays thin). A bundle is ready only when its certification is
BOTH status: "passed" and its recorded bundleHash still matches what’s
on disk right now: a stale certification (a compiler-owned file edited after
anvil certify ran), a copied-in certification.json from a different
bundle, or one that otherwise no longer matches is reported ready: false
with a reason explaining why — readyz never trusts a "passed" status
string it hasn’t re-verified. An uncertified bundle still serves; /readyz
just says so, and the whole fleet’s ready folds to false until every
mounted bundle is certified passed AND fresh.
Principals
Section titled “Principals”A Principal ({ id, scopes[] },
packages/runtime/src/policy.ts) names
WHO is calling — resolved once per MCP session, threaded through
ExecuteContext.principal, and checked against an operation’s own
auth.scopes before any upstream call, alongside every other pre-flight
gate (approval, wire, retry bounds, idempotency carrier). A principal
missing a required scope is refused with policy_denied
(details.code: "policy/scope_denied") — never retried, and never a byte
reaches the upstream host.
Default: the anonymous, every-scope principal — but ONLY when no directory
is configured at all. Leave ANVIL_PRINCIPALS unset and every call
resolves to { id: "anonymous", scopes: ["*"] }, which satisfies any
auth.scopes requirement — this is what keeps single-bundle behavior
byte-identical.
Configure named principals with ANVIL_PRINCIPALS, either inline
(token:id:scope1,scope2;token2:id2:scope3) or as JSON
({"tok_abc": {"id": "alice", "scopes": ["orders.read"]}}):
- streamable-http: the caller’s bearer token looks itself up in the directory.
- stdio:
ANVIL_PRINCIPAL=<token>names one principal for the whole session (one caller per process lifetime).
Once a directory IS configured, an unresolved caller is refused
fail-closed — never anonymous. A missing, mistyped, or unlisted
ANVIL_PRINCIPAL/bearer token on a session with a non-empty ANVIL_PRINCIPALS
is a configuration/auth error, not “no policy configured”: granting the
anonymous principal’s scopes: ["*"] here would silently hand every scope to
an unauthenticated caller. execute() refuses before validation, before auth
material is resolved, before a single byte reaches the upstream host, with
policy_denied (details.code: "policy/principal_unresolved") — never
retried. The record’s principalId is "unresolved" for this refusal, never
"anonymous", so the two cases stay separately auditable.
Every ExecutionRecord carries the resolved principalId — never the
token that resolved it. principalId defaults to "anonymous" when
nothing is configured, so existing record consumers (anvil observe --from-records, the drift alarm below) see one extra, always-present field
rather than a breaking schema change.
Credentials
Section titled “Credentials”Every mounted bundle shares one ANVIL_AUTH_PROFILE deployment profile by
default, and credentialProfileName (packages/runtime/src/auth.ts) adds
only the source’s own security-scheme suffix (credentialProfile, e.g.
oauth) on top of it — so with --fleet, from two bundles on, each
bundle’s profile is additionally namespaced by its own stable id (the same
fleetToolPrefix folding tool names already use):
one bundle: ANVIL_<PROFILE>_<SCHEME>_* (unchanged — byte-identical to no --fleet)two+ bundles: ANVIL_<PROFILE>_<BUNDLE-ID>_<SCHEME>_* (billing and shipping never collide)Without this, two independently-sourced services whose security schemes
happen to share a name (both named oauth, say) would resolve the same
ANVIL_<PROFILE>_* environment variables, and one service’s credential could
be sent to another’s origin. With exactly one bundle mounted, there is
nothing to disambiguate, so the credential namespace stays byte-identical to
anvil serve mcp without --fleet — mirroring the same “nothing is prefixed
with one bundle” rule tool naming already follows.
Benchmarked ladder decisions under --fleet
Section titled “Benchmarked ladder decisions under --fleet”auto disclosure mode (the default — see docs/ARCHITECTURE.md’s ladder
section) can consult a bundle’s own benchmark.report.json
(anvil benchmark --catalog both) to decide whether laddering is actually
helping this bundle’s routing accuracy, the same way standalone
anvil serve mcp (no --fleet) already does. --fleet derives this
measuredAccuracy delta per bundle, with the exact same helper
(measuredAccuracyFromReport, packages/cli/src/commands/ladder-status.ts)
standalone serving uses — so a bundle mounted into a fleet can never
disagree with what serving it alone would decide. A bundle whose measured
laddered-minus-flat accuracy delta falls below the floor
(MIN_LADDERED_ACCURACY_DELTA_PTS, -8 points) serves flat under --fleet
exactly as it would standalone; a bundle with no report, or a report whose
bundleHash no longer matches the bundle’s current content, reproduces
auto’s pre-measurement behavior (ladder whenever the projection is over
budget), also unchanged.
Rate and spend limits
Section titled “Rate and spend limits”packages/runtime/src/limits.ts adds two independent, per-principal gates,
both checked before any upstream call and both refused with a structured,
never-retried error:
- Rate — a token bucket per
(principal, operation). Configure withANVIL_RATE_LIMIT_CAPACITY+ANVIL_RATE_LIMIT_REFILL_PER_SECOND(both required together; a partial pair is treated as unset). Exceeding it refuses withrate_limited(details.code: "policy/rate_limited"). - Spend — a fixed-window budget per principal, denominated in “cost
units” estimated from
effect.kind/effect.risk(costTierFor— there is no real per-call price yet, so every figure this produces is explicitly named an estimate, never a metered cost). Configure withANVIL_SPEND_BUDGET+ANVIL_SPEND_WINDOW_SECONDS. Exceeding it refuses withpolicy_denied(details.code: "policy/budget_exhausted").
Both are absent by default — an unconfigured bundle has no rate limiter and no spend tracker, so nothing here changes behavior unless you opt in.
The drift alarm
Section titled “The drift alarm”anvil observe --from-records <dir> --alarm folds a deployed bundle’s
spooled execution records (ANVIL_RECORDS_DIR/JsonlRecordSpool — the same
records anvil observe --from-records already turns into recorded_traffic
evidence) against the compiled AIR document’s own safety claims, looking for
a contradiction:
- an operation compiled
idempotency.mode: "natural"(repeat calls converge on the same effect) where a recorded call carries the runtime idempotency ledger’s own"replay"evidence (SpooledRecord.ledger,packages/harness/src/records.ts) alongside aconflictoutcome — live traffic says it isn’t naturally idempotent after all. A bareconflicterror code alone is not evidence: an ordinary business conflict (e.g. “this resource already exists”) returns the same code and can itself be perfectly idempotent to repeat, and a payload-free record has no way to prove two calls were “the same logical call” without the ledger’s own replay marker. (Today the ledger is only consulted for operations with a resolvable idempotency-key carrier —idempotency.mode"required"or"key_supported"— so a"natural"-mode operation’s records never actually carry this evidence yet; this alarm stays honest about that rather than alarming on a bare 409.) - an operation whose declared error codes never once appear in traffic while an undeclared one does (reported, not yet case-opening — see below).
Below MIN_SAMPLES_FOR_ALARM (3) recorded calls for an operation, a single
contradicting response is an anecdote, not a pattern, and is never raised.
When a contradiction is found, the alarm opens a real refinement case
through the EXISTING case rails (anvil case ...,
packages/refinement/src/case) — not a
new mechanism. An idempotency-replay contradiction routes through the
already-registered contested_safety_semantic deficiency and its
classify-idempotency skill (evidence bar: minimumStrength: "authoritative",
minimumVerification: "verified" — the highest bar in the codebase, and the
one skill whose evidence policy already names recorded_traffic as an
admissible source), with the contradicting records attached as evidence
(addEvidence, source recorded_traffic).
Proposing only. The alarm never patches AIR — its only writes are
openCase/addEvidence, both scoped to the case directory tree — and
classify-idempotency’s proposals are never auto-approved
(packages/refinement/src/approval.ts’s classifyApproval: idempotency has
no safe “tightening” direction the way retryable=false has for errors).
Opening a case here is exactly as far as this alarm goes; every ordinary
skill/evidence/approval step still applies to whatever a human or agent does
with it afterward.
The undeclared-error-code contradiction is reported in the same run but does
not (yet) open a case: its closest existing skill, enrich-errors
(triggered by undocumented_error), does not admit recorded_traffic in
its evidence policy today. The alarm reports the gap honestly rather than
forcing evidence through a skill whose contract does not accept it —
extending enrich-errors’s evidence policy is a deliberate, separate
change for a reviewer to make.
anvil observe <bundle> --from-records <spool-dir> --alarmanvil observe <bundle> --from-records <spool-dir> --alarm --case-root .refinementWhat the fleet runtime does not change
Section titled “What the fleet runtime does not change”- A single bundle served without
--fleetbehaves exactly as before. - The approval ladder, the idempotency/retry model, and every existing safety gate are untouched — principals and limits are NEW gates ahead of them, not replacements.
- The drift alarm never writes AIR. It opens cases; it never applies patches.