Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ updates:
python-minor-and-patch:
update-types: ["minor", "patch"]
ignore:
# mcp 2 removed mcp.server.fastmcp, which env/gateway imports at module scope, and the dev
# extra's fastmcp needs mcp<2, so a major update can't resolve.
# The lock stays on mcp 1: the dev extra's openenv needs fastmcp<4, which needs mcp<2, so a major
# update can't resolve. The unit-mcp2 job runs the unit tier on mcp 2.
- dependency-name: "mcp"
update-types: ["version-update:semver-major"]
# ci-requirements.txt: the uv the workflows install.
Expand Down
38 changes: 38 additions & 0 deletions .github/scripts/mcp2_test_split.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""The unit-mcp2 job's test split, as pytest arguments: ``leave-out`` prints the ``--ignore`` options, ``fastmcp`` the
protocol test files that import the standalone fastmcp, which run in a pass of their own because fastmcp 4 adds mcp 1's
camel-case attributes to mcp's types once imported.

Left out: the tests that import the gateway server, which serves on mcp 1's FastMCP inside its own image, directly or
through a test module that does; the gateway's and openenv's own tests; and the two packaging tests that read every
extra's installed metadata, openenv's included, which the unit job runs."""

import re
import sys
from pathlib import Path

UNIT = Path("tst/unit")
GATEWAY_TESTS = UNIT / "env" / "gateway"
ALWAYS = [GATEWAY_TESTS, UNIT / "openenv", UNIT / "test_optional_extras_stay_optional.py",
UNIT / "test_runtime_dependencies_are_imported.py"]
GATEWAY_IMPORT = re.compile(r"agent_env\.env\.gateway(\.gateway\b| import gateway\b)|mcp\.server\.fastmcp")
FASTMCP_IMPORT = re.compile(r"^(import fastmcp|from fastmcp|fastmcp = pytest\.importorskip)", re.MULTILINE)


def main(what: str) -> None:
if what == "leave-out":
print(" ".join(f"--ignore={path}" for path in [*ALWAYS, *_gateway_importers()]))
else:
tests = sorted(Path("packages/agentenv-protocol/tests").glob("test_*.py"))
print(" ".join(str(path) for path in tests if FASTMCP_IMPORT.search(path.read_text())))


def _gateway_importers() -> list[Path]:
sources = {path: path.read_text() for path in UNIT.rglob("*.py") if not path.is_relative_to(GATEWAY_TESTS)}
found = {path for path, text in sources.items() if GATEWAY_IMPORT.search(text)}
modules = {".".join(path.with_suffix("").parts) for path in found}
found |= {path for path, text in sources.items() if any(f"from {module} import" in text for module in modules)}
return sorted(found)


if __name__ == "__main__":
main(sys.argv[1])
33 changes: 33 additions & 0 deletions .github/workflows/local-backends.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@ jobs:
- name: Unit tier
run: .venv/bin/python -m pytest tst/unit packages/agentenv-protocol/tests -n auto -q --timeout=300 --junitxml=junit-unit.xml

# The unit tier on mcp 2, which a trainer gets with vLLM 0.30 and later. uv.lock holds mcp 1 (the openenv extra's
# fastmcp 3 needs it), so this job resolves mcp 2.3 and fastmcp 4 itself, with every extra but openenv. fastmcp 4
# adds mcp 1's camel-case attributes to mcp's types once imported, so the tests that import it run in a pass of
# their own, after a pass that must work without them.
unit-mcp2:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install with mcp 2
run: |
python -m pip install --quiet -r .github/ci-requirements.txt
python -c 'import tomllib; dev = tomllib.load(open("pyproject.toml", "rb"))["project"]["optional-dependencies"]["dev"]; print("\n".join(d for d in dev if not d.startswith(("agentenv-framework", "fastmcp"))))' > dev-tools.txt
uv venv --python "$(command -v python)"
uv pip install -r dev-tools.txt -e "./packages/agentenv-protocol[agent,computer]" \
-e ".[aws,boxd,daytona,gcp,sail,tensorlake,tenki,vercel]" "mcp>=2.3,<3" "fastmcp>=4,<5"
.venv/bin/python -c 'import importlib.metadata as m; from packaging.version import Version; assert Version(m.version("mcp")) >= Version("2.3"), m.version("mcp")'
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
# .github/scripts/mcp2_test_split.py says what the passes leave out and which tests import fastmcp.
- name: Unit tier on mcp 2, fastmcp not imported
run: >-
.venv/bin/python -m pytest tst/unit packages/agentenv-protocol/tests -n auto -q --timeout=300
$(python .github/scripts/mcp2_test_split.py leave-out)
$(python .github/scripts/mcp2_test_split.py fastmcp | xargs -n1 printf -- '--ignore=%s ')
--deselect=packages/agentenv-protocol/tests/test_agent_env_environment.py::test_hidden_by_default_reaches_tools_list_on_standalone_fastmcp
- name: The tests that import fastmcp, on mcp 2
run: >-
.venv/bin/python -m pytest -q --timeout=300 $(python .github/scripts/mcp2_test_split.py fastmcp)
packages/agentenv-protocol/tests/test_agent_env_environment.py::test_hidden_by_default_reaches_tools_list_on_standalone_fastmcp

ui:
runs-on: ubuntu-latest
timeout-minutes: 15
Expand Down
12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,13 @@ A merge into `main` needs an approving review from a code owner (`CODEOWNERS`),
resolved, and the checks the `main` ruleset requires: `unit`, `integration-local` and
`integration-local-slow`. The other checks below run on every pull request too and are expected to
pass, but a failing one does not block the merge.
`.github/workflows/local-backends.yml` runs `unit`, `ui`, `integration-local`, `integration-local-slow`,
`.github/workflows/local-backends.yml` runs `unit`, `unit-mcp2`, `ui`, `integration-local`, `integration-local-slow`,
`integration-local-gateway` (the slow tier's `gateway_test.py`, run beside the rest of it) and
`installer`. The Python jobs install from `uv.lock` (public PyPI only), with the uv that
`installer`. `unit-mcp2` runs the unit tier on mcp 2.3 and fastmcp 4, which `uv.lock` can't hold (the
`openenv` extra's fastmcp 3 needs mcp 1), so it resolves them itself; it leaves out the gateway's tests,
the ones that build a card with the real gateway, and `openenv`'s, and runs the tests that import
fastmcp in a pass of their own, since fastmcp 4 adds mcp 1's camel-case attributes to mcp's types once
imported. The other Python jobs install from `uv.lock` (public PyPI only), with the uv that
`.github/ci-requirements.txt` pins, and use no secrets and no external services (a `registry:2`
container for the integration jobs). The `unit` job also fails if `uv.lock` resolves anything from a
registry other than PyPI, if `agentenv-framework-protocol` is not the editable workspace member, if
Expand Down Expand Up @@ -334,6 +338,10 @@ proposes only a base's digest and patch updates, and a major or minor upgrade is
- No ticket ids, program codenames or review context in code, docstrings or docs; that belongs in
the commit message and the PR.
- No defensive code for a case no caller produces; say so in the PR instead.
- Code outside `env/gateway/` runs on mcp 1 and mcp 2 (a trainer with vLLM 0.30 or later has mcp 2):
open MCP client sessions with `agentenv_protocol.mcp_compat.client_session`, read an mcp type's
field with `mcp_compat.field(result, "isError")`, and build a high-level server with
`mcp_compat.server`. The gateway's image pins its own mcp 1.
- Keep core vendor-neutral: no vendor hostnames, account ids or deployment facts in `src/`, `tst/`
or this file. A deployment plugs in through the extension points above. Where the project itself
publishes isn't a deployment fact: its PyPI names, its GitHub repository, and the GHCR namespace its
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ A plain install runs on local stores and needs no cloud SDK. The cloud store bac

The distribution is named `agentenv-framework`, the import package is `agent_env` and the command is `agent-env`. It depends on `agentenv-framework-protocol`, whose import package is `agentenv_protocol`, and installs it too.

agent-env runs on `mcp` 1 or 2, so it installs beside a trainer on vLLM 0.30 or later, which needs mcp 2; the `openenv` extra holds it to mcp 1 until OpenEnv moves to FastMCP 4. A verifier script that opens its own MCP session runs in agent-env's process, so it should open it with `agentenv_protocol.mcp_compat.client_session(mcp_url)` and read results with `mcp_compat.field(result, "isError")`, which work on both, as the bundled examples' verifiers do.

To work on agent-env itself, install from a clone with [uv](https://docs.astral.sh/uv/):

```bash
Expand Down
15 changes: 12 additions & 3 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ and `ctx.call_id` describe the call. Code that runs on behalf
of the call without a parameter of its own, such as a database method, reads the same object through
`ToolContext.current()`, which returns an empty context outside any call. The binding is per call
and follows the request's task, so concurrent callers never see each other's context. It is made
at FastMCP's own tool dispatch, for which `mcp` 1.x offers no public hook, and the mount refuses an
at the server's own tool dispatch, for which `mcp` offers no public hook, and the mount refuses an
app without one, so a server that mounted is a server whose tool calls are bound.

To answer a call before the tool runs, override `on_tool_call(self, context)`: return `None` to let
Expand All @@ -156,7 +156,16 @@ handler directly or a seeding script, binds a context it builds:

## Serving

`serve()` builds the FastMCP app via `create_fastmcp_app()`, which encodes the agent-env deploy
The SDK runs on `mcp` 1 and `mcp` 2. On mcp 1 the app is a `FastMCP`; on mcp 2, which renamed it
`MCPServer` and takes the serving settings (path, host, port, transport security) at
`streamable_http_app()` and `run()` rather than at construction, it is an `MCPServer` that keeps those
settings for them. `agentenv_protocol.mcp_compat.server(name, **settings)` builds either from mcp 1's
FastMCP settings. Code that talks to an MCP server works on both through the same module:
`client_session(url, headers=..., timeout=...)` opens an initialized `ClientSession` (mcp 2's client
takes an `httpx2` client and yields two streams, where mcp 1's took `httpx` and yielded three), and
`field(result, "isError")` reads an mcp type's field, which mcp 2 spells in snake case.

`serve()` builds the app via `create_fastmcp_app()`, which encodes the agent-env deploy
contract once — the name resolution order (`ENVIRONMENT_NAME`, which agent-env sets to the env's
registered name, then `@environment_card`'s name, then the class name; `SERVICE_NAME` is no
longer consulted), `MCP_HOST`/`MCP_PORT`
Expand Down Expand Up @@ -238,7 +247,7 @@ paths (`POST /tools/disable`, `POST /tools/enable`) with its bodies (`{role, too
`modify_env_tool_access` step works on such an env unchanged. A call with no role is `default`, as
at the gateway.

Where the binding lives depends on the app. On mcp's own FastMCP it wraps the tool manager's dispatch; on the standalone `fastmcp` package it joins the app's middleware chain as the outermost layer, so a handler's own middleware runs under it too. An app with neither seam mounts unbound, with a warning, unless a tool declares a `ToolContext` parameter or the handler defines `on_tool_call`: then the mount is refused rather than serving an empty caller.
Where the binding lives depends on the app. On mcp's own server (FastMCP or MCPServer) it wraps the tool manager's dispatch, and on mcp 2 a role's `tools/list` is filtered in the server's middleware, as mcp 2 has no request context outside a handler; on the standalone `fastmcp` package it joins the app's middleware chain as the outermost layer, so a handler's own middleware runs under it too. An app with neither seam mounts unbound, with a warning, unless a tool declares a `ToolContext` parameter or the handler defines `on_tool_call`: then the mount is refused rather than serving an empty caller.

### Uploading an export through a grant

Expand Down
8 changes: 4 additions & 4 deletions packages/agentenv-protocol/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ dependencies = [
[project.optional-dependencies]
agent = [
"a2a-sdk[http-server]>=0.3.26,<0.4",
"mcp>=1.25,<2", # the peer-agents tools' MCP server; 2.0 removed the server API it serves them with
"mcp>=1.25,<3", # the peer-agents tools' MCP server
"python-multipart>=0.0.13", # the staging extension's upload forms
"regex>=2023.12.25",
"uvicorn>=0.30",
Expand All @@ -39,7 +39,7 @@ agent = [
# records the display as H.264 with PyAV (computer-recording/v1).
computer = [
"av>=17",
"mcp>=1.25,<2",
"mcp>=1.25,<3",
"Pillow>=10.4",
"uvicorn>=0.30",
]
Expand All @@ -49,8 +49,8 @@ dev = [
"regex>=2023.12.25",
"pytest>=9.0",
"pytest-asyncio>=0.23.0",
"mcp>=1.25,<2",
"fastmcp>=3.4,<4", # the standalone-fastmcp mount path
"mcp>=1.25,<3",
"fastmcp>=3.4,<5", # the standalone-fastmcp mount path
"Pillow>=10.4", # the computer extra's frame crops and encodings
"av>=17", # the computer extra's encoder, and the tests' decoder
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from starlette.responses import PlainTextResponse
from starlette.types import Receive, Scope, Send

from ..mcp_compat import low_level_server
from ..transfers import serving_port
from .extensions import PeerAgent

Expand Down Expand Up @@ -192,14 +193,10 @@ async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
await self._manager.handle_request(scope, receive, send)

def _mcp_server(self) -> Server:
server: Server = Server(PEER_MCP_SERVER_NAME)

@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [types.Tool(name=name, description=tool.description, inputSchema=_schema(tool.arguments))
for name, tool in self._tools.items()]

@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
tool = self._tools.get(name)
if tool is None:
Expand All @@ -211,7 +208,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextCont
raise TimeoutError(f"{name} got no answer from the peer within {_CALL_BUDGET_SECONDS}s") from None
return [types.TextContent(type="text", text=json.dumps(result))]

return server
return low_level_server(PEER_MCP_SERVER_NAME, list_tools, call_tool)

async def _peer_list(self, _: _NoArguments) -> list[dict[str, Any]]:
return [_summary(peer) for peer in self._peers.values()]
Expand Down
Loading
Loading