From fe65d5db0be1c3e17e6c036faa6d167c47108d8c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:02:48 -0700 Subject: [PATCH 1/3] feat(mcp): run on mcp 1 and mcp 2, so a trainer can install vLLM 0.30 and later vLLM 0.30 and later require mcp>=2, and agent-env and its protocol package pinned mcp<2, so a trainer with agent-env installed was held to vLLM 0.29. - The pins widen to mcp>=1.25,<3 (fastmcp <5). The gateway's image keeps its own mcp<2, since it serves on mcp 1's FastMCP. uv.lock stays on mcp 1: the openenv extra's fastmcp 3 needs it. - agentenv_protocol.mcp_compat holds what differs: the high-level server (FastMCP, or MCPServer holding mcp 1's serving settings), a client session (httpx2 and two streams on mcp 2), the low-level server's handlers, an mcp type's field under either spelling, and a crash's text in a tool's error, which mcp 2 keeps on the server. - The protocol's FastMCP app, its tools/list filter per role (middleware on mcp 2), the peer-agents server and the computer conformance probe use it, and so do agent-env's MCP clients and the example verifiers. agent-env no longer declares mcp itself; it arrives with the protocol package. - The tests run on both versions, and a unit-mcp2 job runs the unit tier on mcp 2.3 and fastmcp 4, in two passes, since fastmcp 4 adds mcp 1's camel-case attributes to mcp's types once imported. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 4 +- .github/workflows/local-backends.yml | 45 +++++ AGENTS.md | 12 +- README.md | 2 + packages/agentenv-protocol/README.md | 15 +- packages/agentenv-protocol/pyproject.toml | 8 +- .../a2a_agent/peer_agents.py | 7 +- .../agent_env_environment.py | 50 ++++-- .../computer/conformance/checks.py | 49 +++--- .../computer/conformance/client.py | 12 +- .../src/agentenv_protocol/mcp_compat.py | 157 ++++++++++++++++++ .../agentenv-protocol/tests/computer_fake.py | 4 +- .../agentenv-protocol/tests/mcp_versions.py | 92 ++++++++++ .../tests/test_agent_env_environment.py | 24 +-- .../tests/test_computer_conformance.py | 5 +- .../test_computer_conformance_recording.py | 2 +- .../tests/test_computer_dataplane.py | 2 +- .../tests/test_computer_declare.py | 2 +- .../tests/test_computer_driver.py | 8 +- .../tests/test_computer_extensions.py | 2 +- .../tests/test_computer_frames.py | 2 +- .../tests/test_computer_queue.py | 2 +- .../tests/test_computer_recording.py | 2 +- .../tests/test_computer_spaces.py | 2 +- .../agentenv-protocol/tests/test_errors.py | 6 +- .../tests/test_extension_methods.py | 2 +- .../tests/test_tool_access.py | 29 +--- .../tests/test_tool_annotations.py | 10 +- .../tests/test_tool_client.py | 2 +- .../tests/test_tool_context_fastmcp.py | 28 ++-- .../tests/test_tool_context_injection.py | 51 +++--- pyproject.toml | 5 +- src/agent_env/env/gateway/requirements.txt | 2 +- src/agent_env/env/legacy_protocol.py | 18 +- .../echo-mcp/artifacts/check_echo/verify.py | 12 +- .../mail-mcp/artifacts/check_mail/verify.py | 11 +- .../providers/env_providers/env_provider.py | 9 +- .../verify_mcp_tool_schema.py | 18 +- .../verify_spec_conformance.py | 15 +- tst/unit/drivers/reference_driver_test.py | 5 +- tst/util/mcp_versions.py | 54 ++++++ uv.lock | 12 +- 42 files changed, 567 insertions(+), 232 deletions(-) create mode 100644 packages/agentenv-protocol/src/agentenv_protocol/mcp_compat.py create mode 100644 packages/agentenv-protocol/tests/mcp_versions.py create mode 100644 tst/util/mcp_versions.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fde20cd99..823d1bfb7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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. diff --git a/.github/workflows/local-backends.yml b/.github/workflows/local-backends.yml index b28a6da97..54469d761 100644 --- a/.github/workflows/local-backends.yml +++ b/.github/workflows/local-backends.yml @@ -53,6 +53,51 @@ 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")' + # Left out of both passes: the gateway's tests and those that build a card with the real gateway server, which + # serves on mcp 1's FastMCP inside its own image; the openenv tests and the two packaging tests that read every + # extra's installed metadata, openenv's included, which the unit job runs. + - 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 + --ignore=tst/unit/env/gateway --ignore=tst/unit/env/test_env.py --ignore=tst/unit/env/envs/test_env_card_loads.py + --ignore=tst/unit/providers/env_providers/env_gateway_provider_test.py + --ignore=tst/unit/task_step/sync_env_clock_test.py --ignore=tst/unit/task_step/test_apply_server_config.py + --ignore=tst/unit/task_step/test_verify_universe_roundtrip.py --ignore=tst/unit/task_step/snapshot_env_upload_grant_test.py + --ignore=tst/unit/task_step/test_snapshot_env.py --ignore=tst/unit/task_step/test_snapshot_agent_state.py + --ignore=tst/unit/openenv --ignore=tst/unit/test_optional_extras_stay_optional.py + --ignore=tst/unit/test_runtime_dependencies_are_imported.py + --ignore=packages/agentenv-protocol/tests/test_computer_spaces.py --ignore=packages/agentenv-protocol/tests/test_errors.py + --ignore=packages/agentenv-protocol/tests/test_tool_access.py --ignore=packages/agentenv-protocol/tests/test_tool_annotations.py + --ignore=packages/agentenv-protocol/tests/test_tool_context_fastmcp.py + --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 + packages/agentenv-protocol/tests/test_computer_spaces.py packages/agentenv-protocol/tests/test_errors.py + packages/agentenv-protocol/tests/test_tool_access.py packages/agentenv-protocol/tests/test_tool_annotations.py + packages/agentenv-protocol/tests/test_tool_context_fastmcp.py + 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 diff --git a/AGENTS.md b/AGENTS.md index e8491684b..99bb0feb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 93e469d7b..8a0db3a6f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 6a9d66065..866f1e2b8 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -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 @@ -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` @@ -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 diff --git a/packages/agentenv-protocol/pyproject.toml b/packages/agentenv-protocol/pyproject.toml index c6bfe3cfc..f6a07c469 100644 --- a/packages/agentenv-protocol/pyproject.toml +++ b/packages/agentenv-protocol/pyproject.toml @@ -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", @@ -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", ] @@ -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 ] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/peer_agents.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/peer_agents.py index dac9ef7ce..219dac218 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/peer_agents.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/peer_agents.py @@ -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 @@ -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: @@ -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()] diff --git a/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py b/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py index 02b1c8896..44445330f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/agent_env_environment.py @@ -21,6 +21,7 @@ from starlette.responses import Response from starlette.routing import Route +from . import mcp_compat from .errors import AgentEnvError from .tool_access import DISABLE as TOOL_DISABLE, ENABLE as TOOL_ENABLE, PATHS as TOOL_ACCESS_PATHS, ToolAccess, caller_role from .tool_access import extensions as tool_access_extensions @@ -333,8 +334,8 @@ def _check_tool_binding(self, app: Any) -> None: """Decide, before any route or tool is added, how every tools/call on ``app`` gets its ToolContext, and refuse what cannot be honoured. The standalone ``fastmcp`` package (a ``middleware`` list with ``add_middleware``) is joined through that public chain, checked first because its 2.x line also keeps a - tool manager whose dispatch has another shape; mcp's own FastMCP (``mcp>=1.25,<2``) has no public hook, - so its tool manager's ``call_tool`` is wrapped. An app with neither cannot bind: mounting it is refused + tool manager whose dispatch has another shape; mcp's own server (FastMCP on mcp 1, MCPServer on mcp 2) has + no public hook around a tool's dispatch, so its tool manager's ``call_tool`` is wrapped. An app with neither cannot bind: mounting it is refused when the handler declared that it reads the context (a ``ToolContext`` parameter or an ``on_tool_call``), since an empty caller would then be a lie nothing can detect, and goes ahead unbound, with a warning, otherwise. A dispatch this protocol already wraps is refused either way (one handler per app, or the @@ -359,8 +360,8 @@ def _check_tool_binding(self, app: Any) -> None: readers += ["tool_access"] if self._tool_access is not None else [] if readers: raise RuntimeError( - "this app has no tool dispatch to bind a ToolContext around (mcp>=1.25,<2 keeps one at " - f"FastMCP._tool_manager; fastmcp exposes a middleware list), and {', '.join(readers)} " + "this app has no tool dispatch to bind a ToolContext around (mcp keeps one at the server's " + f"_tool_manager; fastmcp exposes a middleware list), and {', '.join(readers)} " "would read an empty caller" ) self._binding = None @@ -410,7 +411,7 @@ async def call_tool(name: str, arguments: dict, *args: Any, **kwargs: Any) -> An except Exception as e: error = _agentenv_error(e) if error is None: - raise + raise mcp_compat.with_crash_text(e) return _error_result(error) functools.update_wrapper(call_tool, inner) @@ -421,7 +422,11 @@ async def call_tool(name: str, arguments: dict, *args: Any, **kwargs: Any) -> An @staticmethod def _filter_tool_list(app: Any, manager: Any, access: ToolAccess) -> None: - """tools/list under the live request's caller, read through ``FastMCP.get_context``, as tools/call reads it.""" + """tools/list under the live request's caller, as tools/call reads it: through ``FastMCP.get_context`` on + mcp 1, and in the middleware of mcp 2's MCPServer, which has no request context outside a handler.""" + if not callable(getattr(app, "get_context", None)): + app.middleware.append(_tool_list_filter(access)) + return inner = manager.list_tools def list_tools(*args: Any, **kwargs: Any) -> Any: @@ -446,7 +451,7 @@ async def _describe_registered_tool(self, name: str, description: str) -> None: def _card_for(self, app: Any) -> EnvironmentCard: """Declare the MCP endpoint only when the app says where it serves it; FastMCP-shaped targets may not.""" - path = getattr(getattr(app, "settings", None), "streamable_http_path", None) + path = mcp_compat.streamable_http_path(app) return _merge_mcp_interface(self.environment_card, path) if path else self.environment_card @@ -456,15 +461,13 @@ def _add_route(self, app: Any, path: str, methods: list, handler: Callable) -> N def create_fastmcp_app(handler: Any, *, card: EnvironmentCard, **fastmcp_kwargs: Any) -> Any: - """FastMCP app wired for the agent-env deploy contract, with ``handler`` mounted under ``card``; returned un-served.""" + """mcp's high-level server (FastMCP on mcp 1, MCPServer on mcp 2) wired for the agent-env deploy contract, with + ``handler`` mounted under ``card``; returned un-served.""" try: - from mcp.server.fastmcp import FastMCP + app = mcp_compat.deployed_server(card.name, host=os.environ.get("MCP_HOST", "0.0.0.0"), + port=int(os.environ.get("MCP_PORT", "18765")), **fastmcp_kwargs) except ImportError as e: - raise ImportError("create_fastmcp_app requires the 'mcp' package: pip install 'mcp>=1.25,<2'") from e - app = FastMCP(card.name, **fastmcp_kwargs) - app.settings.host = os.environ.get("MCP_HOST", "0.0.0.0") - app.settings.port = int(os.environ.get("MCP_PORT", "18765")) - app.settings.transport_security.enable_dns_rebinding_protection = False + raise ImportError("create_fastmcp_app requires the 'mcp' package: pip install mcp") from e AgentEnvFastMCPApplication(card, handler).add_routes_to_app(app) return app @@ -652,9 +655,8 @@ def _is_tool_context_annotation(annotation: Any) -> bool: def _fastmcp_context_type() -> type | None: - """FastMCP's Context class when mcp is loaded, else None. mcp is an optional dependency, so it is - looked up rather than imported; a Context-annotated handler has necessarily imported it already.""" - return getattr(sys.modules.get("mcp.server.fastmcp.server"), "Context", None) + """mcp's tool Context class when mcp is loaded, else None.""" + return mcp_compat.context_class() def _is_fastmcp_context_annotation(annotation: Any) -> bool: @@ -812,6 +814,20 @@ async def on_call_tool(self, context: Any, call_next: Callable) -> Any: return middleware +def _tool_list_filter(access: ToolAccess) -> Callable: + """mcp 2 middleware leaving out of a tools/list answer the tools the caller's role has disabled.""" + async def filtered(ctx: Any, call_next: Callable) -> Any: + result = await call_next(ctx) + if ctx.method != "tools/list" or result is None: + return result + role = caller_role(context_from_mcp(types.SimpleNamespace(request_context=ctx), "", {})) + if isinstance(result, dict): + return {**result, "tools": [t for t in result["tools"] if not access.is_disabled(role, t["name"])]} + return result.model_copy(update={"tools": [t for t in result.tools if not access.is_disabled(role, t.name)]}) + + return filtered + + def _fastmcp_tool_result(answer: Any) -> Any: """A hook's ``mcp.types.CallToolResult`` as the ``ToolResult`` a fastmcp middleware chain returns, the error flag kept, under the field spellings of mcp 1 and 2.""" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py index bb5da2b9e..92f159cc3 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/checks.py @@ -20,6 +20,7 @@ from ... import client as protocol from ...client import ExtensionCallError +from ...mcp_compat import field from ...tool_access import DISABLE_TOOL_EXTENSION_URI, ENABLE_TOOL_EXTENSION_URI from ...types import DATA_OBJECTS_EXTENSION_URI, INTAKE_EXTENSION_URI from ..card import ( @@ -147,13 +148,13 @@ async def tool(self, name: str, args: dict | None = None) -> CallToolResult: async def shot(self, **args) -> tuple[CallToolResult, object]: result = await self.tool("computer_screenshot", args) - if result.isError: + if field(result, "isError"): raise AssertionError(f"computer_screenshot failed: {result.content[0].text}") return result, image(result) async def act(self, name: str, args: dict) -> None: result = await self.tool(name, args) - if result.isError: + if field(result, "isError"): raise AssertionError(f"{name} {args} failed: {result.content[0].text}") async def state(self) -> dict: @@ -175,8 +176,8 @@ async def settled(self, holds: Callable[[dict], bool], show: Callable[[dict], ob @staticmethod def refused(result: CallToolResult, code: str) -> str | None: - data = result.structuredContent or {} - if not result.isError: + data = field(result, "structuredContent") or {} + if not field(result, "isError"): return f"it wasn't refused: {data.get('summary')!r}" if data.get("code") != code: return f"refused {data.get('code')!r}, not {code!r}: {data.get('message')}" @@ -215,7 +216,7 @@ async def declared() -> str | None: async def minimum() -> str | None: missing = [n for n in MINIMUM_TOOLS if n not in tools] - bare = [n for n in MINIMUM_TOOLS if n in tools and not (tools[n].inputSchema and tools[n].outputSchema)] + bare = [n for n in MINIMUM_TOOLS if n in tools and not (field(tools[n], "inputSchema") and field(tools[n], "outputSchema"))] if missing: return f"missing {missing}" return f"listed without an input or output schema: {bare}" if bare else None @@ -237,7 +238,7 @@ async def groups() -> str | None: async def _frame(self) -> None: async def native() -> str | None: result, img = await self.shot() - data, display = result.structuredContent, result.structuredContent["display"] + data, display = field(result, "structuredContent"), field(result, "structuredContent")["display"] want = (round(display["width"] * display["scale"]), round(display["height"] * display["scale"])) if img.size != (data["width"], data["height"]) or img.size != want: return f"the image is {img.size}, the frame says {(data['width'], data['height'])}, the display {want}" @@ -247,21 +248,21 @@ async def crop() -> str | None: result, img = await self.shot() w, h = img.size box = [w // 4, h // 4, w // 4 + w // 3, h // 4 + h // 3] - cropped = await self.tool("computer_screenshot", {"region": box, "frame_id": result.structuredContent["frame_id"]}) - if cropped.isError: + cropped = await self.tool("computer_screenshot", {"region": box, "frame_id": field(result, "structuredContent")["frame_id"]}) + if field(cropped, "isError"): return cropped.content[0].text part = image(cropped) if part.size != (box[2] - box[0], box[3] - box[1]): return f"a {box} region came back {part.size}" - if cropped.structuredContent.get("parent_frame_id") != result.structuredContent["frame_id"]: - return f"its parent_frame_id is {cropped.structuredContent.get('parent_frame_id')!r}" + if field(cropped, "structuredContent").get("parent_frame_id") != field(result, "structuredContent")["frame_id"]: + return f"its parent_frame_id is {field(cropped, 'structuredContent').get('parent_frame_id')!r}" points = [(0, 0), (part.width - 1, part.height - 1), (part.width // 2, part.height // 2)] wrong = [p for p in points if part.getpixel(p) != img.getpixel((box[0] + p[0], box[1] + p[1]))] return f"its pixels at {wrong} aren't its frame's" if wrong else None async def jpeg() -> str | None: result = await self.tool("computer_screenshot", {"format": "jpeg", "quality": 60}) - mime = next(c.mimeType for c in result.content if c.type == "image") + mime = next(field(c, "mimeType") for c in result.content if c.type == "image") return None if mime == "image/jpeg" else f"it answered {mime}" await self.check("P2", "a screenshot is native: its image, frame and display agree", native) @@ -273,7 +274,7 @@ async def jpeg() -> str | None: async def _refusal_cases(self) -> list[tuple[str, str, dict, str]]: """The calls a driver must refuse, aimed at a fresh frame, with the code each is refused with.""" result, img = await self.shot() - frame, (w, _h) = result.structuredContent["frame_id"], img.size + frame, (w, _h) = field(result, "structuredContent")["frame_id"], img.size over = int(self.features.get("max_click_count", 1)) + 1 return [ ("a point outside the frame", "computer_click", {"x": w, "y": 0, "frame_id": frame}, "out_of_bounds"), @@ -300,7 +301,7 @@ async def _display(self) -> None: async def get() -> str | None: got = await self.driver.invoke(COMPUTER_DISPLAY, "get") result, _ = await self.shot() - frame = result.structuredContent["display"] + frame = field(result, "structuredContent")["display"] seen = (frame["width"], frame["height"], frame["scale"]) return None if (got["width"], got["height"], got["scale"]) == seen else f"get says {got}, frames {frame}" @@ -417,9 +418,9 @@ async def per_role() -> str | None: back = await other.call_tool("computer_wait", {"ms": 0}) if "computer_wait" in listed: return "a disabled tool is still listed for its role" - if not refused.isError or (refused.structuredContent or {}).get("code") != "permission_denied": + if not field(refused, "isError") or (field(refused, "structuredContent") or {}).get("code") != "permission_denied": return f"a disabled tool answered its role: {refused.content[0].text if refused.content else ''}" - if allowed.isError or back.isError: + if field(allowed, "isError") or field(back, "isError"): return "the tool was refused to another role, or after it was enabled again" return None @@ -431,14 +432,14 @@ async def _pointer(self) -> None: return async def listed() -> str | None: - pointers = (await self.tool("computer_pointer_list")).structuredContent.get("pointers") or [] + pointers = field(await self.tool("computer_pointer_list"), "structuredContent").get("pointers") or [] return None if sum(p.get("default") is True for p in pointers) == 1 else f"it listed {pointers}" async def moved() -> str | None: result, img = await self.shot() - frame, (x, y) = result.structuredContent["frame_id"], (img.width // 3, img.height // 3) + frame, (x, y) = field(result, "structuredContent")["frame_id"], (img.width // 3, img.height // 3) await self.act("computer_pointer_move", {"x": x, "y": y, "frame_id": frame}) - where = (await self.tool("computer_pointer_position", {"frame_id": frame})).structuredContent + where = field(await self.tool("computer_pointer_position", {"frame_id": frame}), "structuredContent") return None if (where.get("x"), where.get("y"), where.get("inside_frame")) == (x, y, True) else ( f"moved to ({x}, {y}); it reports {where}") @@ -486,7 +487,7 @@ async def _unstage(self) -> None: async def _base(self) -> tuple[str, float, object]: """A fresh frame of the fixture: its id, the display's scale, and its image.""" result, img = await self.shot() - return result.structuredContent["frame_id"], result.structuredContent["display"]["scale"], img + return field(result, "structuredContent")["frame_id"], field(result, "structuredContent")["display"]["scale"], img def _at(self, box: str, scale: float, dx: float | None = None, dy: float | None = None) -> tuple[int, int]: """A point of a fixture widget in frame pixels: its centre, or ``dx``, ``dy`` in from its corner.""" @@ -523,7 +524,7 @@ async def norm() -> str | None: min((fx + 4.5) * NORM / img.width, NORM), min((fy + 4.5) * NORM / img.height, NORM)] cropped = await self.tool("computer_screenshot", {"region": region, "frame_id": frame, "space": "norm", "norm_scale": NORM}) - if cropped.isError: + if field(cropped, "isError"): return cropped.content[0].text if (got := image(cropped).getpixel((0, 0))) != colour: wrong.append((x, y, colour, got)) @@ -568,9 +569,9 @@ async def crop() -> str | None: gx, gy = self._at("grid", scale, 0, 0) gw, gh = math.floor(self.layout["grid"][2] * scale), math.floor(self.layout["grid"][3] * scale) cropped = await self.tool("computer_screenshot", {"region": [gx, gy, gx + gw, gy + gh], "frame_id": frame}) - if cropped.isError: + if field(cropped, "isError"): return cropped.content[0].text - inner = cropped.structuredContent["frame_id"] + inner = field(cropped, "structuredContent")["frame_id"] await self.act("computer_click", {"x": math.floor((4 * size + size / 2) * scale), "y": math.floor((6 * size + size / 2) * scale), "frame_id": inner}) return await self.settled(lambda s: s["cells"].get("6,4") == 1, lambda s: s["cells"]) @@ -579,8 +580,8 @@ async def together() -> str | None: targets = [cell(row, col) for row in (1, 3) for col in range(8)] results = await asyncio.gather(*(self.tool("computer_click", {"x": x, "y": y, "frame_id": frame}) for x, y in targets)) - if any(r.isError for r in results): - return f"{sum(r.isError for r in results)} of {len(results)} were refused" + if any(field(r, "isError") for r in results): + return f"{sum(field(r, 'isError') for r in results)} of {len(results)} were refused" landed = lambda s: sum(s["cells"].get(f"{r},{c}", 0) >= 1 for r in (1, 3) for c in range(8)) # noqa: E731 return await self.settled(lambda s: landed(s) == 16, lambda s: f"{landed(s)} of 16 cells") diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py index 4a71ebd8b..0c87b07fb 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/client.py @@ -11,11 +11,11 @@ import httpx from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client from mcp.types import CallToolResult from PIL import Image as PILImage from ... import client as protocol +from ...mcp_compat import client_session from ...tool_context import ROLE_HEADER from ..card import COMPUTER_EXEC @@ -49,13 +49,9 @@ def params(self, uri: str) -> dict: @contextlib.asynccontextmanager async def session(self, role: str | None = None) -> AsyncIterator[ClientSession]: - headers = {ROLE_HEADER: role} if role else {} - timeout = httpx.Timeout(self.timeout_s, read=max(self.timeout_s, STREAM_READ_S)) - async with httpx.AsyncClient(headers=headers, timeout=timeout) as http: - async with streamable_http_client(self.url + protocol.mcp_path(self.card), http_client=http) as streams: - async with ClientSession(streams[0], streams[1]) as session: - await session.initialize() - yield session + async with client_session(self.url + protocol.mcp_path(self.card), headers={ROLE_HEADER: role} if role else None, + timeout=self.timeout_s, read_timeout=max(self.timeout_s, STREAM_READ_S)) as session: + yield session async def invoke(self, uri: str, method: str, params: dict | None = None) -> Any: return await protocol.invoke_extension(self.url, self.card, uri, params or {}, method=method, diff --git a/packages/agentenv-protocol/src/agentenv_protocol/mcp_compat.py b/packages/agentenv-protocol/src/agentenv_protocol/mcp_compat.py new file mode 100644 index 000000000..b0425cdc9 --- /dev/null +++ b/packages/agentenv-protocol/src/agentenv_protocol/mcp_compat.py @@ -0,0 +1,157 @@ +"""mcp 1 and mcp 2 behind one surface: the high-level server an app is built on, a client session on a URL, a +low-level server's tool handlers, a crash's text in a tool's error, and an mcp type's field under either spelling. +mcp is an optional dependency of this package, so each function imports it when called.""" + +from __future__ import annotations + +import contextlib +import functools +import importlib.metadata +import logging +import re +import sys +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +_logger = logging.getLogger(__name__) + +#: The serving options mcp 2 takes at ``streamable_http_app()`` and ``run()``, which mcp 1's FastMCP took as settings. +_SERVING = ("host", "port", "streamable_http_path", "json_response", "stateless_http", "transport_security") + + +@functools.cache +def mcp_2() -> bool: + return int(importlib.metadata.version("mcp").split(".")[0]) >= 2 + + +def server(name: str, **kwargs: Any) -> Any: + """mcp's high-level server named ``name``, ``kwargs`` taking mcp 1's FastMCP settings on either version. mcp 2 + takes the serving ones (path, host, port, transport security) at ``streamable_http_app()`` and ``run()``, so its + server keeps them for those.""" + if not mcp_2(): + from mcp.server.fastmcp import FastMCP + return FastMCP(name, **kwargs) + serving = {key: kwargs.pop(key) for key in _SERVING if key in kwargs} + return _serving_server_class()(name, {"streamable_http_path": "/mcp", **serving}, **kwargs) + + +def deployed_server(name: str, *, host: str, port: int, **kwargs: Any) -> Any: + """``server`` serving streamable HTTP at ``host`` and ``port`` without DNS-rebinding protection.""" + if not mcp_2(): + app = server(name, **kwargs) + app.settings.host, app.settings.port = host, port + app.settings.transport_security.enable_dns_rebinding_protection = False + return app + from mcp.server.transport_security import TransportSecuritySettings + return server(name, host=host, port=port, + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), **kwargs) + + +def streamable_http_path(app: Any) -> str | None: + """Where ``app`` serves streamable HTTP, when it says: mcp 1's FastMCP in its settings, ``server``'s on mcp 2 in + what it serves with.""" + serving = getattr(app, "serving", None) + if isinstance(serving, dict): + return serving.get("streamable_http_path") + return getattr(getattr(app, "settings", None), "streamable_http_path", None) + + +def context_class() -> type | None: + """The high-level server's tool Context class, when mcp is loaded; None otherwise. Looked up, not imported: a + handler annotated with it has necessarily imported it already.""" + for module, name in (("mcp.server.fastmcp.server", "Context"), ("mcp.server.mcpserver.context", "Context")): + found = getattr(sys.modules.get(module), name, None) + if found is not None: + return found + return None + + +def low_level_server(name: str, list_tools: Callable[[], Awaitable[list[Any]]], + call_tool: Callable[[str, dict[str, Any]], Awaitable[list[Any]]]) -> Any: + """mcp's low-level ``Server`` serving ``list_tools`` (the tools) and ``call_tool`` (a call's content). A call that + raises answers with its message as an error result, as mcp 1's decorators answer it.""" + from mcp import types + from mcp.server.lowlevel import Server + if not mcp_2(): + server = Server(name) + server.list_tools()(list_tools) + server.call_tool()(call_tool) + return server + + async def on_list_tools(ctx: Any, params: Any) -> Any: + return types.ListToolsResult(tools=await list_tools()) + + async def on_call_tool(ctx: Any, params: Any) -> Any: + try: + content = await call_tool(params.name, params.arguments or {}) + except Exception as e: + return types.CallToolResult(content=[types.TextContent(type="text", text=str(e))], isError=True) + return types.CallToolResult(content=content) + + return Server(name, on_list_tools=on_list_tools, on_call_tool=on_call_tool) + + +@contextlib.asynccontextmanager +async def client_session(url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None, + read_timeout: float | None = None) -> AsyncIterator[Any]: + """An initialized ``ClientSession`` on ``url`` over streamable HTTP. mcp 2 takes an ``httpx2`` client where mcp 1 + takes an ``httpx`` one, and yields two streams where mcp 1 yields three.""" + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + if mcp_2(): + import httpx2 as http + else: + import httpx as http + limits = {} if timeout is None else {"timeout": http.Timeout(timeout, read=read_timeout or timeout)} + async with http.AsyncClient(headers=headers or {}, **limits) as client: + async with streamable_http_client(url, http_client=client) as streams: + async with ClientSession(streams[0], streams[1]) as session: + await session.initialize() + yield session + + +def with_crash_text(error: Exception) -> Exception: + """The error a tool's dispatch raised, with a crash's own text as mcp 1 gives it ("Error executing tool x: ..."). + mcp 2 keeps a crash's text on the server, logging it there; this logs it the same way and hands it on.""" + if not mcp_2(): + return error + from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError + if not isinstance(error, UnexpectedToolError) or error.__cause__ is None: + return error + _logger.error("%s", error, exc_info=error.__cause__) + exposed = ToolError(f"{error}: {error.__cause__}") + exposed.__cause__ = error.__cause__ + return exposed + + +def field(model: Any, name: str) -> Any: + """An mcp type's field by its wire name (``isError``): mcp 2 spells the attribute in snake case, mcp 1 as on the + wire.""" + snake = re.sub(r"(? type: + """``MCPServer`` holding the serving options it was built with, which ``streamable_http_app()`` and ``run()`` + default to, as mcp 1's FastMCP read them from its settings. Built once mcp 2 is known to be installed.""" + global _SERVING_SERVER + if _SERVING_SERVER is None: + from mcp.server.mcpserver import MCPServer + + class ServingServer(MCPServer): + def __init__(self, name: str, serving: dict[str, Any], **kwargs: Any) -> None: + super().__init__(name, **kwargs) + self.serving = serving + + def streamable_http_app(self, **kwargs: Any) -> Any: + defaults = {key: value for key, value in self.serving.items() if key != "port"} + return super().streamable_http_app(**{**defaults, **kwargs}) + + async def run_streamable_http_async(self, **kwargs: Any) -> None: + await super().run_streamable_http_async(**{**self.serving, **kwargs}) + + _SERVING_SERVER = ServingServer + return _SERVING_SERVER diff --git a/packages/agentenv-protocol/tests/computer_fake.py b/packages/agentenv-protocol/tests/computer_fake.py index 6133104cb..af2a386eb 100644 --- a/packages/agentenv-protocol/tests/computer_fake.py +++ b/packages/agentenv-protocol/tests/computer_fake.py @@ -7,7 +7,7 @@ class with no display. A click paints its button's colour at its point; everythi import contextlib import httpx -from mcp.shared.memory import create_connected_server_and_client_session +from mcp_versions import memory_session from PIL import Image as PILImage from agentenv_protocol.computer import ( @@ -202,7 +202,7 @@ def press_keys(self, keys): @contextlib.asynccontextmanager async def mcp_session(driver: ComputerDriverEnvironment): """An MCP client session on the driver's app, in memory.""" - async with create_connected_server_and_client_session(driver.mcp or driver.create_app()) as session: + async with memory_session(driver.mcp or driver.create_app()) as session: yield session diff --git a/packages/agentenv-protocol/tests/mcp_versions.py b/packages/agentenv-protocol/tests/mcp_versions.py new file mode 100644 index 000000000..5d2ed69e9 --- /dev/null +++ b/packages/agentenv-protocol/tests/mcp_versions.py @@ -0,0 +1,92 @@ +"""The tests' view of mcp 1 and mcp 2 alike: the high-level server (``FastMCP``, built as ``mcp_compat.server`` builds +it), its tool classes, and an in-memory session on a server whose results read by their wire names (``isError``, +``structuredContent``), as mcp 1 spells the attributes.""" + +from __future__ import annotations + +import contextlib +from collections.abc import AsyncIterator +from types import SimpleNamespace +from typing import Any + +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client +from pydantic import BaseModel + +from agentenv_protocol.mcp_compat import mcp_2 +from agentenv_protocol.mcp_compat import server as FastMCP + +MCP_2 = mcp_2() +if MCP_2: + import httpx2 as http + from mcp import Client + from mcp.server.mcpserver import Context + from mcp.server.mcpserver.tools.base import Tool +else: + import httpx as http + from mcp.server.fastmcp import Context + from mcp.server.fastmcp.tools.base import Tool + from mcp.shared.memory import create_connected_server_and_client_session + +__all__ = ["MCP_2", "Context", "FastMCP", "Tool", "http_session", "memory_session", "wire"] + +BASE = "http://127.0.0.1:8000" + + +@contextlib.asynccontextmanager +async def memory_session(app: Any) -> AsyncIterator[Any]: + """An initialized client session on ``app``, in memory.""" + if MCP_2: + async with Client(app) as client: + yield _Session(client) + else: + async with create_connected_server_and_client_session(app) as session: + yield _Session(session) + + +@contextlib.asynccontextmanager +async def http_session(asgi: Any, *, headers: dict[str, str] | None = None) -> AsyncIterator[tuple[Any, Any]]: + """An initialized client session on ``asgi``'s ``/mcp`` over streamable HTTP, in process, sending ``headers``, and + a callable answering the id of the session the server opened for it (read from its response headers: mcp 2's + client doesn't hand it over).""" + opened: list[str] = [] + + async def record(response: Any) -> None: + if "mcp-session-id" in response.headers: + opened.append(response.headers["mcp-session-id"]) + + async with http.AsyncClient(transport=http.ASGITransport(app=asgi), base_url=BASE, headers=headers or {}, + event_hooks={"response": [record]}) as client: + async with streamable_http_client(f"{BASE}/mcp", http_client=client) as streams: + async with ClientSession(streams[0], streams[1]) as session: + await session.initialize() + yield _Session(session), lambda: opened[0] if opened else None + + +def wire(value: Any) -> Any: + """An mcp result with its fields named as mcp 1 names them (``isError``, ``meta``): as it is on mcp 1, and on mcp 2 + a model's fields as attributes under their camel-case wire names, a mapping kept a mapping.""" + if not MCP_2: + return value + if isinstance(value, BaseModel): + fields = {_mcp_1_name(name, info.alias): wire(getattr(value, name)) + for name, info in type(value).model_fields.items()} + return SimpleNamespace(**fields, **(value.model_extra or {})) + if isinstance(value, list): + return [wire(item) for item in value] + return value + + +def _mcp_1_name(name: str, alias: str | None) -> str: + return alias if alias and not alias.startswith("_") else name + + +class _Session: + def __init__(self, session: Any) -> None: + self._session = session + + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None, **kwargs: Any) -> Any: + return wire(await self._session.call_tool(name, arguments, **kwargs)) + + async def list_tools(self, **kwargs: Any) -> Any: + return wire(await self._session.list_tools(**kwargs)) diff --git a/packages/agentenv-protocol/tests/test_agent_env_environment.py b/packages/agentenv-protocol/tests/test_agent_env_environment.py index 5d145b312..5637fb732 100644 --- a/packages/agentenv-protocol/tests/test_agent_env_environment.py +++ b/packages/agentenv-protocol/tests/test_agent_env_environment.py @@ -251,7 +251,7 @@ async def test_mounts_on_plain_starlette_app(): @pytest.mark.parametrize("declared,served_path,expected", _MCP_INTERFACE_CASES) @pytest.mark.asyncio async def test_card_declares_the_mcp_endpoint_the_app_serves(declared, served_path, expected): - pytest.importorskip("mcp.server.fastmcp") + pytest.importorskip("mcp") card = EnvironmentCard(name="items", additionalInterfaces=declared) http_app = create_fastmcp_app(_ToolHandler(), card=card, streamable_http_path=served_path).streamable_http_app() async with httpx.AsyncClient(transport=httpx.ASGITransport(app=http_app), base_url="http://t") as client: @@ -668,7 +668,8 @@ async def describe(self, product_id: Annotated[str, Field(description="The produ def test_tool_schema_parity_with_fastmcp_derivation(): # Card and tools/list schemas are derived independently from the same signature — pin agreement. - fastmcp_base = pytest.importorskip("mcp.server.fastmcp.tools.base") + pytest.importorskip("mcp") + from mcp_versions import Tool async def sample( product_id: Annotated[str, Field(description="The product ID.")], @@ -678,7 +679,7 @@ async def sample( return "[]" ours = _schema_from_signature(sample) - theirs = fastmcp_base.Tool.from_function(sample).parameters + theirs = Tool.from_function(sample).parameters assert set(ours["properties"]) == set(theirs["properties"]) assert set(ours.get("required", [])) == set(theirs.get("required", [])) for prop, spec in ours["properties"].items(): @@ -1140,21 +1141,24 @@ class _Child(_ServeEnv): def test_create_fastmcp_app_without_mcp_raises_install_hint(monkeypatch): - monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", None) + for module in ("mcp.server.fastmcp", "mcp.server.mcpserver", "mcp.server.transport_security"): + monkeypatch.setitem(sys.modules, module, None) with pytest.raises(ImportError, match="requires the 'mcp' package"): create_fastmcp_app(_ToolHandler(), card=EnvironmentCard(name="items")) @pytest.mark.asyncio async def test_create_fastmcp_app_encodes_deploy_contract(monkeypatch): - pytest.importorskip("mcp.server.fastmcp") + pytest.importorskip("mcp") + from mcp_versions import MCP_2 monkeypatch.delenv("SERVICE_NAME", raising=False) monkeypatch.setenv("MCP_HOST", "127.0.0.1") monkeypatch.setenv("MCP_PORT", "19999") handler = _ToolHandler() app = create_fastmcp_app(handler, card=EnvironmentCard(name="items")) - assert app.settings.host == "127.0.0.1" and app.settings.port == 19999 - assert app.settings.transport_security.enable_dns_rebinding_protection is False + serving = app.serving if MCP_2 else app.settings.model_dump() + assert serving["host"] == "127.0.0.1" and serving["port"] == 19999 + assert (app.serving["transport_security"] if MCP_2 else app.settings.transport_security).enable_dns_rebinding_protection is False assert {"count_items", "items_add_item"} <= {t.name for t in await app.list_tools()} async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app.streamable_http_app()), base_url="http://t") as client: card = (await client.get("/.well-known/agent-env.json")).json() @@ -1180,7 +1184,7 @@ def test_hidden_by_default_meta_key_is_the_literal_the_gateway_mirrors(): @pytest.mark.asyncio async def test_hidden_by_default_tool_is_listed_with_the_meta_flag_and_others_without(monkeypatch): - pytest.importorskip("mcp.server.fastmcp") + pytest.importorskip("mcp") monkeypatch.delenv("SERVICE_NAME", raising=False) handler = _PlumbingHandler() app = create_fastmcp_app(handler, card=EnvironmentCard(name="items")) @@ -1211,14 +1215,14 @@ def test_an_unflagged_tool_mounts_on_a_target_without_the_meta_parameter(): def test_create_fastmcp_app_uses_card_verbatim(monkeypatch): # identity is the caller's card; SERVICE_NAME resolution lives in the env's card config - pytest.importorskip("mcp.server.fastmcp") + pytest.importorskip("mcp") monkeypatch.setenv("SERVICE_NAME", "fromenv") assert create_fastmcp_app(_ToolHandler(), card=EnvironmentCard(name="carded")).name == "carded" @pytest.mark.asyncio async def test_create_app_allows_imperative_registration_before_serve(monkeypatch): - pytest.importorskip("mcp.server.fastmcp") + pytest.importorskip("mcp") monkeypatch.delenv("SERVICE_NAME", raising=False) env = _ServeEnv() app = env.create_app() diff --git a/packages/agentenv-protocol/tests/test_computer_conformance.py b/packages/agentenv-protocol/tests/test_computer_conformance.py index 6b57262e1..46e769146 100644 --- a/packages/agentenv-protocol/tests/test_computer_conformance.py +++ b/packages/agentenv-protocol/tests/test_computer_conformance.py @@ -8,10 +8,11 @@ import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") from computer_fake import FakeScreen, MinimalDriver # noqa: E402 +from mcp_versions import wire # noqa: E402 from mcp.types import CallToolResult, TextContent # noqa: E402 from PIL import ImageChops # noqa: E402 from probe_fake import FixtureDriver, served # noqa: E402 @@ -83,7 +84,7 @@ async def test_a_tool_already_hidden_from_the_probes_role_stays_hidden(): await driver.invoke(DISABLE_TOOL_EXTENSION_URI, "disable", {"role": checks.PROBE_ROLE, "tools": ["computer_wait"]}) report = await run_probe(url, "basic") async with driver.session(checks.PROBE_ROLE) as session: - refused = await session.call_tool("computer_wait", {"ms": 0}) + refused = wire(await session.call_tool("computer_wait", {"ms": 0})) assert report.passed, _failed(report) assert refused.isError and refused.structuredContent["code"] == "permission_denied" diff --git a/packages/agentenv-protocol/tests/test_computer_conformance_recording.py b/packages/agentenv-protocol/tests/test_computer_conformance_recording.py index fbcf36228..f52d949ed 100644 --- a/packages/agentenv-protocol/tests/test_computer_conformance_recording.py +++ b/packages/agentenv-protocol/tests/test_computer_conformance_recording.py @@ -9,7 +9,7 @@ import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") pytest.importorskip("av") diff --git a/packages/agentenv-protocol/tests/test_computer_dataplane.py b/packages/agentenv-protocol/tests/test_computer_dataplane.py index 212d99123..d81324495 100644 --- a/packages/agentenv-protocol/tests/test_computer_dataplane.py +++ b/packages/agentenv-protocol/tests/test_computer_dataplane.py @@ -9,7 +9,7 @@ import httpx import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") from computer_fake import FakeDriver, http_client, rpc # noqa: E402 diff --git a/packages/agentenv-protocol/tests/test_computer_declare.py b/packages/agentenv-protocol/tests/test_computer_declare.py index ac4ac2b0b..e4582230b 100644 --- a/packages/agentenv-protocol/tests/test_computer_declare.py +++ b/packages/agentenv-protocol/tests/test_computer_declare.py @@ -8,7 +8,7 @@ import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") from agentenv_protocol.computer._declare import _Stub, _StubModule, declare, main # noqa: E402 diff --git a/packages/agentenv-protocol/tests/test_computer_driver.py b/packages/agentenv-protocol/tests/test_computer_driver.py index a0e3331ce..6026a2255 100644 --- a/packages/agentenv-protocol/tests/test_computer_driver.py +++ b/packages/agentenv-protocol/tests/test_computer_driver.py @@ -4,7 +4,7 @@ import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") import asyncio # noqa: E402 @@ -12,8 +12,8 @@ import io # noqa: E402 from computer_fake import LINUX, FakeDriver, FakeScreen, MinimalDriver, http_client, mcp_session # noqa: E402 -from mcp.server.fastmcp import FastMCP # noqa: E402 -from mcp.shared.memory import create_connected_server_and_client_session # noqa: E402 +from mcp_versions import FastMCP # noqa: E402 +from mcp_versions import memory_session # noqa: E402 from PIL import Image as PILImage # noqa: E402 from agentenv_protocol.computer import ( # noqa: E402 @@ -348,7 +348,7 @@ async def test_a_failure_after_part_of_a_call_was_delivered_is_applied_unknown() async def test_a_driver_mounted_on_an_app_serves_the_same_tools(): driver = MinimalDriver() app = driver.mount(FastMCP("mounted")) - async with create_connected_server_and_client_session(app) as session: + async with memory_session(app) as session: tools = {t.name: t for t in (await session.list_tools()).tools} refused = await session.call_tool("computer_click", {}) assert "required" not in tools["computer_click"].inputSchema and "computer_pointer_move" not in tools diff --git a/packages/agentenv-protocol/tests/test_computer_extensions.py b/packages/agentenv-protocol/tests/test_computer_extensions.py index cbd0a9c85..785ae3b84 100644 --- a/packages/agentenv-protocol/tests/test_computer_extensions.py +++ b/packages/agentenv-protocol/tests/test_computer_extensions.py @@ -8,7 +8,7 @@ import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") from computer_fake import FakeDriver, MinimalDriver, http_client # noqa: E402 diff --git a/packages/agentenv-protocol/tests/test_computer_frames.py b/packages/agentenv-protocol/tests/test_computer_frames.py index 6c5eb9b79..94b9fb3c4 100644 --- a/packages/agentenv-protocol/tests/test_computer_frames.py +++ b/packages/agentenv-protocol/tests/test_computer_frames.py @@ -7,7 +7,7 @@ import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") from computer_fake import FakeDriver, FakeScreen, http_client, mcp_session # noqa: E402 diff --git a/packages/agentenv-protocol/tests/test_computer_queue.py b/packages/agentenv-protocol/tests/test_computer_queue.py index 93ca1f07f..fe5a5becd 100644 --- a/packages/agentenv-protocol/tests/test_computer_queue.py +++ b/packages/agentenv-protocol/tests/test_computer_queue.py @@ -8,7 +8,7 @@ import anyio import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") from computer_fake import COLOURS, FakeDriver, FakeScreen, MinimalDriver, http_client, mcp_session # noqa: E402 diff --git a/packages/agentenv-protocol/tests/test_computer_recording.py b/packages/agentenv-protocol/tests/test_computer_recording.py index c238dd9bf..2523e0c30 100644 --- a/packages/agentenv-protocol/tests/test_computer_recording.py +++ b/packages/agentenv-protocol/tests/test_computer_recording.py @@ -12,7 +12,7 @@ import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") av = pytest.importorskip("av") diff --git a/packages/agentenv-protocol/tests/test_computer_spaces.py b/packages/agentenv-protocol/tests/test_computer_spaces.py index 9352dbf1b..1fd7fdb06 100644 --- a/packages/agentenv-protocol/tests/test_computer_spaces.py +++ b/packages/agentenv-protocol/tests/test_computer_spaces.py @@ -5,7 +5,7 @@ import httpx import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") pytest.importorskip("PIL") fastmcp = pytest.importorskip("fastmcp") diff --git a/packages/agentenv-protocol/tests/test_errors.py b/packages/agentenv-protocol/tests/test_errors.py index ec8123bf2..5d0e7a5e3 100644 --- a/packages/agentenv-protocol/tests/test_errors.py +++ b/packages/agentenv-protocol/tests/test_errors.py @@ -8,10 +8,10 @@ import httpx import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") fastmcp = pytest.importorskip("fastmcp") -from mcp.shared.memory import create_connected_server_and_client_session # noqa: E402 +from mcp_versions import memory_session # noqa: E402 from agentenv_protocol import ( # noqa: E402 AgentEnvEnvironment, @@ -103,7 +103,7 @@ async def test_invalid_params_never_ran_so_applied_is_no(): async def test_a_tool_error_is_an_error_result_with_structured_fields_on_mcps_fastmcp(): env = _Env() with anyio.fail_after(30): - async with create_connected_server_and_client_session(env.create_app()) as session: + async with memory_session(env.create_app()) as session: refused = await session.call_tool("click", {"x": 140}) served = await session.call_tool("click", {"x": 4}) crashed = await session.call_tool("crash", {}) diff --git a/packages/agentenv-protocol/tests/test_extension_methods.py b/packages/agentenv-protocol/tests/test_extension_methods.py index 73d35aa8b..eca903637 100644 --- a/packages/agentenv-protocol/tests/test_extension_methods.py +++ b/packages/agentenv-protocol/tests/test_extension_methods.py @@ -8,7 +8,7 @@ import httpx import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") from agentenv_protocol import ( # noqa: E402 AgentEnvEnvironment, diff --git a/packages/agentenv-protocol/tests/test_tool_access.py b/packages/agentenv-protocol/tests/test_tool_access.py index ac04104a2..974bc1f0c 100644 --- a/packages/agentenv-protocol/tests/test_tool_access.py +++ b/packages/agentenv-protocol/tests/test_tool_access.py @@ -8,12 +8,11 @@ import httpx import pytest -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") fastmcp = pytest.importorskip("fastmcp") -from mcp import ClientSession # noqa: E402 -from mcp.client import streamable_http as _streamable_http # noqa: E402 -from mcp.server.fastmcp import FastMCP # noqa: E402 +from mcp_versions import BASE as _BASE # noqa: E402 +from mcp_versions import FastMCP, http_session # noqa: E402 from agentenv_protocol import ( # noqa: E402 ROLE_HEADER, @@ -26,10 +25,6 @@ ) from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI, ENABLE_TOOL_EXTENSION_URI, ToolAccess # noqa: E402 -pytestmark = pytest.mark.skipif(not hasattr(_streamable_http, "streamable_http_client"), - reason="mcp without streamable_http_client") - -_BASE = "http://127.0.0.1:8000" @environment_card(name="desk") @@ -58,12 +53,8 @@ async def _served(): @contextlib.asynccontextmanager async def session(role: str | None = None): - headers = {ROLE_HEADER: role} if role else {} - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url=_BASE, headers=headers) as http: - async with _streamable_http.streamable_http_client(f"{_BASE}/mcp", http_client=http) as (read, write, _): - async with ClientSession(read, write) as s: - await s.initialize() - yield s + async with http_session(asgi, headers={ROLE_HEADER: role} if role else {}) as (s, _): + yield s async with app.session_manager.run(): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url=_BASE) as rest: @@ -159,13 +150,9 @@ async def test_roles_are_honoured_on_fastmcp_too(): async with asgi.router.lifespan_context(asgi): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url=_BASE) as rest: await rest.post("/tools/disable", json={"role": "agent", "tools": ["write"]}) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url=_BASE, - headers={ROLE_HEADER: "agent"}) as http: - async with _streamable_http.streamable_http_client(f"{_BASE}/mcp", http_client=http) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - tools = await _names(session) - refused = await session.call_tool("write", {}) + async with http_session(asgi, headers={ROLE_HEADER: "agent"}) as (session, _): + tools = await _names(session) + refused = await session.call_tool("write", {}) assert tools == ["read"] assert refused.isError and refused.structuredContent["code"] == "permission_denied" diff --git a/packages/agentenv-protocol/tests/test_tool_annotations.py b/packages/agentenv-protocol/tests/test_tool_annotations.py index feeb1c58e..00eda0cb3 100644 --- a/packages/agentenv-protocol/tests/test_tool_annotations.py +++ b/packages/agentenv-protocol/tests/test_tool_annotations.py @@ -8,10 +8,10 @@ import pytest from pydantic import BaseModel -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") fastmcp = pytest.importorskip("fastmcp") -from mcp.shared.memory import create_connected_server_and_client_session # noqa: E402 +from mcp_versions import memory_session # noqa: E402 from mcp.types import CallToolResult, ImageContent, TextContent # noqa: E402 from agentenv_protocol import ( # noqa: E402 @@ -54,7 +54,7 @@ def shot(self) -> Annotated[CallToolResult, _Frame]: async def _mcp_tools(env: AgentEnvEnvironment) -> dict: with anyio.fail_after(30): - async with create_connected_server_and_client_session(env.create_app()) as session: + async with memory_session(env.create_app()) as session: return {t.name: t for t in (await session.list_tools()).tools} @@ -73,7 +73,7 @@ async def test_annotations_meta_and_output_schemas_are_listed_on_mcps_fastmcp(): @pytest.mark.asyncio async def test_structured_results_reach_the_caller_with_their_content_blocks(): with anyio.fail_after(30): - async with create_connected_server_and_client_session(_Env().create_app()) as session: + async with memory_session(_Env().create_app()) as session: look = await session.call_tool("look", {}) shot = await session.call_tool("shot", {}) assert look.structuredContent == {"frame_id": "f1", "width": 1280} @@ -173,7 +173,7 @@ async def test_update_tool_rewrites_a_description_on_the_card_and_in_tools_list_ app = env.create_app() await env.update_tool("look", description="Look again.") with anyio.fail_after(30): - async with create_connected_server_and_client_session(app) as session: + async with memory_session(app) as session: tools = {t.name: t for t in (await session.list_tools()).tools} look = await session.call_tool("look", {}) card = await _card_tools(app.streamable_http_app()) diff --git a/packages/agentenv-protocol/tests/test_tool_client.py b/packages/agentenv-protocol/tests/test_tool_client.py index 410219f4c..ca5294496 100644 --- a/packages/agentenv-protocol/tests/test_tool_client.py +++ b/packages/agentenv-protocol/tests/test_tool_client.py @@ -66,7 +66,7 @@ def _route_clients_to(monkeypatch, transport: httpx.AsyncBaseTransport) -> None: @pytest.fixture def serve_items(monkeypatch): """Serve an _Items env with create_fastmcp_app in the given mode, in process; yields the handler.""" - pytest.importorskip("mcp.server.fastmcp") + pytest.importorskip("mcp") async def serve(**fastmcp_kwargs): handler = _Items() diff --git a/packages/agentenv-protocol/tests/test_tool_context_fastmcp.py b/packages/agentenv-protocol/tests/test_tool_context_fastmcp.py index 1e046a7fc..a5fcb40ea 100644 --- a/packages/agentenv-protocol/tests/test_tool_context_fastmcp.py +++ b/packages/agentenv-protocol/tests/test_tool_context_fastmcp.py @@ -15,8 +15,6 @@ from fastmcp import Client, FastMCP # noqa: E402 from fastmcp.exceptions import ToolError # noqa: E402 from fastmcp.server.middleware import Middleware # noqa: E402 -from mcp import ClientSession # noqa: E402 -from mcp.client import streamable_http as _streamable_http # noqa: E402 from mcp.types import CallToolResult, TextContent # noqa: E402 from agentenv_protocol import ( # noqa: E402 @@ -165,23 +163,29 @@ async def test_the_hook_answers_a_call_before_the_tool_runs(): @pytest.mark.asyncio -@pytest.mark.skipif(not hasattr(_streamable_http, "streamable_http_client"), reason="mcp without streamable_http_client") async def test_a_direct_caller_over_http_gets_the_header_role_and_its_own_session(): app = FastMCP("fm") env = _Env() env.mount(app) asgi = app.http_app(path="/mcp", json_response=True) - base = "http://127.0.0.1:8000" - client = httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url=base, headers={ROLE_HEADER: "grader"}) + headers = {ROLE_HEADER: "grader", "Accept": "application/json, text/event-stream"} + client = httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url="http://127.0.0.1:8000", headers=headers) + + async def rpc(method: str, params: dict, sid: str | None = None) -> httpx.Response: + return await client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + headers={"mcp-session-id": sid} if sid else {}) + with anyio.fail_after(30): async with asgi.router.lifespan_context(asgi), client: - async with _streamable_http.streamable_http_client(f"{base}/mcp", http_client=client) as (read, write, session_id): - async with ClientSession(read, write) as session: - await session.initialize() - direct = await session.call_tool("whoami_sync", {}) - proxied = await session.call_tool("whoami_sync", {}, meta={ROLE_META_KEY: "bob"}) - sid = session_id() - assert sid and direct.content[0].text == "grader|grader" and proxied.content[0].text == "bob|bob" + initialized = await rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "t", "version": "1"}}) + sid = initialized.headers["mcp-session-id"] + await client.post("/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers={"mcp-session-id": sid}) + direct = (await rpc("tools/call", {"name": "whoami_sync", "arguments": {}}, sid)).json()["result"] + proxied = (await rpc("tools/call", {"name": "whoami_sync", "arguments": {}, + "_meta": {ROLE_META_KEY: "bob"}}, sid)).json()["result"] + assert direct["content"][0]["text"] == "grader|grader" and proxied["content"][0]["text"] == "bob|bob" assert env.seen[-2].caller == Caller("grader", sid) assert env.seen[-1].caller == Caller("bob") # a _meta role entry: the connection's id is not the caller's diff --git a/packages/agentenv-protocol/tests/test_tool_context_injection.py b/packages/agentenv-protocol/tests/test_tool_context_injection.py index 4d42b6fd4..56ef174ce 100644 --- a/packages/agentenv-protocol/tests/test_tool_context_injection.py +++ b/packages/agentenv-protocol/tests/test_tool_context_injection.py @@ -14,13 +14,10 @@ import pytest from pydantic import Field -pytest.importorskip("mcp.server.fastmcp") +pytest.importorskip("mcp") -from mcp import ClientSession # noqa: E402 -from mcp.client import streamable_http as _streamable_http # noqa: E402 -from mcp.server.fastmcp import Context, FastMCP # noqa: E402 -from mcp.server.fastmcp.tools.base import Tool # noqa: E402 -from mcp.shared.memory import create_connected_server_and_client_session # noqa: E402 +from mcp_versions import MCP_2, Context, FastMCP, Tool, http_session, wire # noqa: E402 +from mcp_versions import memory_session # noqa: E402 from mcp.types import CallToolResult, TextContent # noqa: E402 from agentenv_protocol import ( # noqa: E402 @@ -42,8 +39,9 @@ def _text(result: Any) -> str: - """The text of FastMCP.call_tool's result: a (content, structured) tuple for structured tools, else content.""" - content = result[0] if isinstance(result, tuple) else result + """The text of the server's call_tool result: mcp 2's CallToolResult, or on mcp 1's FastMCP a (content, + structured) tuple for structured tools, else the content.""" + content = result.content if MCP_2 else result[0] if isinstance(result, tuple) else result return content[0].text @@ -118,7 +116,7 @@ async def test_card_and_fastmcp_tools_list_both_omit_the_slot(): } card_tools = await _card_tools(app) assert card_tools["whoami"]["inputSchema"] == expected - listed = {t.name: t for t in await app.list_tools()} + listed = {t.name: t for t in wire(await app.list_tools())} assert set(listed["whoami"].inputSchema["properties"]) == {"q", "limit"} assert listed["whoami"].inputSchema["required"] == ["q"] assert "$defs" not in listed["whoami"].inputSchema @@ -162,7 +160,7 @@ async def test_concurrent_calls_with_different_meta_roles_never_cross(): results: dict[str, str] = {} async def call(role: str) -> None: - async with create_connected_server_and_client_session(app) as session: + async with memory_session(app) as session: result = await session.call_tool("rendezvous", {"who": role}, meta={ROLE_META_KEY: role}) assert result.isError is False results[role] = result.content[0].text @@ -198,7 +196,7 @@ async def test_header_fallback_and_default_blank_roles(): await call("whoami_sync", {}, context=_request_context({ROLE_META_KEY: " "}, None)) assert env.seen[-1][0].caller.role is None - async with create_connected_server_and_client_session(app) as session: + async with memory_session(app) as session: result = await session.call_tool("whoami_sync", {}, meta={ROLE_META_KEY: "default"}) assert result.content[0].text == "-" and env.seen[-1][0].caller == Caller() @@ -247,7 +245,7 @@ async def test_on_tool_call_refusal_reaches_the_wire_as_is_error_for_structured_ assert app._tool_manager.get_tool("structured").output_schema is not None assert app._tool_manager.get_tool("plain").output_schema is not None - async with create_connected_server_and_client_session(app) as session: + async with memory_session(app) as session: for name in ("structured", "plain", "slotless"): result = await session.call_tool(name, {"x": 1}, meta={ROLE_META_KEY: "intruder"}) assert result.isError is True, name @@ -273,7 +271,7 @@ class _BadReturn(_GuardedEnv): def on_tool_call(self, context: ToolContext) -> Any: return {"not": "a CallToolResult"} - async with create_connected_server_and_client_session(_Raising().create_app()) as session: + async with memory_session(_Raising().create_app()) as session: result = await session.call_tool("plain", {"x": 1}) assert result.isError is True and "boom" in result.content[0].text @@ -281,7 +279,7 @@ def on_tool_call(self, context: ToolContext) -> Any: app = bad.create_app() with pytest.raises(TypeError, match="on_tool_call must return None or mcp.types.CallToolResult, got dict"): await app.call_tool("plain", {"x": 1}) - async with create_connected_server_and_client_session(app) as session: + async with memory_session(app) as session: result = await session.call_tool("plain", {"x": 1}) assert result.isError is True and "on_tool_call must return None or mcp.types.CallToolResult" in result.content[0].text assert bad.ran == [] @@ -304,7 +302,7 @@ async def test_fastmcp_context_param_is_off_the_card_but_still_injected(): card_tools = await _card_tools(app) assert set(card_tools["t"]["inputSchema"]["properties"]) == {"x"} assert set(card_tools["both"]["inputSchema"]["properties"]) == {"x"} - listed = {t.name: t for t in await app.list_tools()} + listed = {t.name: t for t in wire(await app.list_tools())} assert set(listed["t"].inputSchema["properties"]) == {"x"} assert set(listed["both"].inputSchema["properties"]) == {"x"} assert app._tool_manager.get_tool("t").context_kwarg == "ctx" @@ -363,7 +361,7 @@ async def guard(name: str, arguments: dict, context: Any = None, convert_result: assert installed is not guard and installed.__wrapped__ is guard assert installed.__agentenv_tool_context__ is True - async with create_connected_server_and_client_session(app) as session: + async with memory_session(app) as session: result = await session.call_tool("slow", {"delay": 0}, meta={ROLE_META_KEY: "alice"}) assert result.isError is False and result.content[0].text == "alice|alice" assert guard_saw == [("slow", "alice")] @@ -407,26 +405,19 @@ def echo(self, q: str, tc: ToolContext) -> str: @pytest.mark.asyncio -@pytest.mark.skipif(not hasattr(_streamable_http, "streamable_http_client"), reason="mcp without streamable_http_client") async def test_direct_caller_over_streamable_http_gets_the_header_role_and_its_own_session(): env = _Env() app = FastMCP("direct", json_response=True) env.mount(app) - base = "http://127.0.0.1:8000" - client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app.streamable_http_app()), base_url=base, - headers={ROLE_HEADER: "grader"}) + asgi = app.streamable_http_app() with anyio.fail_after(30): - async with app.session_manager.run(), client: - async with _streamable_http.streamable_http_client(f"{base}/mcp", http_client=client) as (read, write, session_id): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool("whoami_sync", {}) - forwarded = await session.call_tool("whoami_sync", {}, meta={ROLE_META_KEY: "bob"}) - sid = session_id() - assert sid and result.content[0].text == "grader" and forwarded.content[0].text == "bob" + async with app.session_manager.run(), http_session(asgi, headers={ROLE_HEADER: "grader"}) as (session, sid): + result = await session.call_tool("whoami_sync", {}) + forwarded = await session.call_tool("whoami_sync", {}, meta={ROLE_META_KEY: "bob"}) + assert sid() and result.content[0].text == "grader" and forwarded.content[0].text == "bob" direct, proxied = env.seen[-2][0], env.seen[-1][0] - assert direct.caller == Caller("grader", sid) + assert direct.caller == Caller("grader", sid()) assert proxied.caller == Caller("bob") # a _meta role entry: the connection's id is not the caller's @@ -481,7 +472,7 @@ def echo(self, q: str) -> str: async def test_unknown_tool_is_answered_by_the_dispatch_without_the_hook(): env = _Env() app = env.create_app() - async with create_connected_server_and_client_session(app) as session: + async with memory_session(app) as session: result = await session.call_tool("nope", {"x": 1}, meta={ROLE_META_KEY: "alice"}) assert result.isError is True and "Unknown tool" in result.content[0].text assert env.hook_roles == [] and env.seen == [] diff --git a/pyproject.toml b/pyproject.toml index 48010176e..0c48fb59d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,13 +29,12 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agentenv-framework-protocol[computer]>=0.1.0", # a computer_driver env's put reads the driver class with it + "agentenv-framework-protocol[computer]>=0.1.0", # a computer_driver env's put reads the driver class with it; brings mcp (1 or 2) for agent_env's MCP clients "certifi>=2024.2.2", # the public roots the local transfer CA's trust bundle carries "click>=8.0.0", "cryptography>=42", # the local transfer CA "httpx>=0.27.0", "litellm>=1.96.0,<1.97", - "mcp>=1.25,<2", # same floor as env/gateway/requirements.txt and agentenv-protocol; 2.0.0 removed mcp.server.fastmcp, imported at module scope by env/gateway "packaging>=22", # evaluates a plugin's requirement markers with no extra "a2a-sdk==0.3.26", "pydantic>=2.12", # model_validate(extra=...) reads agent responses tolerantly @@ -94,7 +93,7 @@ daytona = [ ] dev = [ "agentenv-framework[aws,boxd,daytona,gcp,openenv,sail,tensorlake,tenki,vercel]", # exercise portable optional providers and stores in CI - "fastmcp>=3.4,<4", # the standalone-fastmcp mount path of the protocol; <4 keeps mcp 1.x + "fastmcp>=3.4,<5", # the standalone-fastmcp mount path of the protocol; openenv 0.10 holds the lock to fastmcp 3 and mcp 1 "Pillow>=10.4", # the protocol's computer extra, whose tests the unit tier runs "av>=17", # the protocol's computer extra, whose tests the unit tier runs and which the recording tests decode with "griffelib==2.3.0", # the plugin API check; exact, since what it reports as a break changes between releases diff --git a/src/agent_env/env/gateway/requirements.txt b/src/agent_env/env/gateway/requirements.txt index 89a76f21d..7795521ea 100644 --- a/src/agent_env/env/gateway/requirements.txt +++ b/src/agent_env/env/gateway/requirements.txt @@ -1,5 +1,5 @@ psycopg2-binary>=2.9.0 httpx -mcp>=1.25,<2 +mcp>=1.25,<2 # the gateway serves on mcp 1's FastMCP, which mcp 2 removed pydantic>=2.0 tzdata diff --git a/src/agent_env/env/legacy_protocol.py b/src/agent_env/env/legacy_protocol.py index e9533a844..b36ee8d2d 100644 --- a/src/agent_env/env/legacy_protocol.py +++ b/src/agent_env/env/legacy_protocol.py @@ -13,6 +13,7 @@ import httpx from agentenv_protocol import RPC_PATH, DataPart, client as protocol_v1 +from agentenv_protocol.mcp_compat import client_session if TYPE_CHECKING: from agent_env.env.env import DeployedEnv @@ -119,20 +120,15 @@ async def reset_via_mcp_tool( max_retries: int = DEFAULT_MCP_MAX_RETRIES, ) -> None: """Call the ``{environment_name}_reset`` MCP tool, retrying on transient failures.""" - from mcp import ClientSession - from mcp.client.streamable_http import streamable_http_client - for attempt in range(max_retries): reset_done = False try: - async with streamable_http_client(mcp_url) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - reset_tool_name = f"{environment_name}_reset" - result = await session.call_tool(reset_tool_name, {"mock_data_path": mock_data_path}) - result_text = "".join(c.text for c in result.content if hasattr(c, "text")) - logger.info(f"load_environment_artifact: {reset_tool_name} returned: {result_text}") - reset_done = True + async with client_session(mcp_url) as session: + reset_tool_name = f"{environment_name}_reset" + result = await session.call_tool(reset_tool_name, {"mock_data_path": mock_data_path}) + result_text = "".join(c.text for c in result.content if hasattr(c, "text")) + logger.info(f"load_environment_artifact: {reset_tool_name} returned: {result_text}") + reset_done = True except BaseExceptionGroup as eg: if reset_done: logger.info("load_environment_artifact: succeeded (ignoring cleanup ExceptionGroup)") 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 index 9328a9e81..e35d9440e 100644 --- a/src/agent_env/examples/echo-mcp/artifacts/check_echo/verify.py +++ b/src/agent_env/examples/echo-mcp/artifacts/check_echo/verify.py @@ -1,14 +1,12 @@ """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 +from agentenv_protocol.mcp_compat import client_session, field 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}) + async with client_session(mcp_url) as session: + 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}"}] + return [{"id": "echoes", "result": not field(result, "isError") and text == SENT, + "description": f"echo returned {text!r}"}] diff --git a/src/agent_env/examples/mail-mcp/artifacts/check_mail/verify.py b/src/agent_env/examples/mail-mcp/artifacts/check_mail/verify.py index 3d360af10..28308c6f5 100644 --- a/src/agent_env/examples/mail-mcp/artifacts/check_mail/verify.py +++ b/src/agent_env/examples/mail-mcp/artifacts/check_mail/verify.py @@ -1,15 +1,12 @@ """Calls the search_emails tool through the env's MCP endpoint and checks it finds the inbox's one invoice.""" import json -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client +from agentenv_protocol.mcp_compat import client_session, field 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("search_emails", {"query": "invoice"}) + async with client_session(mcp_url) as session: + result = await session.call_tool("search_emails", {"query": "invoice"}) text = "".join(getattr(part, "text", "") for part in result.content) - found = [email["id"] for email in json.loads(text)] if not result.isError else [] + found = [email["id"] for email in json.loads(text)] if not field(result, "isError") else [] return [{"id": "finds-the-invoice", "result": found == ["m-1"], "description": f"search_emails found {found}"}] diff --git a/src/agent_env/providers/env_providers/env_provider.py b/src/agent_env/providers/env_providers/env_provider.py index 16dd2982f..e516dafa5 100644 --- a/src/agent_env/providers/env_providers/env_provider.py +++ b/src/agent_env/providers/env_providers/env_provider.py @@ -9,8 +9,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, ClassVar import httpx -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client +from agentenv_protocol.mcp_compat import client_session from agent_env.env.env import DeployedEnv from agent_env.env.gateway.constants import WELL_KNOWN_PATH from agent_env.plugins import _registration @@ -205,10 +204,8 @@ def _card_from(response: httpx.Response) -> dict | None: async def _tool_names(mcp_url: str) -> list[str]: """The server's tool names as it lists them, duplicates included.""" - async with streamable_http_client(mcp_url) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - return [tool.name for tool in (await session.list_tools()).tools] + async with client_session(mcp_url) as session: + return [tool.name for tool in (await session.list_tools()).tools] def _builtin_env_providers() -> dict[str, type[_SandboxEnvironmentProvider]]: diff --git a/src/agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py b/src/agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py index c5e5c42e6..47f0c84fb 100644 --- a/src/agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +++ b/src/agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py @@ -5,9 +5,7 @@ import logging from typing import Any, ClassVar, Optional -import httpx -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client +from agentenv_protocol.mcp_compat import client_session, field from agent_env.task_step.context import TaskStepContext from agent_env.entity.refs import EntityRef @@ -105,12 +103,12 @@ def validate_tool_descriptions(tools: list) -> dict[str, Any]: """Check tool descriptions and parameter quality. Includes full tool schemas.""" missing = [t.name for t in tools if not (t.description or "").strip()] tool_schemas = [ - {"name": t.name, "description": t.description or "", "input_schema": t.inputSchema or {}} + {"name": t.name, "description": t.description or "", "input_schema": field(t, "inputSchema") or {}} for t in tools ] parameter_issues: dict[str, list[str]] = {} for t in tools: - issues = VerifyMCPToolSchemaTaskStep._check_parameter_issues(t.name, t.inputSchema or {}) + issues = VerifyMCPToolSchemaTaskStep._check_parameter_issues(t.name, field(t, "inputSchema") or {}) if issues: parameter_issues[t.name] = issues @@ -126,14 +124,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: mcp_url = deployed.mcp_url logger.info(f"Connecting to MCP at {mcp_url} to verify tool schemas") - http_client = httpx.AsyncClient() - try: - async with streamable_http_client(mcp_url, http_client=http_client) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - tools_result = await session.list_tools() - finally: - await http_client.aclose() + async with client_session(mcp_url) as session: + tools_result = await session.list_tools() tools = tools_result.tools logger.info(f"Retrieved {len(tools)} tool(s) from {mcp_url}") diff --git a/src/agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py b/src/agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py index 8a61e84dc..c2825af89 100644 --- a/src/agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +++ b/src/agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py @@ -23,8 +23,7 @@ import httpx import yaml -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client +from agentenv_protocol.mcp_compat import client_session, field from agent_env.env import legacy_protocol from agent_env.task_step.context import TaskStepContext @@ -263,7 +262,7 @@ def diff_conformance( spec_params = normalize_spec_params(spec) spec_ops, spec_collisions = _index_by_normalized(list(spec_params.items()), service_name) live, live_collisions = _index_by_normalized( - [(t.name, normalize_tool_params(t.inputSchema or {})) for t in tools], + [(t.name, normalize_tool_params(field(t, "inputSchema") or {})) for t in tools], service_name, ) @@ -436,14 +435,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # slow gateway can't trip httpx's short default and block the gate. mcp_url = deployed.mcp_url logger.info(f"Connecting to MCP at {mcp_url} to read live tools") - http_client = httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) - try: - async with streamable_http_client(mcp_url, http_client=http_client) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - tools_result = await session.list_tools() - finally: - await http_client.aclose() + async with client_session(mcp_url, timeout=_HTTP_TIMEOUT_S) as session: + tools_result = await session.list_tools() tools = tools_result.tools # 3. Diff. Without the tool-name prefix every tool reads as both missing and extra. diff --git a/tst/unit/drivers/reference_driver_test.py b/tst/unit/drivers/reference_driver_test.py index cbed28526..8bfd09a9a 100644 --- a/tst/unit/drivers/reference_driver_test.py +++ b/tst/unit/drivers/reference_driver_test.py @@ -14,12 +14,13 @@ import httpx import pytest -from mcp.shared.memory import create_connected_server_and_client_session import agent_env from agentenv_protocol.computer import (COMPUTER_EXEC, COMPUTER_KEYBOARD, COMPUTER_POINTER, COMPUTER_RECORDING, Platform, RecordHints) +from tst.util.mcp_versions import memory_session + DRIVER = Path(agent_env.__file__).parent / "drivers" / "envs" / "reference" / "driver.py" LINUX = Platform(os="linux", distribution="ubuntu", version="24.04", arch="amd64", display="x11") WIDTH, HEIGHT = 640, 400 @@ -176,7 +177,7 @@ def module(name: str, **attrs) -> types.ModuleType: @contextlib.asynccontextmanager async def mcp_session(driver): - async with create_connected_server_and_client_session(driver.mcp or driver.create_app()) as session: + async with memory_session(driver.mcp or driver.create_app()) as session: yield session diff --git a/tst/util/mcp_versions.py b/tst/util/mcp_versions.py new file mode 100644 index 000000000..f7b4c5724 --- /dev/null +++ b/tst/util/mcp_versions.py @@ -0,0 +1,54 @@ +"""An in-memory client session on an mcp server, on mcp 1 and mcp 2 alike, whose results read by mcp 1's attribute +names (``isError``, ``structuredContent``); the protocol package's tests keep the same helper.""" + +from __future__ import annotations + +import contextlib +from collections.abc import AsyncIterator +from types import SimpleNamespace +from typing import Any + +from agentenv_protocol.mcp_compat import mcp_2 +from pydantic import BaseModel + +MCP_2 = mcp_2() +if MCP_2: + from mcp import Client +else: + from mcp.shared.memory import create_connected_server_and_client_session + + +@contextlib.asynccontextmanager +async def memory_session(app: Any) -> AsyncIterator[Any]: + """An initialized client session on ``app``, in memory.""" + if MCP_2: + async with Client(app) as client: + yield _Session(client) + else: + async with create_connected_server_and_client_session(app) as session: + yield _Session(session) + + +def wire(value: Any) -> Any: + """An mcp result as mcp 1 names its fields: as it is on mcp 1, and on mcp 2 a model's fields as attributes under + their camel-case wire names, a mapping kept a mapping.""" + if not MCP_2: + return value + if isinstance(value, BaseModel): + fields = {(info.alias if info.alias and not info.alias.startswith("_") else name): wire(getattr(value, name)) + for name, info in type(value).model_fields.items()} + return SimpleNamespace(**fields, **(value.model_extra or {})) + if isinstance(value, list): + return [wire(item) for item in value] + return value + + +class _Session: + def __init__(self, session: Any) -> None: + self._session = session + + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None, **kwargs: Any) -> Any: + return wire(await self._session.call_tool(name, arguments, **kwargs)) + + async def list_tools(self, **kwargs: Any) -> Any: + return wire(await self._session.list_tools(**kwargs)) diff --git a/uv.lock b/uv.lock index bd20098c9..daa63fe56 100644 --- a/uv.lock +++ b/uv.lock @@ -56,7 +56,6 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "litellm" }, - { name = "mcp" }, { name = "modal" }, { name = "packaging" }, { name = "pydantic" }, @@ -155,7 +154,7 @@ requires-dist = [ { name = "daytona", marker = "extra == 'daytona'", specifier = "~=0.224.0" }, { name = "e2b", specifier = "~=2.46.4" }, { name = "fastapi", specifier = ">=0.110" }, - { name = "fastmcp", marker = "extra == 'dev'", specifier = ">=3.4,<4" }, + { name = "fastmcp", marker = "extra == 'dev'", specifier = ">=3.4,<5" }, { name = "google-api-core", marker = "extra == 'gcp'", specifier = ">=2.24" }, { name = "google-auth", marker = "extra == 'gcp'", specifier = ">=2.40" }, { name = "google-cloud-secret-manager", marker = "extra == 'gcp'", specifier = ">=2.20" }, @@ -164,7 +163,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "httpx2", marker = "extra == 'vercel'", specifier = ">=2.13.1,<3" }, { name = "litellm", specifier = ">=1.96.0,<1.97" }, - { name = "mcp", specifier = ">=1.25,<2" }, { name = "modal", specifier = ">=1.5.2.dev22" }, { name = "moto", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "openenv", marker = "extra == 'openenv'", specifier = ">=0.9" }, @@ -238,11 +236,11 @@ requires-dist = [ { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'dev'", specifier = ">=0.3.26,<0.4" }, { name = "av", marker = "extra == 'computer'", specifier = ">=17" }, { name = "av", marker = "extra == 'dev'", specifier = ">=17" }, - { name = "fastmcp", marker = "extra == 'dev'", specifier = ">=3.4,<4" }, + { name = "fastmcp", marker = "extra == 'dev'", specifier = ">=3.4,<5" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", marker = "extra == 'agent'", specifier = ">=1.25,<2" }, - { name = "mcp", marker = "extra == 'computer'", specifier = ">=1.25,<2" }, - { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.25,<2" }, + { name = "mcp", marker = "extra == 'agent'", specifier = ">=1.25,<3" }, + { name = "mcp", marker = "extra == 'computer'", specifier = ">=1.25,<3" }, + { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.25,<3" }, { name = "pillow", marker = "extra == 'computer'", specifier = ">=10.4" }, { name = "pillow", marker = "extra == 'dev'", specifier = ">=10.4" }, { name = "pydantic", specifier = ">=2.11" }, From 03661f38776ced75457b17c903cdff5729ca6713 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 15:28:39 -0700 Subject: [PATCH 2/3] fix(mcp): keep mcp's client timeouts in client_session, and put unit-mcp2's venv on PATH client_session built a plain httpx client when no timeout was given, so the env provider's tool listing, the legacy reset and the example verifiers lost mcp's 30 s and 300 s timeouts for httpx's 5 s default. It now builds the client with mcp's own create_mcp_http_client on both versions, and a test checks the defaults and an explicit timeout. The generated-CLI tests run their script on python3 from PATH, which the unit-mcp2 job didn't point at its venv, as the unit job does. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/local-backends.yml | 1 + .../src/agentenv_protocol/mcp_compat.py | 16 ++++---- .../tests/test_mcp_compat.py | 37 +++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 packages/agentenv-protocol/tests/test_mcp_compat.py diff --git a/.github/workflows/local-backends.yml b/.github/workflows/local-backends.yml index 54469d761..b7212f06e 100644 --- a/.github/workflows/local-backends.yml +++ b/.github/workflows/local-backends.yml @@ -73,6 +73,7 @@ jobs: 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" # Left out of both passes: the gateway's tests and those that build a card with the real gateway server, which # serves on mcp 1's FastMCP inside its own image; the openenv tests and the two packaging tests that read every # extra's installed metadata, openenv's included, which the unit job runs. diff --git a/packages/agentenv-protocol/src/agentenv_protocol/mcp_compat.py b/packages/agentenv-protocol/src/agentenv_protocol/mcp_compat.py index b0425cdc9..9dd50dfe0 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/mcp_compat.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/mcp_compat.py @@ -6,6 +6,7 @@ import contextlib import functools +import importlib import importlib.metadata import logging import re @@ -94,16 +95,15 @@ async def on_call_tool(ctx: Any, params: Any) -> Any: @contextlib.asynccontextmanager async def client_session(url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None, read_timeout: float | None = None) -> AsyncIterator[Any]: - """An initialized ``ClientSession`` on ``url`` over streamable HTTP. mcp 2 takes an ``httpx2`` client where mcp 1 - takes an ``httpx`` one, and yields two streams where mcp 1 yields three.""" + """An initialized ``ClientSession`` on ``url`` over streamable HTTP, on the client mcp builds for itself: its + timeouts (30 s, 300 s to read a stream) unless ``timeout`` is given. mcp 2's client is ``httpx2`` where mcp 1's is + ``httpx``, and yields two streams where mcp 1's yields three.""" from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client - if mcp_2(): - import httpx2 as http - else: - import httpx as http - limits = {} if timeout is None else {"timeout": http.Timeout(timeout, read=read_timeout or timeout)} - async with http.AsyncClient(headers=headers or {}, **limits) as client: + from mcp.shared._httpx_utils import create_mcp_http_client + http = importlib.import_module("httpx2" if mcp_2() else "httpx") + limits = None if timeout is None else http.Timeout(timeout, read=read_timeout or timeout) + async with create_mcp_http_client(headers=headers, timeout=limits) as client: async with streamable_http_client(url, http_client=client) as streams: async with ClientSession(streams[0], streams[1]) as session: await session.initialize() diff --git a/packages/agentenv-protocol/tests/test_mcp_compat.py b/packages/agentenv-protocol/tests/test_mcp_compat.py new file mode 100644 index 000000000..83ae011eb --- /dev/null +++ b/packages/agentenv-protocol/tests/test_mcp_compat.py @@ -0,0 +1,37 @@ +"""mcp_compat's client session: mcp's own HTTP client and its timeouts unless the caller gives one.""" +from __future__ import annotations + +import contextlib + +import pytest + +pytest.importorskip("mcp") + +import mcp.client.streamable_http as streamable_http # noqa: E402 + +from agentenv_protocol.mcp_compat import client_session # noqa: E402 + + +class _Stop(Exception): + pass + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("kwargs", "connect", "read"), [({}, 30, 300), ({"timeout": 5, "read_timeout": 60}, 5, 60)]) +async def test_a_session_keeps_mcps_client_timeouts_unless_given_its_own(monkeypatch, kwargs, connect, read): + seen = [] + + @contextlib.asynccontextmanager + async def streams(url, *, http_client): + seen.append(http_client) + raise _Stop + yield + + monkeypatch.setattr(streamable_http, "streamable_http_client", streams) + with pytest.raises(_Stop): + async with client_session("http://127.0.0.1:1/mcp", headers={"AgentEnv-Role": "grader"}, **kwargs): + pass + + [client] = seen + assert (client.timeout.connect, client.timeout.read) == (connect, read) + assert client.headers["AgentEnv-Role"] == "grader" From 5ae9c204a300c7694358b0aeec90f942b7afc8c4 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 16:15:21 -0700 Subject: [PATCH 3/3] ci(mcp): find the unit-mcp2 job's left-out and fastmcp tests by what they import A test added on main that imports the gateway server broke the job's fixed --ignore list. .github/scripts/mcp2_test_split.py now finds the tests that import the gateway server, directly or through a test module that does, and the protocol tests that import fastmcp, so a new one is placed on its own. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/mcp2_test_split.py | 38 ++++++++++++++++++++++++++++ .github/workflows/local-backends.yml | 21 +++------------ 2 files changed, 42 insertions(+), 17 deletions(-) create mode 100644 .github/scripts/mcp2_test_split.py diff --git a/.github/scripts/mcp2_test_split.py b/.github/scripts/mcp2_test_split.py new file mode 100644 index 000000000..050684e1f --- /dev/null +++ b/.github/scripts/mcp2_test_split.py @@ -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]) diff --git a/.github/workflows/local-backends.yml b/.github/workflows/local-backends.yml index b7212f06e..0139b2b98 100644 --- a/.github/workflows/local-backends.yml +++ b/.github/workflows/local-backends.yml @@ -74,29 +74,16 @@ jobs: -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" - # Left out of both passes: the gateway's tests and those that build a card with the real gateway server, which - # serves on mcp 1's FastMCP inside its own image; the openenv tests and the two packaging tests that read every - # extra's installed metadata, openenv's included, which the unit job runs. + # .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 - --ignore=tst/unit/env/gateway --ignore=tst/unit/env/test_env.py --ignore=tst/unit/env/envs/test_env_card_loads.py - --ignore=tst/unit/providers/env_providers/env_gateway_provider_test.py - --ignore=tst/unit/task_step/sync_env_clock_test.py --ignore=tst/unit/task_step/test_apply_server_config.py - --ignore=tst/unit/task_step/test_verify_universe_roundtrip.py --ignore=tst/unit/task_step/snapshot_env_upload_grant_test.py - --ignore=tst/unit/task_step/test_snapshot_env.py --ignore=tst/unit/task_step/test_snapshot_agent_state.py - --ignore=tst/unit/openenv --ignore=tst/unit/test_optional_extras_stay_optional.py - --ignore=tst/unit/test_runtime_dependencies_are_imported.py - --ignore=packages/agentenv-protocol/tests/test_computer_spaces.py --ignore=packages/agentenv-protocol/tests/test_errors.py - --ignore=packages/agentenv-protocol/tests/test_tool_access.py --ignore=packages/agentenv-protocol/tests/test_tool_annotations.py - --ignore=packages/agentenv-protocol/tests/test_tool_context_fastmcp.py + $(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 - packages/agentenv-protocol/tests/test_computer_spaces.py packages/agentenv-protocol/tests/test_errors.py - packages/agentenv-protocol/tests/test_tool_access.py packages/agentenv-protocol/tests/test_tool_annotations.py - packages/agentenv-protocol/tests/test_tool_context_fastmcp.py + .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: