Skip to content

feat(bundle): write MCP server, website and multi envs from their env.toml - #73

Merged
earakely-scale merged 8 commits into
mainfrom
edgararakelyan/env-from-toml
Oct 7, 2026
Merged

earakely-scale merged 8 commits into
mainfrom
edgararakelyan/env-from-toml

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

A bundle's envs/<name>/ folders become envs: an MCP server (a Dockerfile alone is enough), a website or a multi. Each is written over the images built from its folder or named in the store, and agent-env run deploys it.

It builds on #71's pinning helper.

What an env folder holds

envs/crm/                    # an MCP server: the type when env.toml doesn't name one
  Dockerfile                 # built as <id>__env_image, the folder as its context
  server.py                  # @environment_card(name="crm") names the env
envs/shop/                   # a website
  env.toml                   # type = "website"
  Dockerfile.backend         # <id>__backend_image
  Dockerfile.frontend        # <id>__frontend_image
envs/suite/env.toml          # type = "multi"; mcp_server_envs = ["crm", "mail"]; website_envs = ["shop"]
  • Images. A key left out builds the folder's Dockerfile, or Dockerfile.backend / Dockerfile.frontend for a website. { dockerfile = "docker/Dockerfile" } names another one, still with the folder as context. A bare id or { artifact = "…", version = n } names a store image.

    • An image table takes only dockerfile; anything else, such as build_args, is refused by name.
  • environment_name. It's env.toml's if set. Otherwise it's the single @environment_card(name=…) in the .py files the image is built from (the Dockerfile's folder first, then the whole folder), as env mcp-server put reads it.

    • No card, or several, is refused before any build.
    • So is a store image with no name set.
    • A name that differs from the card isn't refused: the deploy injects ENVIRONMENT_NAME, which the SDK prefers.
  • Keys. Each type takes a closed set, with no [metadata] and nothing stamped:

    • mcp_server: image, environment_name, env_provider_type.
    • website: backend_image, frontend_image, environment_name, env_provider_type.
    • multi: mcp_server_envs, website_envs, name, env_provider_type.

    A multi's name left out is random per deploy, as with env multi put.

Checked before anything is written

  • Unknown keys, and a value of the wrong type.
  • An env_provider_type no installed provider has, or one that deploys a single MCP server, on a website or multi.
  • An environment_name that's one of the names a gateway deploy gives its own containers (gateway, servicedb, pgweb, db-mcp, website-browser).
  • A multi with no envs, a name with whitespace, or a child of the wrong type. Children are typed refs (EntityRef.env_type), checked in the resolver for bundle envs and in the plan for store envs.
  • Two of a multi's envs that name one container. An MCP server runs as one container named after it, and a website as <name>-website-backend and <name>-website-frontend.
  • On a provider other than the core's, which gives a multi one env card, an MCP server and a website of one name.
  • A gateway_server or service_db folder. Config names the one every deploy uses, and a run builds it when it's missing.
  • An env folder whose id is another type of env in the store. An env keeps its type across versions, and the store would refuse it only after earlier writes.

The env.toml checks are AuthoringContext.accept_env / accepted_env, beside accepted, so a plugin env type's own from_toml can make them too. The provider rules are provider_refusal, the same function deploy_refusal uses, so a bundle refuses exactly what a deploy would.

bundle check and plugin check report the store-free ones without reading a store.

Writing and reuse

  • The writer. Materialize has an env writer. The env types whose from_toml reads only the toml and what it names are written and tracked by the ledger: the core's (MCPServerEnv, WebsiteEnv, MultiEnv) and any type that inherits one of those, or Env's.
    • An unchanged env is reused.
    • One whose image or child is written anew is rewritten, and so is a multi above it.
    • A plugin env type with a from_toml of its own is still refused, since the ledger can't list what it reads.
  • Store refs. A store ref named without a version is pinned at the version the plan read (feat(bundle): keep what an interrupted run wrote, and rebuild an image when a file's permissions change #71's unpinned_store_refs).

Deploying

The run's preflight walks an env the bundle writes from its planned config, as it walks a store env.

  • Infra. A gateway deploy on the local provider names the gateway and service-db, and the website browser when the env or a child has websites. The run builds them before the tasks start.
  • Other providers. An image the bundle builds counts as one only this machine has, so another provider is refused before any write. So is a website on Modal's container gateway.
  • Versions. A store image or child env named without a version is checked at the version the plan read, which is the one the env is written at.

Limits

  • The folder is the build context. A Dockerfile that copies from outside its folder needs those files inside it. A per-image context can come later as an additive key.
  • A website's two images share that context, so an edit to either side, or to env.toml, rebuilds both.
  • The website rule is generic. "<role>_image left out builds Dockerfile.<role>" applies to any image key a type declares; today only the website's two use it.

Tests

  • Envs from folders (env_toml_test), with docker stubbed:
    • an MCP server named by its card, reused, then rebuilt and rewritten on an edit;
    • environment_name over the card, and a Dockerfile in a subfolder;
    • a store image pinned, then rewritten when the store gains a version;
    • a website's two images;
    • a multi over bundle and store envs, rewritten when a child is;
    • 15 refusals before any write, plus an unknown provider type, a plugin provider's multi with an MCP server and a website of one name, a store child of the wrong type, a type change, and a check that reads no store.
  • Preflight: a built env image refused on another provider, a bundle website on Modal, the infra an MCP server, website, multi and server-provider env each name, and a store image checked at the version the plan read when a newer one lands after planning.
  • Conformance (toml_refs_test): each type's toml_refs start at keys it takes, its from_toml loads exactly what they declare, and what it writes references nothing else. This covers MCPServerEnv, WebsiteEnv, MultiEnv, A2AAgent and Eval.
  • Slow tier, through agent-env run with real docker builds on the local sandbox:
    • the items server as an env folder, named by its card, on the server provider. The first run builds, a rerun builds nothing, and an edit rebuilds only the image and rewrites the env.
    • through the gateway, one env of each kind, each answering a verifier that calls it: an MCP server built from its folder; one built from docker/Dockerfile under its own environment_name (its <name>_add_item tool proves the name reached the server); one over a store image a CLI put wrote; a website, with its frontend page and its backend's health checked through the gateway; and a multi of a bundle MCP server, a store MCP server and the bundle website.
    • an edit to the multi's bundle child rebuilds that image alone and rewrites the child and the multi, the website stays unchanged, and the redeployed multi serves the new build. A dry run afterwards shows every image and env unchanged.
    • a multi whose two MCP servers name one container is refused before anything is built, and no file in the test's folder changes.
  • Existing tests: fixtures with bare env folders now name their env, and the expected writes include each env's image.
  • Unit tier: 5,986 passed, on main merged in.

End to end

Real runs on macOS arm64 with Docker, from a fresh state root, on the local stores and the local sandbox provider. The bundle holds:

  • items, the integration suites' items server, as an MCP server folder;
  • shop, the Slack website, its two Dockerfiles at the folder root and the files they copy inside the folder;
  • suite, a multi of items, a store env mail put by the CLI, and shop.

Each has a task deploying it.

check result
dry run, then run items, shop's two images and suite planned, then built and written; the gateway, service-db and Chromium browser built first; all three deployed through the gateway (69 s)
what was written items is an MCP server named items (its card) over items__env_image v1; shop is a website over __backend_image / __frontend_image; suite is a multi of items v1, mail v1 and shop v1
rerun nothing built or written
--sandbox modal refused before any write: each built image ("built on this machine from envs/…/Dockerfile"), the websites on Modal's container gateway (the multi too), and the local registry's images
shop's env.toml without environment_name refused: its backend source declares no environment card
items named mail beside the store's mail in suite refused: two of suite's MCP servers with one name
a gateway_server folder refused, naming the config setting
items' folder turned into a valid website refused: its id is an MCP server env in the store
an edit to items' seed file items' image rebuilt (v2), items and suite rewritten, nothing else; the dry run predicted it; all three deployed

No refusal wrote anything, and no run left a sandbox folder or container.

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no new actionable issue or outstanding previous finding remains.

Summary

Bundles can now build or reuse images from envs/<name>/ and write MCP server, website, and multi envs for agent-env run to deploy. Planning checks env settings, references, names, and deployment needs before writes begin.

  • Bundle folders build images and write MCP server and website envs.
  • Multi envs can combine bundle envs with store envs.
  • Run preflight checks planned env images and gateway services.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A["Bundle folders"] --> B["Resolve and check"]
  B --> C["Build images"]
  C --> D["Write envs"]
  D --> E["Check deployment"]
  E --> F["Run tasks"]
Loading

Reviews (5) · Last reviewed commit: "refactor(bundle): check env.toml in the ..."

earakely-scale and others added 3 commits October 6, 2026 10:49
…e when an executable bit changes

Three changes to the bundle ledger, which envs written from env.toml will build on.

A version an interrupted run wrote is kept. The ledger now stamps a write's version on its
pending row as soon as the write returns, before it re-checks what the write held, which can
take seconds. The next run of the bundle keeps that version, rather than writing it again, when
it's still the store's latest and its inputs haven't changed. The run and the dry run report it
as "unchanged (written by an interrupted run that didn't record it)". A version someone else
wrote since is written over, as before.

A built image's executable bits are inputs. A build copies each file's mode into the image, so a
chmod alone now rebuilds it. A file with no executable bit is hashed as before, so the images a
bundle built without one are still reused.

One helper, unpinned_store_refs, names the store refs a write gives no version. Its writer pins
them and the ledger hashes them from the same mapping, for every kind whose writer pins, not only
envs and agents.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… file is executable

A build copies each file's permission bits into the image, so 0755 to 0700 changes
what runs in it, but both were hashed as executable. A file's mode now joins its
digest whenever it isn't the usual 0644, so images built from such files are
reused as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
….toml

An env folder becomes an env: an mcp_server (a Dockerfile alone is enough), a website or a
multi, written over the images built from the folder or named in the store.

- Images: an image key left out builds the folder's Dockerfile, or Dockerfile.backend and
  Dockerfile.frontend for a website, as <id>__env_image or __backend_image / __frontend_image;
  { dockerfile = "..." } names another; an image table takes only dockerfile.
- environment_name: env.toml's, else the one @environment_card(name=...) in the source the image
  is built from, else refused. A store image needs it set.
- env.toml takes a closed set of keys per type and no [metadata]; env_provider_type is checked as
  the put commands check it, and a name a gateway deploy gives its own containers is refused.
- A multi's mcp_server_envs and website_envs are typed refs, checked before any write for bundle
  and store envs alike, and two of its MCP servers or websites can't share a name.
- gateway_server and service_db folders are refused: config names those, and a run builds them.
- An env folder whose id is another type of env in the store is refused before any write.
- The env types the core's from_toml writes are tracked by the ledger, so an unchanged env is
  reused and one whose image or child is written anew is rewritten. A plugin env type with a
  from_toml of its own is refused, as before.
- The run's preflight checks a bundle env from its planned config: the infra its gateway deploy
  needs, and an image the bundle builds as one only this machine has.

A conformance test checks that each type's toml_refs name keys it takes, that its from_toml loads
exactly what they declare, and that what it writes references nothing else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Base automatically changed from edgararakelyan/ledger-pins-modes-orphans to main October 6, 2026 19:07
# Conflicts:
#	src/agent_env/bundle/ledger.py
#	src/agent_env/bundle/materialize.py
#	src/agent_env/bundle/plan.py
@earakely-scale
earakely-scale marked this pull request as ready for review October 6, 2026 19:09
@earakely-scale
earakely-scale requested a review from a team as a code owner October 6, 2026 19:09
Comment thread src/agent_env/bundle/plan.py Outdated
Comment thread src/agent_env/env/envs/multi_env.py
Comment thread src/agent_env/bundle/preflight.py Outdated
earakely-scale and others added 4 commits October 6, 2026 12:22
…te, and preflight the planned versions

A multi's MCP servers and websites run as containers their environment_names name:
an MCP server as one by that name, a website as its backend's and frontend's. Two
of a multi's envs could name one container across the two lists, which the check
missed. It now compares the containers. On a provider other than the core's, an MCP
server and a website of one name are refused here too, not only when the multi is
written.

The preflight read a store image or child env named without a version at its
latest, while the writer pins the version the plan read; it now reads that version,
and so does the check of a bundle agent's store image.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…check it by calling it

An MCP server built from its folder and named by its card, one built from docker/Dockerfile under a
name of its own, one over a store image a CLI put wrote, a website, and a multi of bundle and store
envs: each is deployed on the local sandbox through the gateway and answers a verifier that calls
its tools or pages. An edit to a multi's child rebuilds that child alone and rewrites the multi, which
then serves the new build. A multi whose children name one container is refused before anything is
built or written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e deploy path's provider rules

_toml.py goes. The checks every env.toml gets now live on AuthoringContext as accept_env and
accepted_env, beside accepted and card_names, so a plugin env type writing its own from_toml can
make them too. The MCP server and website accept_toml are each one call.

What a provider can't deploy is one function, provider_refusal, in _deployment.py: the server
provider deploys one MCP server, and a plugin's provider can't tell a multi's MCP server and website
of one name apart. deploy_refusal, the env.toml check and the plan's multi check all call it, so the
bundle refuses exactly what a deploy would. The one_server flag and the function-level provider
imports go with it.

Also: the environment_name lookup returns its name and problem rather than writing into the
fields; ctx.config_problem prefixes a problem with the entry's toml for env and agent tomls alike;
and the accepted fields carry env_provider_type, defaulting to the gateway provider's type, so
from_toml no longer repeats the default.

Wording: an unknown provider type reads 'env.toml: Unknown env_provider_type: ...', and the multi
refusal reads 'gives a multi one env card, so it can't tell ...' at deploy and in a bundle alike.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit 05b3310 into main Oct 7, 2026
14 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/env-from-toml branch October 7, 2026 00:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant