From a828523f65fdd1cd19122be516a8edda27f67307 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 14 Oct 2026 13:22:31 -0700 Subject: [PATCH 1/3] feat(examples): ship echo-mcp, an MCP server bundle that runs through the gateway A second example beside hello, for a machine with Docker: envs/echo builds an MCP server from a Dockerfile (an AgentEnvEnvironment with one tool, echo), deploy_env deploys it behind the gateway, and env_outcome_verifier checks it by calling the tool through the env's MCP endpoint. It is registered as echo-mcp under agent_env.bundles, so `agent-env run echo-mcp` runs it by name. The server's requirements name agentenv-framework-protocol and mcp, since the protocol doesn't install mcp. A release pins the protocol line to the version it ships with, beside the pyproject pin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/publish-pypi.yml | 4 ++++ AGENTS.md | 2 +- README.md | 2 +- pyproject.toml | 1 + src/agent_env/examples/echo-mcp/README.md | 10 ++++++++++ .../echo-mcp/artifacts/check_echo/verify.py | 14 ++++++++++++++ .../examples/echo-mcp/envs/echo/Dockerfile | 8 ++++++++ .../echo-mcp/envs/echo/requirements.txt | 3 +++ .../examples/echo-mcp/envs/echo/server.py | 18 ++++++++++++++++++ .../examples/echo-mcp/tasks/smoke.json | 5 +++++ .../cli/run_bundle_built_envs_test.py | 16 ++++++++++++++-- tst/unit/bundle/installed_test.py | 5 +++-- 12 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 src/agent_env/examples/echo-mcp/README.md create mode 100644 src/agent_env/examples/echo-mcp/artifacts/check_echo/verify.py create mode 100644 src/agent_env/examples/echo-mcp/envs/echo/Dockerfile create mode 100644 src/agent_env/examples/echo-mcp/envs/echo/requirements.txt create mode 100644 src/agent_env/examples/echo-mcp/envs/echo/server.py create mode 100644 src/agent_env/examples/echo-mcp/tasks/smoke.json diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 600c2906..573cf6a7 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -66,6 +66,10 @@ jobs: # workspace members, and the bare name keys [tool.uv.sources]; none of those may be rewritten. sed -i "s/\"agentenv-framework-protocol[^\"]*\"/\"agentenv-framework-protocol==${protocol}\"/" pyproject.toml grep -q "\"agentenv-framework-protocol==${protocol}\"" pyproject.toml + # The echo-mcp example's server image installs the protocol this release ships with. + echo_requirements=src/agent_env/examples/echo-mcp/envs/echo/requirements.txt + sed -i "s/^agentenv-framework-protocol.*/agentenv-framework-protocol==${protocol}/" "$echo_requirements" + grep -qx "agentenv-framework-protocol==${protocol}" "$echo_requirements" uv build --no-sources --out-dir dist - name: Check its metadata renders run: uvx --index-url https://pypi.org/simple twine check --strict dist/* diff --git a/AGENTS.md b/AGENTS.md index 96f27c3f..407e7232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ and deserialized through a registry. | `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. | | `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. | | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. | -| `examples/` | The bundles agent-env ships (`hello`), registered under `agent_env.bundles` in `pyproject.toml`. Tests run them, but they are examples for users, not fixtures. | +| `examples/` | The bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker), registered under `agent_env.bundles` in `pyproject.toml`. Tests run them, but they are examples for users, not fixtures. | ## Extension points diff --git a/README.md b/README.md index f79e41c7..e6868673 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ uv tool install agentenv-framework agent-env run hello ``` -`agent-env run hello` runs the built-in hello task, which needs no Docker, model or configuration. To try it without installing anything, run it with uvx: +`agent-env run hello` runs the built-in hello task, which needs no Docker, model or configuration. `agent-env run echo-mcp` needs Docker: it builds an MCP server from a Dockerfile, deploys it through the gateway and checks it by calling its tool. `agent-env run` lists both. To try hello without installing anything, run it with uvx: ```bash uvx --from agentenv-framework agent-env run hello diff --git a/pyproject.toml b/pyproject.toml index 41a1d57f..cbdac392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ agent-env = "agent_env.cli:cli" [project.entry-points."agent_env.bundles"] hello = "agent_env.examples" +echo-mcp = "agent_env.examples" [project.urls] Homepage = "https://www.agentenvframework.com" diff --git a/src/agent_env/examples/echo-mcp/README.md b/src/agent_env/examples/echo-mcp/README.md new file mode 100644 index 00000000..4a142e52 --- /dev/null +++ b/src/agent_env/examples/echo-mcp/README.md @@ -0,0 +1,10 @@ +An MCP server built from a Dockerfile, deployed through the gateway and checked by calling its tool. + +It needs Docker. `envs/echo/` is the server: a `Dockerfile` and a `server.py` whose one tool, `echo`, +returns the text it's sent, named `echo` by its environment card. `deploy_env` builds it and deploys +it behind the gateway, and `env_outcome_verifier` runs `artifacts/check_echo/verify.py` against the +env's MCP endpoint. The first run also builds the infra it deploys on, the gateway and a service +database, which takes minutes. The run tears it all down when it ends. + +Run it with `agent-env run echo-mcp`, or copy this folder and run the copy with +`agent-env run ./my-echo`. diff --git a/src/agent_env/examples/echo-mcp/artifacts/check_echo/verify.py b/src/agent_env/examples/echo-mcp/artifacts/check_echo/verify.py new file mode 100644 index 00000000..9328a9e8 --- /dev/null +++ b/src/agent_env/examples/echo-mcp/artifacts/check_echo/verify.py @@ -0,0 +1,14 @@ +"""Calls the echo tool through the env's MCP endpoint and checks it sends the text back.""" +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + +SENT = "hello from agent-env" + + +async def verify(mcp_url): + async with streamable_http_client(mcp_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool("echo", {"text": SENT}) + text = "".join(getattr(part, "text", "") for part in result.content) + return [{"id": "echoes", "result": not result.isError and text == SENT, "description": f"echo returned {text!r}"}] diff --git a/src/agent_env/examples/echo-mcp/envs/echo/Dockerfile b/src/agent_env/examples/echo-mcp/envs/echo/Dockerfile new file mode 100644 index 00000000..f7aeb5dd --- /dev/null +++ b/src/agent_env/examples/echo-mcp/envs/echo/Dockerfile @@ -0,0 +1,8 @@ +FROM public.ecr.aws/docker/library/python:3.11-slim@sha256:c20888b6acdd1e63e1c433a185bf3ad162c0288fe484616ce062e0d28add2900 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY server.py . +RUN useradd --create-home --uid 10001 appuser +USER appuser +CMD ["python", "server.py"] diff --git a/src/agent_env/examples/echo-mcp/envs/echo/requirements.txt b/src/agent_env/examples/echo-mcp/envs/echo/requirements.txt new file mode 100644 index 00000000..2f549484 --- /dev/null +++ b/src/agent_env/examples/echo-mcp/envs/echo/requirements.txt @@ -0,0 +1,3 @@ +# A release pins agentenv-framework-protocol to the version it was released with. +agentenv-framework-protocol +mcp>=1.25,<2 diff --git a/src/agent_env/examples/echo-mcp/envs/echo/server.py b/src/agent_env/examples/echo-mcp/envs/echo/server.py new file mode 100644 index 00000000..2a0b0b2e --- /dev/null +++ b/src/agent_env/examples/echo-mcp/envs/echo/server.py @@ -0,0 +1,18 @@ +"""The echo env: an MCP server with one tool, which returns the text it's sent.""" +from typing import Annotated + +from pydantic import Field + +from agentenv_protocol import AgentEnvEnvironment, environment_card, tool + + +@environment_card(name="echo") +class EchoEnv(AgentEnvEnvironment): + @tool(name="echo") + def echo(self, text: Annotated[str, Field(description="The text to send back.")]) -> str: + """Return the text it's sent.""" + return text + + +if __name__ == "__main__": + EchoEnv().serve() diff --git a/src/agent_env/examples/echo-mcp/tasks/smoke.json b/src/agent_env/examples/echo-mcp/tasks/smoke.json new file mode 100644 index 00000000..7365f109 --- /dev/null +++ b/src/agent_env/examples/echo-mcp/tasks/smoke.json @@ -0,0 +1,5 @@ +[ + {"id": "env", "type": "deploy_env", "env_id": "echo", "sandbox_type": "local"}, + {"id": "echoes", "type": "env_outcome_verifier", "env_id": "echo", "file_artifact_id": "check_echo", + "verifier_id": "echoes"} +] diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index 43cca2dd..0ce46d1c 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -5,8 +5,9 @@ under a name of its own, one over a store image, a website, and a multi of bundle and store envs, which is rewritten when a child is. Environments and a universe the bundle writes load into an MCP server deployed through the gateway, and an edit to their files loads anew. A universe composed every way a bundle composes one seeds each server of a multi -of bundle and store servers, and a store universe shared across servers seeds the one server it's loaded into. Needs a -Docker daemon and the local registry.""" +of bundle and store servers, and a store universe shared across servers seeds the one server it's loaded into. The +echo-mcp example agent-env ships runs by name, and a rerun writes nothing. Needs a Docker daemon and the local +registry.""" import hashlib import json @@ -541,3 +542,14 @@ def test_a_multi_whose_servers_share_a_tool_name_is_refused_at_deploy_naming_the assert "tasks/suite.json v1: failed at step 'env'" in result.output, result.output assert "serve duplicate tool names" in result.output, result.output assert "'list_items' from 'items' and 'stock'" in result.output, result.output + + +def test_the_shipped_echo_mcp_example_runs_by_name_and_a_rerun_writes_nothing(state): + first, second = _run("echo-mcp"), _run("echo-mcp") + + assert first.exit_code == 0, first.output + assert "tasks/smoke.json v1: passed (echoes: 1)" in first.output, first.output + assert second.exit_code == 0, second.output + for entry in ("envs/echo (Dockerfile image)", "envs/echo", "artifacts/check_echo", "tasks/smoke.json"): + assert f"{entry}: v1, unchanged" in second.output, second.output + assert "tasks/smoke.json v1: passed (echoes: 1)" in second.output, second.output diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index 9af92eb7..abf060a1 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -117,7 +117,7 @@ def test_agent_env_ships_hello_rooted_at_its_own_distribution(): ] -def test_the_plugin_report_counts_the_bundle_agent_env_ships(monkeypatch, quiet_logs): +def test_the_plugin_report_counts_the_bundles_agent_env_ships(monkeypatch, quiet_logs): monkeypatch.setattr(sys.modules["agent_env.cli.plugin"], "config_effect", lambda name: None) result = CliRunner().invoke(cli, ["plugin", "show", CORE, "--json"]) @@ -125,7 +125,8 @@ def test_the_plugin_report_counts_the_bundle_agent_env_ships(monkeypatch, quiet_ assert result.exit_code == 0, result.output (core,) = json.loads(result.stdout)["plugins"] - assert [(c["group"], c["name"], c["status"]) for c in core["contributions"]] == [(BUNDLES, "hello", "active")] + assert [(c["group"], c["name"], c["status"]) for c in core["contributions"]] == [ + (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active")] assert "\n agent-env itself\n" in shown and "requires:" not in shown From 991f95ff54231e6dc9615e380a4ecb0c89fcfd89 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 14 Oct 2026 13:52:40 -0700 Subject: [PATCH 2/3] test(installer): the core's plugin row lists echo-mcp beside hello Co-Authored-By: Claude Opus 5.5 (1M context) --- tst/installer/test_container_journey.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tst/installer/test_container_journey.py b/tst/installer/test_container_journey.py index 549b29d2..fe3e9782 100644 --- a/tst/installer/test_container_journey.py +++ b/tst/installer/test_container_journey.py @@ -133,7 +133,7 @@ def test_a_new_user_installs_agent_env_adds_plugins_runs_a_task_and_removes_them container.ok("python", "-m", "pip", "install", "-q", "--no-index", "--find-links", "/wheels", "uv") container.ok("uv", "tool", "install", "agentenv-framework") rows = container.ok("agent-env", "plugin", "list").stdout.split("STATUS\n", 1)[1] - assert re.fullmatch(r"agentenv-framework +\S+ +bundle hello +ok\n", rows), rows + assert re.fullmatch(r"agentenv-framework +\S+ +bundle echo-mcp, bundle hello +ok\n", rows), rows added = container.ok("agent-env", "plugin", "add", browser, grader, "--yes") assert "toy_navigate active" in added.stdout and "toy_grade active" in added.stdout From 9391d6e1386b331f10c27c11578a1283031ba168 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 14 Oct 2026 15:13:45 -0700 Subject: [PATCH 3/3] test: each file the release pins to the protocol holds one line its sed rewrites Co-Authored-By: Claude Opus 5.5 (1M context) --- tst/unit/test_release_protocol_pins.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tst/unit/test_release_protocol_pins.py diff --git a/tst/unit/test_release_protocol_pins.py b/tst/unit/test_release_protocol_pins.py new file mode 100644 index 00000000..e0b65e5e --- /dev/null +++ b/tst/unit/test_release_protocol_pins.py @@ -0,0 +1,21 @@ +"""The files ``.github/workflows/publish-pypi.yml`` rewrites to pin agentenv-framework-protocol on a release: each must +hold exactly one line its sed matches, or the release fails after the protocol is already on PyPI.""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +WORKFLOW = (REPO / ".github" / "workflows" / "publish-pypi.yml").read_text() + + +def test_the_pyproject_holds_the_one_quoted_protocol_requirement_the_release_pins(): + assert len(re.findall(r'"agentenv-framework-protocol[^"]*"', (REPO / "pyproject.toml").read_text())) == 1 + + +def test_the_echo_mcp_requirements_the_release_pins_hold_one_protocol_line(): + (path,) = re.findall(r"^\s*echo_requirements=(\S+)$", WORKFLOW, re.MULTILINE) + lines = (REPO / path).read_text().splitlines() + + assert len([line for line in lines if line.startswith("agentenv-framework-protocol")]) == 1