Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
997449b
feat(website): a Notes site on WebsiteEnvironment, one image that pas…
williamloo-scale Nov 3, 2026
7278968
fix(website): the Notes page saves queued edits before switching user…
williamloo-scale Nov 3, 2026
13b3046
fix(website): the Notes page keeps the signed-in user when a save or …
williamloo-scale Nov 3, 2026
c0f94a7
refactor(website): the Notes example reads what a task left it with t…
williamloo-scale Nov 4, 2026
35c2089
fix(website): the Notes page keeps unsaved edits apart from its input…
williamloo-scale Nov 4, 2026
7017624
refactor(website): the Notes example keeps its own data and is ready …
williamloo-scale Nov 5, 2026
48ac23c
fix(website): the Notes example changes its data under one lock, so c…
williamloo-scale Nov 5, 2026
4bab4b8
feat(website): a website env may be an MCP server too, its tools decl…
williamloo-scale Nov 5, 2026
dac69e2
fix(website): a site's MCP calls through /svc carry the gateway's rol…
williamloo-scale Nov 5, 2026
7a909f1
test(website): the Notes example skips the mcp check, serving no tools
williamloo-scale Nov 5, 2026
199d1f2
fix(website): a site's MCP endpoint may be a full URL, is reserved ho…
williamloo-scale Nov 5, 2026
31b695a
fix(website): a site's MCP endpoint keeps the site's own spelling, an…
williamloo-scale Nov 5, 2026
3d9e4aa
fix(website): a full MCP URL on a site behind a path prefix keeps its…
williamloo-scale Nov 5, 2026
555ab9b
Merge remote-tracking branch 'origin/main' into review-508
earakely-scale Nov 6, 2026
d7f3c7b
fix(website): a full MCP URL under a site's base path stays off its r…
earakely-scale Nov 6, 2026
ffefe67
Merge origin/main (#444's conformance levels) into wloo/website-mcp; …
earakely-scale Nov 6, 2026
bc3a539
fix(gateway): the gateway doesn't start while a site's card can't be …
earakely-scale Nov 6, 2026
493f52f
fix(gateway): an MCP endpoint a site's card names by a relative path …
earakely-scale Nov 6, 2026
ba64a8a
refactor(gateway): one reading of a site card's URLs for discovery, /…
earakely-scale Nov 6, 2026
d593587
test(integration): throwaway registries run the mirror image the work…
earakely-scale Nov 6, 2026
677c5d6
fix(computers): a restarted desktop clears the X lock a killed X left
earakely-scale Oct 30, 2026
56ba1e2
fix(cli): plugin list, show and check write their report to the stdou…
earakely-scale Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,31 @@ print(env.ready, env.problems) # False ['shop: seeded']
asked, or answers no `WebsiteReadiness`, is a problem itself. A card or child without `ready`, an MCP server's or an
older site's, isn't counted, so an env with no site serving it is ready with no problems.

## Websites with tools

A website env may also be an MCP server, so an agent can act on a site through tools as well as its pages. Give a
`WebsiteEnvironment` `@tool` (or `@resource`) methods, as an MCP env has; they need the `mcp` package. The site then
serves them at `/mcp` (streamable HTTP) beside its pages, `@api` routes and data plane, and its card declares that
endpoint as its `mcp` interface (`additionalInterfaces`) and lists the tools under `capabilities.tools`. A site without
tools serves no `/mcp` and declares no interface; `serves_mcp()` says which a site is.

```python
@environment_card(name="shop")
@website(domains=["shop.example"], static="dist")
class Shop(WebsiteEnvironment):
@tool()
def place_order(self, item: str, ctx: ToolContext) -> str:
"""Place an order for item, as the caller."""
...
```

A tool runs in the site's process, so it changes what its pages show and what `data/get` returns, and it reads the
universe's time (`self.now()`) as a page's request does. The gateway reads each site's card when it starts, and serves
the tools of a site whose card declares an `mcp` interface as it does an MCP env's: listed to agents with the rest,
called with the caller's role in `ToolContext`. The site's `/mcp` is one of the paths its card advertises, which its
browsing route (`/website/<name>/`) never reaches. `python -m agentenv_protocol.website.conformance` checks a declared
endpoint (`mcp`): it answers `initialize` and lists the tools the card does.

## Computer drivers

A computer driver is what runs inside a computer an agent uses through screenshots and input. Write
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
the card's ``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` (by the declared health path,
deprecated, for a card without it); the data plane's and card's paths not the site's pages; and the data plane the
card advertises, whose round trip at the card's ``url`` (data/get, data/reset, data/get, which no longer returns the
exported data, data/add of what the first data/get returned, data/get again) returns what it was given. A failed one is
``failed``. The recommended checks are what an SDK site does and the gateway uses when a server offers it:
exported data, data/add of what the first data/get returned, data/get again) returns what it was given; and, for a
card declaring an ``mcp`` interface, a site that is an MCP server too, that endpoint answering ``initialize`` and
listing, every page of ``tools/list`` read, no tool its card doesn't (streamable HTTP, ``MCP_PROTOCOL_VERSION``); a
card's tool it doesn't list is warned, since a hidden-by-default tool is listed to no caller until one enables it.
A failed one is ``failed``. The recommended checks are what an SDK site does and the gateway uses when a server offers it:
``X-Universe-Now`` on every answer the other checks got, ``clock/v1``'s ``sync_time`` against a clock the checker serves, the forwarded headers
wherever an answer shows them (on ``/`` and on a GET route with its trailing slash toggled, which a server may
redirect), and the cookies those answers set: none tied to a domain, the session path's the one the card names, and
Expand Down Expand Up @@ -38,13 +41,15 @@
from pydantic import ValidationError

from ..client import find_extension, find_extension_method
from ..types import METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH
from ..types import MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH
from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time
from .readiness import DEFAULT_HEALTH_PATH, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness

Status = Literal["passed", "failed", "skipped", "warned"]
CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc)
CLOCK_SLACK_S = 120
MCP_PROTOCOL_VERSION = "2025-06-18"
MCP_LIST_PAGES = 100


@dataclasses.dataclass(frozen=True)
Expand Down Expand Up @@ -112,6 +117,7 @@ def run(self) -> list[Result]:
steps = (("card", True, lambda: self._card(response, card, site)), ("any_host", True, self._any_host),
("health", True, lambda: self._health(card, health)), ("universe_now", False, self._universe_now),
("reserved_paths", True, self._reserved_paths), ("data_plane", True, lambda: self._data_plane(card)),
("mcp", True, lambda: self._mcp(card)),
("clock", False, lambda: self._clock(card)), ("forwarded", False, lambda: self._forwarded(site)),
("cookies", False, lambda: self._cookies(site)))
outcomes = {name: _outcome(step) for name, _required, step in steps if name != "universe_now"}
Expand Down Expand Up @@ -228,6 +234,55 @@ def _data_plane(self, card: dict) -> tuple[Status, str]:
return "failed", "data/get after data/reset and data/add of its own export returned other data"
return "passed", f"{len(exported)} part(s) round-tripped through reset and add"

def _mcp(self, card: dict) -> tuple[Status, str]:
"""The MCP endpoint the card declares answers ``initialize`` and lists no tool the card doesn't, reading up to
``MCP_LIST_PAGES`` pages of ``tools/list``; its answers needn't carry ``X-Universe-Now``, which the MCP
transport doesn't promise."""
interfaces = [i for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)
and i.get("transport") == MCP_TRANSPORT]
if not interfaces:
return "skipped", "the card declares no mcp interface"
path = interfaces[0].get("url")
if not isinstance(path, str) or not path:
return "failed", f"the card's mcp interface has no url: {path!r}"
url = self._url(path)
headers = {"Accept": "application/json, text/event-stream"}
first = self.client.post(url, headers=headers, json={
"jsonrpc": "2.0", "id": "initialize", "method": "initialize",
"params": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {},
"clientInfo": {"name": "agentenv-website-conformance", "version": "1"}}})
server = _mcp_result(first, "initialize", "initialize").get("serverInfo") or {}
if session := first.headers.get("mcp-session-id"):
headers["Mcp-Session-Id"] = session
try:
self.client.post(url, headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
served: list = []
cursor = None
for _page in range(MCP_LIST_PAGES):
listed = _mcp_result(self.client.post(url, headers=headers, json={
"jsonrpc": "2.0", "id": "tools/list", "method": "tools/list",
**({"params": {"cursor": cursor}} if cursor else {})}), "tools/list", "tools/list")
if not isinstance(listed, dict) or not isinstance(listed.get("tools"), list):
return "failed", f"tools/list at {path} answered no tools list: {str(listed)[:200]}"
served += [t.get("name") for t in listed["tools"] if isinstance(t, dict)]
cursor = listed.get("nextCursor")
if not cursor:
break
else:
return "failed", f"tools/list at {path} still had a next page after {MCP_LIST_PAGES}"
finally:
if session:
with contextlib.suppress(httpx.HTTPError):
self.client.delete(url, headers=headers)
advertised = {t.get("name") for t in (card.get("capabilities") or {}).get("tools") or [] if isinstance(t, dict)}
if unadvertised := sorted(set(served) - advertised):
return "failed", f"tools/list at {path} named {unadvertised}, which the card doesn't list"
name = server.get("name") or "the server"
if unlisted := sorted(advertised - set(served)):
return "warned", (f"{name} at {path} lists {len(served)} of the card's tools, not {unlisted}: hidden by "
"default, or missing")
return "passed", f"{name} at {path} lists the card's {len(served)} tool(s)"

def _clock(self, card: dict) -> tuple[Status, str]:
if find_extension(card, CLOCK_EXTENSION_URI) is None:
return "failed", f"the card declares no {CLOCK_EXTENSION_URI}"
Expand Down Expand Up @@ -310,6 +365,48 @@ def _outcome(step: Callable[[], tuple[Status, str]]) -> tuple[Status, str]:
return "failed", f"{type(e).__name__}: {e}"


def _mcp_result(response: httpx.Response, method: str, request_id: str) -> dict:
"""The result of the JSON-RPC reply to request ``request_id``: the answer's JSON, or the reply among an event
stream's messages, each event's data lines joined, as ``ToolSession`` reads them."""
if response.headers.get("content-type", "").startswith("text/event-stream"):
messages = []
for data in _sse_data(response.text):
try:
messages.append(json.loads(data))
except ValueError:
raise RuntimeError(f"{method} answered an event that isn't JSON: {data[:200]!r}") from None
else:
try:
messages = [json.loads(response.text)]
except ValueError:
raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC answer: "
f"{response.text[:200]!r}") from None
body = next((m for m in messages if isinstance(m, dict) and m.get("id") == request_id
and ("result" in m or "error" in m)), None)
if body is None or "result" not in body:
raise RuntimeError(f"{method} answered {response.status_code}: {str(body or messages)[:200]}")
return body["result"] or {}


def _sse_data(text: str) -> Iterator[str]:
"""The data of each ``message`` event in a server-sent event stream, its data lines joined; an event the stream
ends inside is dropped, as are events with no data."""
event, data = "message", []
for line in text.splitlines():
if not line:
payload = "\n".join(data)
if payload.strip() and event == "message":
yield payload
event, data = "message", []
continue
field, _, value = line.partition(":")
value = value[1:] if value.startswith(" ") else value
if field == "data":
data.append(value)
elif field == "event":
event = value


def _field_error(error: dict) -> str:
"""One of a ``ValidationError``'s errors as a person reads it: where, then what."""
where = ".".join(map(str, error.get("loc") or ())) or "the answer"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
(``@reset_data``, ``@add_data``, ``@get_data``) as any AgentEnvEnvironment does: ``data/get`` is how agentenv reads
what a task left a site with.

A site may also be an MCP server, as an MCP env is: its ``@tool`` and ``@resource`` methods are served at ``/mcp``
(streamable HTTP, of the ``mcp`` package), which its card declares as its ``mcp`` interface and lists the tools of, so
an agent can act on the site through tools beside its pages. A site with none serves no ``/mcp``.

One route is built in, replaced by a site's own ``@api`` route at its very path and answering before one whose path
parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the acting
user in when nobody is (GET), or to sign out (DELETE)."""
Expand All @@ -32,9 +36,12 @@

from ..agent_env_environment import (
AgentEnvEnvironment,
_RESOURCE_ATTR,
_TOOL_ATTR,
AgentEnvStarletteApplication,
_marked_members,
_maybe_await,
create_fastmcp_app,
extension,
)
from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, ClockUnavailable, UniverseClock, clock_patch_script, iso
Expand Down Expand Up @@ -164,8 +171,15 @@ def create_app(self) -> Starlette:
api_routes = self.routes()
taken = {openapi_path(r.path) for r in api_routes}
root = self._site_path(declaration.static) if declaration.static is not None else None
asgi = Starlette(middleware=[Middleware(_Forwarded), Middleware(_Universe, env=self)])
AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi)
middleware = [Middleware(_Forwarded), Middleware(_Universe, env=self)]
if self.serves_mcp():
self.mcp = create_fastmcp_app(self, card=self._build_card())
served = self.mcp.streamable_http_app()
asgi = Starlette(middleware=middleware, lifespan=served.router.lifespan_context)
asgi.router.routes.extend(served.routes)
else:
asgi = Starlette(middleware=middleware)
AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi)
if SESSION_PATH not in taken:
asgi.router.routes.append(Route(SESSION_PATH, self._session, methods=["GET", "DELETE"]))
asgi.router.routes.extend(Route(r.path, _unreserved(endpoint(getattr(self, r.fn.__name__), r, self.context)),
Expand All @@ -175,6 +189,11 @@ def create_app(self) -> Starlette:
self._mounted = True
return asgi

def serves_mcp(self) -> bool:
"""Whether the site is an MCP server too: it has ``@tool`` or ``@resource`` methods, or forwards tools."""
return any(True for _ in _marked_members(self, _TOOL_ATTR)) or any(
True for _ in _marked_members(self, _RESOURCE_ATTR)) or bool(self.forwarded_tools())

def mount(self, app: Any) -> Any:
raise TypeError(f"{type(self).__name__} builds its own app with create_app(); set app = ... to serve more")

Expand Down
12 changes: 6 additions & 6 deletions packages/agentenv-protocol/tests/test_website_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from agentenv_protocol.website import conformance
from agentenv_protocol.website.clock import UNIVERSE_NOW_HEADER, iso

CHECKS = ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "clock", "forwarded", "cookies"]
CHECKS = ["card", "any_host", "health", "universe_now", "reserved_paths", "data_plane", "mcp", "clock", "forwarded", "cookies"]


def statuses(results: list[conformance.Result]) -> dict[str, str]:
Expand Down Expand Up @@ -68,7 +68,7 @@ def sdk_site(notes: WebsiteEnvironment) -> TestClient:
def test_an_sdk_site_passes_every_check(sdk_site: TestClient) -> None:
results = conformance.check("http://testserver", client=sdk_site)
assert [r.name for r in results] == CHECKS
assert all(r.status == "passed" for r in results), results
assert all(r.status == ("skipped" if r.name == "mcp" else "passed") for r in results), results
assert sdk_site.post("/agentenv", json={"jsonrpc": "2.0", "id": 1, "method": "data/get"}).json()["result"][
"parts"][0]["data"] == {"notes": [{"id": 1, "text": "hello"}]}

Expand Down Expand Up @@ -173,7 +173,7 @@ async def unrouted(request: Request, exc: Exception) -> Response:

def test_a_handwritten_server_meeting_the_contract_passes() -> None:
results = conformance.check("http://testserver", client=TestClient(_handwritten()))
assert statuses(results) == dict.fromkeys(CHECKS, "passed"), results
assert statuses(results) == {**dict.fromkeys(CHECKS, "passed"), "mcp": "skipped"}, results


@pytest.mark.parametrize(("answer", "status", "detail"), [
Expand Down Expand Up @@ -317,9 +317,9 @@ def test_a_server_with_no_card_health_or_header_fails_the_contract_and_warns_of_
bare = Starlette(routes=[Route("/", lambda request: JSONResponse({"not": "html"}))])
results = conformance.check("http://testserver", client=TestClient(bare))
assert statuses(results) == {"card": "failed", "any_host": "failed", "health": "failed", "universe_now": "warned",
"reserved_paths": "passed", "data_plane": "failed", "clock": "warned",
"forwarded": "passed", "cookies": "skipped"}
assert [r.name for r in results if r.required] == ["card", "any_host", "health", "reserved_paths", "data_plane"]
"reserved_paths": "passed", "data_plane": "failed", "mcp": "skipped",
"clock": "warned", "forwarded": "passed", "cookies": "skipped"}
assert [r.name for r in results if r.required] == ["card", "any_host", "health", "reserved_paths", "data_plane", "mcp"]


def test_a_page_and_a_health_path_with_no_card_or_data_plane_fail_the_contract() -> None:
Expand Down
Loading
Loading