From 997449b74d4cc3338cd51d3caf5012a225c13b39 Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 13:34:07 -0800 Subject: [PATCH 01/13] feat(website): a Notes site on WebsiteEnvironment, one image that passes the conformance check (FD-4683) This PR's change, rebased as one commit onto #428. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/.gitignore | 2 + .../examples/website/notes/Dockerfile | 26 + .../website/notes/Dockerfile.dockerignore | 6 + .../examples/website/notes/README.md | 89 ++ .../examples/website/notes/data/seed.json | 15 + .../examples/website/notes/env.toml | 4 + .../examples/website/notes/site.py | 131 +++ .../examples/website/notes/web/index.html | 34 + .../website/notes/web/package-lock.json | 1020 +++++++++++++++++ .../examples/website/notes/web/package.json | 14 + .../examples/website/notes/web/src/main.js | 210 ++++ .../examples/website/notes/web/src/style.css | 57 + .../examples/website/notes/web/vite.config.js | 11 + .../tests/test_website_notes_example.py | 237 ++++ 14 files changed, 1856 insertions(+) create mode 100644 packages/agentenv-protocol/examples/website/notes/.gitignore create mode 100644 packages/agentenv-protocol/examples/website/notes/Dockerfile create mode 100644 packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore create mode 100644 packages/agentenv-protocol/examples/website/notes/README.md create mode 100644 packages/agentenv-protocol/examples/website/notes/data/seed.json create mode 100644 packages/agentenv-protocol/examples/website/notes/env.toml create mode 100644 packages/agentenv-protocol/examples/website/notes/site.py create mode 100644 packages/agentenv-protocol/examples/website/notes/web/index.html create mode 100644 packages/agentenv-protocol/examples/website/notes/web/package-lock.json create mode 100644 packages/agentenv-protocol/examples/website/notes/web/package.json create mode 100644 packages/agentenv-protocol/examples/website/notes/web/src/main.js create mode 100644 packages/agentenv-protocol/examples/website/notes/web/src/style.css create mode 100644 packages/agentenv-protocol/examples/website/notes/web/vite.config.js create mode 100644 packages/agentenv-protocol/tests/test_website_notes_example.py diff --git a/packages/agentenv-protocol/examples/website/notes/.gitignore b/packages/agentenv-protocol/examples/website/notes/.gitignore new file mode 100644 index 000000000..545a6e274 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/.gitignore @@ -0,0 +1,2 @@ +web/node_modules/ +web/dist/ diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile b/packages/agentenv-protocol/examples/website/notes/Dockerfile new file mode 100644 index 000000000..461e886b2 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile @@ -0,0 +1,26 @@ +ARG NOTES=packages/agentenv-protocol/examples/website/notes + +FROM mirror.gcr.io/library/node:22-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS web +ARG NOTES +WORKDIR /web +COPY ${NOTES}/web/package.json ${NOTES}/web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY ${NOTES}/web/ ./ +RUN npm run build + +FROM mirror.gcr.io/library/python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf +ARG NOTES +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY packages/agentenv-protocol /tmp/agentenv-protocol +RUN pip install --no-cache-dir /tmp/agentenv-protocol "uvicorn>=0.30" && rm -rf /tmp/agentenv-protocol +COPY ${NOTES}/site.py /app/site.py +COPY ${NOTES}/data /app/data +COPY --from=web /web/dist /app/web/dist +ENV PORT=8000 +EXPOSE 8000 +RUN useradd --create-home --uid 10001 appuser +USER appuser +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/healthz' % os.environ.get('PORT', '8000'), timeout=2)" +CMD ["python", "site.py"] diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore b/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore new file mode 100644 index 000000000..10ae9270c --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +**/node_modules +**/dist +**/__pycache__ +**/.pytest_cache +**/.venv +.git diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md new file mode 100644 index 000000000..3311112b0 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -0,0 +1,89 @@ +# Notes: a reference website/v1 site + +Notes is a small notes app built on `agentenv_protocol.website.WebsiteEnvironment`. It is the reference site for the +`urn:agentenv:website/v1` server contract. Each user keeps their own notes and can create, edit, mark done and delete +them. The page shows the universe's time. + +| File | What it is | +| --- | --- | +| `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | +| `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | +| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike. | +| `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. | +| `env.toml` | The website env as a bundle entry. | + +## What it exercises + +- **Site declaration.** `@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")`. + The card advertises website/v1 with the domains, the health path and every route by name. +- **Routes.** `list_users`, `sign_in`, `list_notes` (with an optional `?done=`), `create_note` (201), `get_note`, + `edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`. +- **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only a caller that + sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller + gets 403. The gateway passes on whatever role a browser sends, so without the key a page could claim `admin`; with no + `WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed. +- **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the + store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded + user. A user's notes are invisible to every other user, and another user's note answers 404. +- **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or + as a file part by bytes or `file://`), `data/get` exports it, `data/reset` empties it, and `/go` returns + `{initial_state, current_state, state_diff}`. +- **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names + it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header. + It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the + SDK. +- **Readiness.** `@ready_check seeded` holds `/healthz` at 503 until the store has users, for example after a + `data/reset` and before the next `data/add`. + +## Run it + +From this folder: + +```bash +cd web && npm ci && npm run build && cd .. +PYTHONPATH=/packages/agentenv-protocol/src python site.py +``` + +Open http://localhost:8000. To work on the frontend, run `npm run dev` in `web/` while `site.py` runs; Vite proxies +`/api` to port 8000. + +## Test it + +```bash +python -m pytest packages/agentenv-protocol/tests/test_website_notes_example.py +``` + +The test imports `site.py`, runs the conformance checker, and checks sign-in, per-user notes, the admin-only route, +clock-synced timestamps, and the data plane's reset and seed load. It serves a stand-in page, so it needs no Vite +build. + +## Build and verify the image + +The build context is the repo root, because the image installs the protocol package from this repo's source: + +```bash +docker build -f packages/agentenv-protocol/examples/website/notes/Dockerfile -t notes-site . +docker run -d --name notes-site -p 18080:8000 notes-site +python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal +curl -s http://localhost:18080/api/notes +``` + +Use `--clock-host` so the container can reach the checker's clock. The clock check leaves the server following a +clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart +the container before using it. + +## Register it + +```bash +agent-env env website put --id notes-site \ + --server-dockerfile packages/agentenv-protocol/examples/website/notes/Dockerfile \ + --server-docker-context . --health-path /healthz +``` + +Run it from the repo root. `environment_name` comes from the card (`@environment_card(name="notes")`). The stored env +has a server image and no frontend image. Pass `--skip-validation` to register without deploying. + +`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name`, `health` and +`backend_image`. Domains are not one of them: they come from the card. A bundle builds `backend_image` with this +folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a +published release instead, register the site with `put` as above. diff --git a/packages/agentenv-protocol/examples/website/notes/data/seed.json b/packages/agentenv-protocol/examples/website/notes/data/seed.json new file mode 100644 index 000000000..0a52ab7be --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/data/seed.json @@ -0,0 +1,15 @@ +{ + "current_user": "ann", + "users": [ + {"id": "ann", "name": "Ann Lee"}, + {"id": "ben", "name": "Ben Ortiz"} + ], + "notes": [ + {"id": 1, "owner": "ann", "title": "Buy milk", "body": "Oat, two cartons.", "done": false, + "created_at": "2026-01-05T09:00:00.000Z", "updated_at": "2026-01-05T09:00:00.000Z"}, + {"id": 2, "owner": "ann", "title": "Book dentist", "body": "", "done": true, + "created_at": "2026-01-04T15:30:00.000Z", "updated_at": "2026-01-06T08:10:00.000Z"}, + {"id": 3, "owner": "ben", "title": "Draft the quarterly report", "body": "Due Friday.", "done": false, + "created_at": "2026-01-03T11:00:00.000Z", "updated_at": "2026-01-03T11:00:00.000Z"} + ] +} diff --git a/packages/agentenv-protocol/examples/website/notes/env.toml b/packages/agentenv-protocol/examples/website/notes/env.toml new file mode 100644 index 000000000..a1d019251 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/env.toml @@ -0,0 +1,4 @@ +type = "website" +environment_name = "notes" +health = "/healthz" +backend_image = { dockerfile = "Dockerfile" } diff --git a/packages/agentenv-protocol/examples/website/notes/site.py b/packages/agentenv-protocol/examples/website/notes/site.py new file mode 100644 index 000000000..5eaafda0e --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -0,0 +1,131 @@ +"""Notes: the reference site of ``urn:agentenv:website/v1``, a small notes app on WebsiteEnvironment. Each user keeps +notes they create, edit, mark done and delete; a browser with no session is signed in as the acting user, the seed's +``current_user``, and can sign in as another seeded user. The site keeps its state in the default store, which the data +plane loads, exports and resets, and stamps each note with the universe's time. ``admin`` callers, as the gateway's +``AgentEnv-Role`` names them, can list every user's notes. + +Run it with ``python site.py`` after building ``web/`` into ``web/dist``; it serves on ``PORT`` (8000).""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel + +from agentenv_protocol import environment_card +from agentenv_protocol.website import ( + ApiContext, + BadRequest, + NotFound, + Unauthorized, + WebsiteEnvironment, + api, + ready_check, + website, +) +from agentenv_protocol.website.clock import iso + +NOTES = "notes" +USERS = "users" + + +class NewNote(BaseModel): + title: str + body: str = "" + + +class NoteEdit(BaseModel): + title: Optional[str] = None + body: Optional[str] = None + done: Optional[bool] = None + + +class SignIn(BaseModel): + user: str + + +@environment_card(name="notes") +@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json") +class Notes(WebsiteEnvironment): + @ready_check + def seeded(self) -> bool: + """The site is usable once its store knows who may sign in.""" + return bool(self.store[USERS]) + + @api.get("/api/users", name="list_users") + def list_users(self) -> list: + """The users a browser can sign in as.""" + return self.store[USERS] + + @api.post("/api/sign-in", name="sign_in") + def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: + """Sign the browser in as one of the site's users.""" + if self.store.get(USERS, body.user) is None: + raise NotFound(f"no user {body.user!r}") + ctx.sign_in(body.user) + return {"user": ctx.user} + + @api.get("/api/notes", name="list_notes") + def list_notes(self, ctx: ApiContext, done: Optional[bool] = None) -> list: + """The signed-in user's notes, newest first; with ``done``, only those done or not.""" + match = {"owner": self._user(ctx)} if done is None else {"owner": self._user(ctx), "done": done} + return sorted(self.store.find(NOTES, **match), key=lambda note: note["created_at"], reverse=True) + + @api.post("/api/notes", name="create_note", status=201) + def create_note(self, note: NewNote, ctx: ApiContext) -> dict: + """Add a note for the signed-in user, not done, created and updated now.""" + now = iso(self.now()) + return self.store.insert(NOTES, {"owner": self._user(ctx), "title": _title(note.title), "body": note.body, + "done": False, "created_at": now, "updated_at": now}) + + @api.get("/api/notes/{note_id:int}", name="get_note") + def get_note(self, note_id: int, ctx: ApiContext) -> dict: + return self._note(note_id, ctx) + + @api.patch("/api/notes/{note_id:int}", name="edit_note") + def edit_note(self, note_id: int, edit: NoteEdit, ctx: ApiContext) -> dict: + """Change a note's title, body or done, and its ``updated_at``.""" + note = self._note(note_id, ctx) + fields = edit.model_dump(exclude_none=True) + if "title" in fields: + fields["title"] = _title(fields["title"]) + note.update(fields, updated_at=iso(self.now())) + return note + + @api.post("/api/notes/{note_id:int}/toggle", name="toggle_note") + def toggle_note(self, note_id: int, ctx: ApiContext) -> dict: + """Mark a note done, or not done when it was.""" + note = self._note(note_id, ctx) + note.update(done=not note["done"], updated_at=iso(self.now())) + return note + + @api.delete("/api/notes/{note_id:int}", name="delete_note") + def delete_note(self, note_id: int, ctx: ApiContext) -> dict: + self.store.delete(NOTES, self._note(note_id, ctx)["id"]) + return {"deleted": note_id} + + @api.get("/api/admin/notes", name="all_notes", roles=["admin"]) + def all_notes(self) -> list: + """Every user's notes, for an ``admin`` caller only.""" + return self.store[NOTES] + + def _user(self, ctx: ApiContext) -> str: + if ctx.user is None: + raise Unauthorized("sign in first") + return ctx.user + + def _note(self, note_id: int, ctx: ApiContext) -> dict: + """The signed-in user's note ``note_id``; another user's is as missing as one that doesn't exist.""" + note = self.store.get(NOTES, note_id) + if note is None or note["owner"] != self._user(ctx): + raise NotFound(f"no note {note_id}") + return note + + +def _title(title: str) -> str: + if not title.strip(): + raise BadRequest("a note needs a title", code="empty_title") + return title.strip() + + +if __name__ == "__main__": + Notes().serve() diff --git a/packages/agentenv-protocol/examples/website/notes/web/index.html b/packages/agentenv-protocol/examples/website/notes/web/index.html new file mode 100644 index 000000000..1e498d3c1 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/index.html @@ -0,0 +1,34 @@ + + + + + + Notes + + +
+

Notes

+
+ + +
+
+
+
+ + + +
+ + +
    +
    + + + diff --git a/packages/agentenv-protocol/examples/website/notes/web/package-lock.json b/packages/agentenv-protocol/examples/website/notes/web/package-lock.json new file mode 100644 index 000000000..938f9c2cd --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/package-lock.json @@ -0,0 +1,1020 @@ +{ + "name": "notes-site-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notes-site-web", + "version": "0.0.0", + "devDependencies": { + "vite": "5.4.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.2.tgz", + "integrity": "sha512-qB7Gzl1QI45zP1zXgqLKzjFmOBGQw2s2MLtdw0eB4xdFGPNE8LhmOnItFgyYJXXNSW8Go9c2ovvXrUUzw5sAMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.2.tgz", + "integrity": "sha512-8+hXNO1J+GsYCGHwyJuBlnhyozivEvIEm/P6NkJeuoVUN+60TDvYYXk38UTm5bKRTLhIpqEQYx4d9CeN6kjh0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.2.tgz", + "integrity": "sha512-/uEZY+tcWDE8DXa/rajU3Bwv6IfvQavvzjmbkn39GgQenT+UYtAxfEMdB7VKr74y8aonncm0eQa1EbiNfg+Cjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.2.tgz", + "integrity": "sha512-3tCLEgakEvb5M3/KyYdhPjoteyWYziMTeznoXfcs/31eeFu+Vgs0VNRlb6Zc+YC07/sLa84OwTDF5zHOMFk92Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.2.tgz", + "integrity": "sha512-z31ODzkdkX7OOJCssvpz++hhtkQKWKw2L0eO2HAxFP7RMSXXedzkrTjesqCUxs4XLckRShjNfxsNxjGY5DQCGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.2.tgz", + "integrity": "sha512-CTvrb0KTAQqcrpcj7rg8FgAI0+jRWnCl+En6exqXDZztCo7vspxlRUX8z9Hp/J/ja2XRnQ1JDbexkBtRyD9jSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.2.tgz", + "integrity": "sha512-SJdblZpBkbOh1e4JwMM5pJJ2PdVmD3WWbMTXvncMSRLA3DdZuwU+P93RaSChOAXN4Mu53tBoFp1ADPzi3dIVAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.2.tgz", + "integrity": "sha512-pUBEd7uI9dAu3TpbPcvi9eU2aJslsjIomormeTaQHmzWNhyqNx3mmq+ekC6blGNZu8F3uTHngTibKp7tbblF0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.2.tgz", + "integrity": "sha512-wW6ARw2Ah0/teOBTgTeSJIpVdPKaxQmEgZ/irkUfr3F9sgimqpApCv/NbQzjm0Nfi5EFilHh1H8kz01uQLaq5Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.2.tgz", + "integrity": "sha512-xcFYMHNL42fRu2MFipvxznr+zd+DRqwL98bhNvDWbMPRcFtMYf34NAz4xVOlnEhzgpNRE5htSemcqAsAxf2eQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.2.tgz", + "integrity": "sha512-U9HrnJ9PFXqA//P/oEGaXBIJraEqzYMS9yuQOdOcPCvHZIC1FFOlnd8ocAaeDDPDpY8xOrhfxMRtr6KqKi3gyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.2.tgz", + "integrity": "sha512-2hZN8CFGfsbDw+DNtImkPRitpLNjVM2XEbi4ZQ5igLxMFD4gI/P6Zo9czGxuvQXtRyZtZyZp8Kr4ERsAipplBw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.2.tgz", + "integrity": "sha512-HZO7UfWEsReAnkdVByyQaFsNXJCtvdfun/LFb2Xd4qnhgF1ZfhikpbgqrTRtNMmB5OgNnSwFLiPobOT8oNT9iw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.2.tgz", + "integrity": "sha512-AdYIihuLkJ2TScy9BpSPiDex3zOMjkNJLsFlk9NeJ0U3WEtOx4W8z0nOkPEhDS2S4gwsRWBa6ZZexJ9e6OxyzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.2.tgz", + "integrity": "sha512-iZE0NXcAGqLoCWeHRPkBxJe93JT4Q981z1ZqR+y7qAiIksdRlWhw8hmqHFo1B4ZnU66He31NUb52Iyv+vwMEBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.2.tgz", + "integrity": "sha512-vC7EAtJpSqSrrbg8huMfgxtwlR8fUsFCe5rStDk1WA8POky/6EEp52dxENMmqLKjSfdM3TT1WWPcZ01hiYZcdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.2.tgz", + "integrity": "sha512-DdqG6x72MHpQOlkK/cqrru9b2QMJHLkNojCB3bteB5fmkE/7XEAkReJMANp8wgef8eiEC99SvdP/BH7Ho/BpLA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.2.tgz", + "integrity": "sha512-F10mjdnRmGg9K+LGdqQVeUYFr08f3fVBOh7YNE5p1KIpTzhtKKOLqcQEsqZG6yEM2eouXaMo2AZzeDAcseVTrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.2.tgz", + "integrity": "sha512-ChzK9f3iE4N5GInBquiZtzWOqh+CYS5nlLlXGJxeG3d2T9rk8L1Hxg0s+v2Eh6vmAc/1T6IhOVkQU0ORP2eV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.2.tgz", + "integrity": "sha512-95WwzC+KH9hPHtbgwvwwW6MkKME6vMAdAQLS2diCvoi/iUiYdC0Y1RIPYVESx0iWqkN5ZaZq6qD1BflLcyTMEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.2.tgz", + "integrity": "sha512-eKbpwlolQmDKd/dbFjvqB1+2qdYQLrgxi3Mtbijp7/3yBBvO9OoHtQ3ecYIaCdW46ijSjy5/yydyrrxHI416/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.2.tgz", + "integrity": "sha512-is9lnlv6khapl+H9Hjp0gIVsUVtDxQga2nwMHpQbhDXMCMGcNmlel4/q9ihf/3FvvbL4GdGOPOGvWoei2RCggg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.2.tgz", + "integrity": "sha512-yTq+7YjsgbBcMdlk7Wq+VoJilGQtdtRc4PyTvN7vhO958atCO2nQeIjCF/YANSD/Z/fnrCr27fYsGgC6PFTndQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.2.tgz", + "integrity": "sha512-nAvZib4Xz1zx8OKhtRuyCsSmCmbfegv1AGbfpG+CowQKxCBW1pLlfYWjcr1+GI3BzRKQbBl7l9640GwWAwOF7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.2.tgz", + "integrity": "sha512-R3CVbEbNa1OXjpldD1tOLarb5rB08xjQiRMpe7PZKtT8eHCZhrV4ClSPfLLbSgRMM1RfLtmAfouBRf14f47lMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.2.tgz", + "integrity": "sha512-DeuVFilvaGtoS9TFIbTIQ0Kt4CBqP3xnjJ8SFqWWbXlFbrpmCdivEW7Rm38trRA8rZzQQ8lhs73nEetWotJpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.67.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.2.tgz", + "integrity": "sha512-sFZk5BeRUQzkjmuClrJI41BowIP4yv/40d5gM9vtjxVLEt+8JG9FGlBps6cliTP2KhFiw22oqNGo0G9pVpPZlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.2", + "@rollup/rollup-android-arm64": "4.67.2", + "@rollup/rollup-android-x64": "4.67.2", + "@rollup/rollup-darwin-arm64": "4.67.2", + "@rollup/rollup-darwin-x64": "4.67.2", + "@rollup/rollup-freebsd-arm64": "4.67.2", + "@rollup/rollup-freebsd-x64": "4.67.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.2", + "@rollup/rollup-linux-arm-musleabihf": "4.67.2", + "@rollup/rollup-linux-arm64-gnu": "4.67.2", + "@rollup/rollup-linux-arm64-musl": "4.67.2", + "@rollup/rollup-linux-loong64-gnu": "4.67.2", + "@rollup/rollup-linux-loong64-musl": "4.67.2", + "@rollup/rollup-linux-ppc64-gnu": "4.67.2", + "@rollup/rollup-linux-ppc64-musl": "4.67.2", + "@rollup/rollup-linux-riscv64-gnu": "4.67.2", + "@rollup/rollup-linux-riscv64-musl": "4.67.2", + "@rollup/rollup-linux-s390x-gnu": "4.67.2", + "@rollup/rollup-linux-x64-gnu": "4.67.2", + "@rollup/rollup-linux-x64-musl": "4.67.2", + "@rollup/rollup-openbsd-x64": "4.67.2", + "@rollup/rollup-openharmony-arm64": "4.67.2", + "@rollup/rollup-win32-arm64-msvc": "4.67.2", + "@rollup/rollup-win32-ia32-msvc": "4.67.2", + "@rollup/rollup-win32-x64-gnu": "4.67.2", + "@rollup/rollup-win32-x64-msvc": "4.67.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/packages/agentenv-protocol/examples/website/notes/web/package.json b/packages/agentenv-protocol/examples/website/notes/web/package.json new file mode 100644 index 000000000..c741095dd --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/package.json @@ -0,0 +1,14 @@ +{ + "name": "notes-site-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "5.4.10" + } +} diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js new file mode 100644 index 000000000..f4318fabf --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -0,0 +1,210 @@ +import "./style.css"; + +let offsetMs = 0; +let filter = "all"; +let renders = 0; +const saving = new Map(); +const lastSent = new Map(); +const landed = new Map(); +const CLOCK_REFRESH_MS = 5000; + +export function universeNow() { + return new Date(Date.now() + offsetMs); +} + +function followUniverse(response) { + const at = Date.parse(response.headers.get("X-Universe-Now") || ""); + if (!Number.isNaN(at)) offsetMs = at - Date.now(); +} + +async function api(path, { method = "GET", body } = {}) { + const response = await fetch(path, { + method, + headers: body === undefined ? {} : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + followUniverse(response); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || `${method} ${path}: ${response.status}`); + return data; +} + +const $ = (id) => document.getElementById(id); + +function queueSave(id, fields) { + lastSent.set(id, fields); + const queued = saving.get(id); + if (queued) { + queued.next = fields; + return; + } + const state = { next: fields }; + saving.set(id, state); + run(async () => { + try { + while (state.next) { + const next = state.next; + state.next = null; + await api(`api/notes/${id}`, { method: "PATCH", body: next }); + } + landed.set(id, renders); + } catch (error) { + lastSent.set(id, {}); + landed.delete(id); + throw error; + } finally { + saving.delete(id); + } + }); +} + +function showError(error) { + $("error").textContent = error ? error.message : ""; + $("error").hidden = !error; +} + +function formatTime(value) { + return new Date(value).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }); +} + +function tick() { + $("universe-now").textContent = `${formatTime(universeNow())} UTC`; + $("universe-now").dateTime = universeNow().toISOString(); +} + +function noteItem(note) { + const item = document.createElement("li"); + item.className = `note card${note.done ? " done" : ""}`; + item.dataset.id = note.id; + + const done = document.createElement("input"); + done.type = "checkbox"; + done.checked = note.done; + done.setAttribute("aria-label", `Mark "${note.title}" ${note.done ? "not done" : "done"}`); + done.addEventListener("change", () => run(() => api(`api/notes/${note.id}/toggle`, { method: "POST" }))); + + const title = document.createElement("input"); + title.className = "title"; + title.defaultValue = note.title; + title.setAttribute("aria-label", "Title"); + + const body = document.createElement("textarea"); + body.className = "body"; + body.rows = 2; + body.defaultValue = note.body; + body.placeholder = "Details"; + body.setAttribute("aria-label", "Details"); + + const save = () => { + const sent = lastSent.get(note.id) ?? { title: note.title, body: note.body }; + if (title.value === sent.title && body.value === sent.body) return; + queueSave(note.id, { title: title.value, body: body.value }); + }; + for (const field of [title, body]) { + field.addEventListener("change", save); + field.addEventListener("blur", save); + } + + const meta = document.createElement("small"); + meta.textContent = `Created ${formatTime(note.created_at)} ยท updated ${formatTime(note.updated_at)}`; + + const remove = document.createElement("button"); + remove.className = "delete"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => run(() => api(`api/notes/${note.id}`, { method: "DELETE" }))); + + const text = document.createElement("div"); + text.className = "text"; + text.append(title, body, meta); + item.append(done, text, remove); + return item; +} + +function editing() { + const kept = []; + for (const field of document.querySelectorAll("#notes .title, #notes .body")) { + const focused = field === document.activeElement; + if (focused || field.value !== field.defaultValue) { + kept.push({ id: field.closest("li").dataset.id, kind: field.className, value: field.value, focused, + start: field.selectionStart, end: field.selectionEnd }); + } + } + return kept; +} + +function restore(kept) { + for (const { id, kind, value, focused, start, end } of kept) { + const field = document.querySelector(`#notes li[data-id="${id}"] .${kind}`); + if (!field) continue; + field.value = value; + if (focused) { + field.focus(); + field.setSelectionRange(start, end); + } + } +} + +async function render() { + const query = filter === "all" ? "" : `?done=${filter === "done"}`; + const request = ++renders; + const notes = await api(`api/notes${query}`); + if (request !== renders) return; + for (const [id, after] of landed) { + if (request > after && !saving.has(id)) { + lastSent.delete(id); + landed.delete(id); + } + } + const kept = editing(); + $("notes").replaceChildren(...notes.map(noteItem)); + restore(kept); + if (!notes.length) { + const empty = document.createElement("li"); + empty.className = "empty"; + empty.textContent = "No notes here yet."; + $("notes").append(empty); + } +} + +async function run(action) { + try { + await action(); + showError(null); + await render(); + } catch (error) { + showError(error); + } +} + +async function start() { + const [session, users] = await Promise.all([api("api/session"), api("api/users")]); + $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); + $("user").value = session.user ?? ""; + $("user").addEventListener("change", (event) => + run(() => api("api/sign-in", { method: "POST", body: { user: event.target.value } }))); + + $("new-note").addEventListener("submit", (event) => { + event.preventDefault(); + const body = { title: $("new-title").value, body: $("new-body").value }; + run(async () => { + await api("api/notes", { method: "POST", body }); + if ($("new-title").value === body.title) $("new-title").value = ""; + if ($("new-body").value === body.body) $("new-body").value = ""; + }); + }); + + for (const button of document.querySelectorAll("[data-filter]")) { + button.addEventListener("click", () => { + filter = button.dataset.filter; + document.querySelectorAll("[data-filter]").forEach((b) => b.classList.toggle("active", b === button)); + run(async () => {}); + }); + } + + tick(); + setInterval(tick, 1000); + setInterval(() => fetch("healthz").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS); + await render(); +} + +start().catch(showError); diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/style.css b/packages/agentenv-protocol/examples/website/notes/web/src/style.css new file mode 100644 index 000000000..8ecd48b6a --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/src/style.css @@ -0,0 +1,57 @@ +:root { + --bg: #f6f6f3; + --card: #ffffff; + --text: #1d1d1b; + --muted: #6b6b66; + --line: #deded8; + --accent: #2f5bd3; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } +body { margin: 0; } + +.bar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + background: var(--card); +} +.bar h1 { margin: 0; font-size: 1.25rem; } +.who { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; color: var(--muted); font-size: 0.9rem; } +.who time { font-variant-numeric: tabular-nums; } + +main { max-width: 720px; margin: 0 auto; padding: 16px; } + +.card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px; } + +input, textarea, select, button { font: inherit; color: inherit; } +input, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: transparent; } +textarea { resize: vertical; } +button { border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; background: var(--card); cursor: pointer; } +button[type="submit"], .filters .active { background: var(--accent); border-color: var(--accent); color: #fff; } + +#new-note { display: grid; gap: 8px; } +#new-note button { justify-self: end; } + +.filters { display: flex; gap: 8px; margin: 16px 0; } + +#error { color: #b3261e; margin: 0 0 12px; } + +#notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.note { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: start; } +.note .text { display: grid; gap: 6px; min-width: 0; } +.note .title { font-weight: 600; border-color: transparent; } +.note .body { border-color: transparent; color: var(--muted); } +.note .title:focus, .note .body:focus { border-color: var(--line); } +.note small { color: var(--muted); } +.note.done .title { text-decoration: line-through; color: var(--muted); } +.note input[type="checkbox"] { width: auto; margin-top: 10px; } +.delete { color: #b3261e; } +.empty { color: var(--muted); text-align: center; padding: 24px; } diff --git a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js new file mode 100644 index 000000000..d07ecd2c2 --- /dev/null +++ b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "./", + server: { + proxy: { + "/api": "http://127.0.0.1:8000", + "/healthz": "http://127.0.0.1:8000", + }, + }, +}); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py new file mode 100644 index 000000000..e02147b10 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -0,0 +1,237 @@ +"""The Notes example site (examples/website/notes) as website/v1's reference site: it passes the conformance checker, +and its own behavior holds: the acting user's auto sign-in and signing in as another user, notes kept per user, the +admin-only route, timestamps from the universe's clock, and the store's seed loaded, exported and reset through the +data plane.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urljoin, urlsplit + +import pytest +from starlette.responses import PlainTextResponse +from starlette.testclient import TestClient + +from agentenv_protocol.website import (ROLE_KEY_ENV, ROLE_KEY_HEADER, SESSION_COOKIE, UNIVERSE_NOW_HEADER, WebsiteEnvironment, + conformance, website) + +NOTES_DIR = Path(__file__).resolve().parents[1] / "examples" / "website" / "notes" +SEED = json.loads((NOTES_DIR / "data" / "seed.json").read_text(encoding="utf-8")) +RPC = "/agentenv" + + +def _load_site(): + """The example's ``site.py``, imported under a name of its own: ``site`` is the standard library's.""" + spec = importlib.util.spec_from_file_location("notes_example_site", NOTES_DIR / "site.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +notes_site = _load_site() + + +def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: + body = client.post(RPC, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}).json() + assert "error" not in body, body + return body["result"] + + +@pytest.fixture +def notes_class(tmp_path: Path) -> type[WebsiteEnvironment]: + """The example's class, serving a stand-in page instead of ``web/dist``, which only a Vite build makes.""" + (tmp_path / "index.html").write_text("Notes") + declared = notes_site.Notes.declaration()._replace(static=str(tmp_path), data=str(NOTES_DIR / "data" / "seed.json")) + return website(**declared._asdict())(type("Notes", (notes_site.Notes,), {})) + + +@pytest.fixture +def env(notes_class: type[WebsiteEnvironment]) -> WebsiteEnvironment: + return notes_class() + + +@pytest.fixture +def app(env: WebsiteEnvironment): + return env.create_app() + + +@pytest.fixture +def client(app) -> TestClient: + return TestClient(app) + + +class _Clock: + """A gateway-shaped /clock/time whose time a test sets.""" + + def __init__(self, virtual: str) -> None: + self.virtual = virtual + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = json.dumps({"virtual_time": outer.virtual}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: + return None + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.server.server_port}/clock/time" + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + + +@pytest.fixture +def gateway_clock(): + clock = _Clock("2040-02-03T04:05:06Z") + yield clock + clock.close() + + +def test_the_example_passes_every_conformance_check(client: TestClient) -> None: + results = conformance.check("http://testserver", client=client) + assert {r.name: r.status for r in results} == dict.fromkeys( + ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + + +def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(client: TestClient) -> None: + card = client.get("/.well-known/agent-env.json").json() + site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1")["params"] + assert card["name"] == "notes" + assert (site["domains"], site["health"]) == (["notes.test"], "/healthz") + assert [r["name"] for r in site["routes"]] == ["list_users", "sign_in", "list_notes", "create_note", "get_note", + "edit_note", "toggle_note", "delete_note", "all_notes"] + assert next(r for r in site["routes"] if r["name"] == "all_notes")["roles"] == ["admin"] + assert client.get("/healthz").json() == {"status": "ok"} + + +def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: + session = client.get("/api/session") + assert session.json() == {"user": "ann", "role": "default", "signed_in": True} + assert SESSION_COOKIE in session.cookies + assert [n["title"] for n in client.get("/api/notes").json()] == ["Buy milk", "Book dentist"] + assert client.post("/api/sign-in", json={"user": "ben"}).json() == {"user": "ben"} + assert client.get("/api/session").json()["user"] == "ben" + assert [n["title"] for n in client.get("/api/notes").json()] == ["Draft the quarterly report"] + refused = client.post("/api/sign-in", json={"user": "mallory"}) + assert refused.status_code == 404 and refused.json()["code"] == "not_found" + + +def test_notes_are_created_edited_toggled_and_deleted_by_their_owner_only(app, client: TestClient) -> None: + created = client.post("/api/notes", json={"title": " Call Sam ", "body": "about the trip"}) + assert created.status_code == 201 + note = created.json() + assert (note["owner"], note["title"], note["done"]) == ("ann", "Call Sam", False) + assert note["created_at"] == note["updated_at"] + + ben = TestClient(app) + ben.post("/api/sign-in", json={"user": "ben"}) + assert "Call Sam" not in [n["title"] for n in ben.get("/api/notes").json()] + for response in (ben.get(f"/api/notes/{note['id']}"), ben.patch(f"/api/notes/{note['id']}", json={"done": True}), + ben.post(f"/api/notes/{note['id']}/toggle"), ben.delete(f"/api/notes/{note['id']}")): + assert response.status_code == 404, response.text + + edited = client.patch(f"/api/notes/{note['id']}", json={"body": "about the trip, before Friday"}).json() + assert (edited["title"], edited["body"]) == ("Call Sam", "about the trip, before Friday") + assert client.post(f"/api/notes/{note['id']}/toggle").json()["done"] is True + assert [n["title"] for n in client.get("/api/notes", params={"done": "true"}).json()] == ["Call Sam", "Book dentist"] + assert client.patch(f"/api/notes/{note['id']}", json={"title": " "}).json()["code"] == "empty_title" + assert client.post("/api/notes", json={"body": "no title"}).status_code == 400 + assert client.delete(f"/api/notes/{note['id']}").json() == {"deleted": note["id"]} + assert client.get(f"/api/notes/{note['id']}").status_code == 404 + + +def test_every_users_notes_are_listed_for_an_admin_caller_only(client: TestClient, + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(ROLE_KEY_ENV, "k3y") + for headers in ({}, {"AgentEnv-Role": "agent", ROLE_KEY_HEADER: "k3y"}, {"AgentEnv-Role": "admin"}, + {"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "guess"}): + refused = client.get("/api/admin/notes", headers=headers) + assert refused.status_code == 403, headers + every = client.get("/api/admin/notes", headers={"AgentEnv-Role": "admin", ROLE_KEY_HEADER: "k3y"}).json() + assert {n["owner"] for n in every} == {"ann", "ben"} + + +def test_timestamps_follow_the_universes_clock_once_synced(client: TestClient, gateway_clock: _Clock) -> None: + before = client.post("/api/notes", json={"title": "before the sync"}).json() + assert not before["created_at"].startswith("2040") + client.post("/agentenv/ext/sync_time", json={"env_get_time_url": gateway_clock.url}) + after = client.post("/api/notes", json={"title": "after the sync"}) + assert after.json()["created_at"].startswith("2040-02-03T04:05") + assert after.headers[UNIVERSE_NOW_HEADER].startswith("2040-02-03T04:05") + toggled = client.post(f"/api/notes/{before['id']}/toggle").json() + assert toggled["created_at"] == before["created_at"] and toggled["updated_at"].startswith("2040-02-03T04:05") + + +def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestClient) -> None: + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + rpc(client, "data/reset") + assert rpc(client, "data/get")["parts"][0]["data"] == {} + assert client.get("/api/notes").status_code == 401 + assert client.get("/healthz").status_code == 503 + + seed_file = {"kind": "file", "file": {"uri": (NOTES_DIR / "data" / "seed.json").as_uri(), + "mimeType": "application/json"}} + rpc(client, "data/add", {"parts": [seed_file]}) + assert client.get("/healthz").status_code == 200 + assert rpc(client, "data/get")["parts"][0]["data"] == SEED + assert client.get("/api/session").json()["user"] == "ann" + + client.post("/api/notes/1/toggle") + state = client.get("/go").json() + assert state["initial_state"] == SEED + assert state["state_diff"]["notes"]["changed"][0]["after"]["done"] is True + + +GATEWAY_PREFIX = "/website/notes" + + +def _behind_gateway_prefix(app): + """The gateway's ``/website//`` route: a request under the prefix reaches the site with the prefix taken off, + and any other one answers the gateway's own 404.""" + + async def gateway(scope, receive, send): + if scope["type"] == "http" and scope["path"].startswith(GATEWAY_PREFIX + "/"): + path = scope["path"][len(GATEWAY_PREFIX):] + return await app({**scope, "path": path, "raw_path": path.encode()}, receive, send) + if scope["type"] != "http": + return await app(scope, receive, send) + await PlainTextResponse("no gateway route", status_code=404)(scope, receive, send) + + return gateway + + +@pytest.mark.skipif(not (NOTES_DIR / "web" / "dist" / "index.html").is_file(), reason="web/dist needs a Vite build") +def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() -> None: + page_url = f"http://gateway.test{GATEWAY_PREFIX}/" + browser = TestClient(_behind_gateway_prefix(notes_site.Notes().create_app()), base_url="http://gateway.test") + page = browser.get(page_url) + assert page.status_code == 200 and page.headers["content-type"].startswith("text/html") + + assets = [urljoin(page_url, ref) for ref in re.findall(r'(?:src|href)="([^"]+)"', page.text)] + assert assets and all(urlsplit(url).path.startswith(GATEWAY_PREFIX + "/") for url in assets), assets + scripts = [] + for url in assets: + fetched = browser.get(url) + assert fetched.status_code == 200, url + if url.endswith(".js"): + scripts.append(fetched.text) + + called = {path for script in scripts for path in re.findall(r'["`](/?api/[\w-]+)', script)} + assert {"api/session", "api/users", "api/notes", "api/sign-in"} <= called + assert not [path for path in called if path.startswith("/")], called + assert browser.get(urljoin(page_url, "api/session")).json()["user"] == "ann" + assert [u["id"] for u in browser.get(urljoin(page_url, "api/users")).json()] == ["ann", "ben"] From 7278968206601d6669de6a777dc715040ae53ccf Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 14:22:37 -0800 Subject: [PATCH 02/13] fix(website): the Notes page saves queued edits before switching users, and adds a note once per submit (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/web/src/main.js | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index f4318fabf..d061a6cea 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -40,7 +40,7 @@ function queueSave(id, fields) { } const state = { next: fields }; saving.set(id, state); - run(async () => { + state.done = run(async () => { try { while (state.next) { const next = state.next; @@ -180,14 +180,31 @@ async function start() { const [session, users] = await Promise.all([api("api/session"), api("api/users")]); $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); $("user").value = session.user ?? ""; - $("user").addEventListener("change", (event) => - run(() => api("api/sign-in", { method: "POST", body: { user: event.target.value } }))); + $("user").addEventListener("change", (event) => { + const user = event.target.value; + $("user").disabled = true; + run(async () => { + try { + while (saving.size) await Promise.all([...saving.values()].map((state) => state.done)); + await api("api/sign-in", { method: "POST", body: { user } }); + } finally { + $("user").disabled = false; + } + }); + }); + const add = $("new-note").querySelector("button[type=submit]"); $("new-note").addEventListener("submit", (event) => { event.preventDefault(); + if (add.disabled) return; const body = { title: $("new-title").value, body: $("new-body").value }; + add.disabled = true; run(async () => { - await api("api/notes", { method: "POST", body }); + try { + await api("api/notes", { method: "POST", body }); + } finally { + add.disabled = false; + } if ($("new-title").value === body.title) $("new-title").value = ""; if ($("new-body").value === body.body) $("new-body").value = ""; }); From 13b3046db1662e3943d94800e966e6b3b76eb8be Mon Sep 17 00:00:00 2001 From: William Loo Date: Tue, 3 Nov 2026 15:01:02 -0800 Subject: [PATCH 03/13] fix(website): the Notes page keeps the signed-in user when a save or sign-in fails, and reloads itself at its slash-ending address (FD-4683) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/README.md | 2 +- .../examples/website/notes/web/index.html | 3 +++ .../examples/website/notes/web/src/main.js | 13 +++++++++++-- .../tests/test_website_notes_example.py | 1 + 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 3311112b0..67bac1ae3 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -8,7 +8,7 @@ them. The page shows the universe's time. | --- | --- | | `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | | `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | -| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike. | +| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. | | `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. | | `env.toml` | The website env as a bundle entry. | diff --git a/packages/agentenv-protocol/examples/website/notes/web/index.html b/packages/agentenv-protocol/examples/website/notes/web/index.html index 1e498d3c1..468ef6385 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/index.html +++ b/packages/agentenv-protocol/examples/website/notes/web/index.html @@ -4,6 +4,9 @@ Notes +
    diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index d061a6cea..fafffe38f 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -49,6 +49,7 @@ function queueSave(id, fields) { } landed.set(id, renders); } catch (error) { + state.error = error; lastSent.set(id, {}); landed.delete(id); throw error; @@ -179,15 +180,23 @@ async function run(action) { async function start() { const [session, users] = await Promise.all([api("api/session"), api("api/users")]); $("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id))); - $("user").value = session.user ?? ""; + let signedInAs = session.user ?? ""; + $("user").value = signedInAs; $("user").addEventListener("change", (event) => { const user = event.target.value; $("user").disabled = true; run(async () => { try { - while (saving.size) await Promise.all([...saving.values()].map((state) => state.done)); + while (saving.size) { + const pending = [...saving.values()]; + await Promise.all(pending.map((state) => state.done)); + const failed = pending.find((state) => state.error); + if (failed) throw failed.error; + } await api("api/sign-in", { method: "POST", body: { user } }); + signedInAs = user; } finally { + $("user").value = signedInAs; $("user").disabled = false; } }); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index e02147b10..c7ae6212d 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -220,6 +220,7 @@ def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() -> browser = TestClient(_behind_gateway_prefix(notes_site.Notes().create_app()), base_url="http://gateway.test") page = browser.get(page_url) assert page.status_code == 200 and page.headers["content-type"].startswith("text/html") + assert page.text.index('location.pathname.endsWith("/")') < page.text.index(" diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index fafffe38f..61ead9d31 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -6,6 +6,7 @@ let renders = 0; const saving = new Map(); const lastSent = new Map(); const landed = new Map(); +const drafts = new Map(); const CLOCK_REFRESH_MS = 5000; export function universeNow() { @@ -31,6 +32,21 @@ async function api(path, { method = "GET", body } = {}) { const $ = (id) => document.getElementById(id); +function setDraft(id, kind, value, saved) { + const draft = { ...drafts.get(id) }; + if (value === saved) delete draft[kind]; + else draft[kind] = value; + if (Object.keys(draft).length) drafts.set(id, draft); + else drafts.delete(id); +} + +function settleDraft(id, sent) { + const draft = drafts.get(id); + if (!draft) return; + for (const kind of Object.keys(sent)) if (draft[kind] === sent[kind]) delete draft[kind]; + if (!Object.keys(draft).length) drafts.delete(id); +} + function queueSave(id, fields) { lastSent.set(id, fields); const queued = saving.get(id); @@ -46,6 +62,7 @@ function queueSave(id, fields) { const next = state.next; state.next = null; await api(`api/notes/${id}`, { method: "PATCH", body: next }); + settleDraft(id, next); } landed.set(id, renders); } catch (error) { @@ -84,15 +101,18 @@ function noteItem(note) { done.setAttribute("aria-label", `Mark "${note.title}" ${note.done ? "not done" : "done"}`); done.addEventListener("change", () => run(() => api(`api/notes/${note.id}/toggle`, { method: "POST" }))); + const draft = drafts.get(note.id) ?? {}; const title = document.createElement("input"); title.className = "title"; title.defaultValue = note.title; + title.value = draft.title ?? note.title; title.setAttribute("aria-label", "Title"); const body = document.createElement("textarea"); body.className = "body"; body.rows = 2; body.defaultValue = note.body; + body.value = draft.body ?? note.body; body.placeholder = "Details"; body.setAttribute("aria-label", "Details"); @@ -101,7 +121,8 @@ function noteItem(note) { if (title.value === sent.title && body.value === sent.body) return; queueSave(note.id, { title: title.value, body: body.value }); }; - for (const field of [title, body]) { + for (const [kind, field] of [["title", title], ["body", body]]) { + field.addEventListener("input", () => setDraft(note.id, kind, field.value, note[kind])); field.addEventListener("change", save); field.addEventListener("blur", save); } @@ -112,7 +133,10 @@ function noteItem(note) { const remove = document.createElement("button"); remove.className = "delete"; remove.textContent = "Delete"; - remove.addEventListener("click", () => run(() => api(`api/notes/${note.id}`, { method: "DELETE" }))); + remove.addEventListener("click", () => run(async () => { + await api(`api/notes/${note.id}`, { method: "DELETE" }); + drafts.delete(note.id); + })); const text = document.createElement("div"); text.className = "text"; @@ -121,28 +145,19 @@ function noteItem(note) { return item; } -function editing() { - const kept = []; - for (const field of document.querySelectorAll("#notes .title, #notes .body")) { - const focused = field === document.activeElement; - if (focused || field.value !== field.defaultValue) { - kept.push({ id: field.closest("li").dataset.id, kind: field.className, value: field.value, focused, - start: field.selectionStart, end: field.selectionEnd }); - } - } - return kept; +function focused() { + const field = document.activeElement; + if (!field?.matches?.("#notes .title, #notes .body")) return null; + return { id: field.closest("li").dataset.id, kind: field.className, start: field.selectionStart, + end: field.selectionEnd }; } -function restore(kept) { - for (const { id, kind, value, focused, start, end } of kept) { - const field = document.querySelector(`#notes li[data-id="${id}"] .${kind}`); - if (!field) continue; - field.value = value; - if (focused) { - field.focus(); - field.setSelectionRange(start, end); - } - } +function refocus(kept) { + if (!kept) return; + const field = document.querySelector(`#notes li[data-id="${kept.id}"] .${kept.kind}`); + if (!field) return; + field.focus(); + field.setSelectionRange(kept.start, kept.end); } async function render() { @@ -156,9 +171,9 @@ async function render() { landed.delete(id); } } - const kept = editing(); + const kept = focused(); $("notes").replaceChildren(...notes.map(noteItem)); - restore(kept); + refocus(kept); if (!notes.length) { const empty = document.createElement("li"); empty.className = "empty"; @@ -195,6 +210,7 @@ async function start() { } await api("api/sign-in", { method: "POST", body: { user } }); signedInAs = user; + drafts.clear(); } finally { $("user").value = signedInAs; $("user").disabled = false; From 701762477170a489a5187f0ee9e3d70d739ad360 Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 14:55:48 -0800 Subject: [PATCH 06/13] refactor(website): the Notes example keeps its own data and is ready through website/v1's ready alone (FD-4683) The SDK on main keeps no store and serves no health path, so Notes holds its users and notes itself, starting from data/seed.json, serves them through @reset_data, @add_data and @get_data, and names its acting user with acting_user(). Its readiness is website/v1's ready (@ready_check seeded); env.toml and the image's health check no longer name /healthz, which the compose health check now reads as the card. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/Dockerfile | 2 +- .../examples/website/notes/README.md | 22 ++--- .../examples/website/notes/env.toml | 1 - .../examples/website/notes/site.py | 86 +++++++++++++++---- .../examples/website/notes/web/src/main.js | 2 +- .../examples/website/notes/web/vite.config.js | 1 - .../tests/test_website_notes_example.py | 24 ++++-- 7 files changed, 99 insertions(+), 39 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/Dockerfile b/packages/agentenv-protocol/examples/website/notes/Dockerfile index 461e886b2..c042318da 100644 --- a/packages/agentenv-protocol/examples/website/notes/Dockerfile +++ b/packages/agentenv-protocol/examples/website/notes/Dockerfile @@ -22,5 +22,5 @@ EXPOSE 8000 RUN useradd --create-home --uid 10001 appuser USER appuser HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ - CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/healthz' % os.environ.get('PORT', '8000'), timeout=2)" + CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/.well-known/agent-env.json' % os.environ.get('PORT', '8000'), timeout=2)" CMD ["python", "site.py"] diff --git a/packages/agentenv-protocol/examples/website/notes/README.md b/packages/agentenv-protocol/examples/website/notes/README.md index 67d54de43..a89badd6e 100644 --- a/packages/agentenv-protocol/examples/website/notes/README.md +++ b/packages/agentenv-protocol/examples/website/notes/README.md @@ -9,13 +9,13 @@ them. The page shows the universe's time. | `site.py` | The server: the `Notes` WebsiteEnvironment, its `@api` routes, and `serve()` on `PORT` (8000). | | `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. | | `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. | -| `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. | +| `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs against the card. | | `env.toml` | The website env as a bundle entry. | ## What it exercises -- **Site declaration.** `@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json")`. - The card advertises website/v1 with the domains, the health path and every route by name. +- **Site declaration.** `@website(domains=["notes.test"], static="web/dist")`. The card advertises website/v1 with + the domains and every route by name. - **Routes.** `list_users`, `sign_in`, `list_notes` (with an optional `?done=`), `create_note` (201), `get_note`, `edit_note`, `toggle_note` and `delete_note`. Errors are raised as `NotFound`, `BadRequest` and `Unauthorized`. - **Roles.** `all_notes` (`GET /api/admin/notes`) is declared with `roles=["admin"]`. It answers only a caller that @@ -23,17 +23,17 @@ them. The page shows the universe's time. gets 403. The gateway passes on whatever role a browser sends, so without the key a page could claim `admin`; with no `WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed. - **Users.** `ApiContext.user` is the user in the signed session cookie. With no cookie, it is the acting user (the - store's `current_user`), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded + data's `current_user`, which `acting_user()` returns), and `GET /api/session` signs that user in. `POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another user's note answers 404. -- **State.** The site writes no data hooks, so it uses the default store. `data/add` loads the seed (as a data part, or - as a file part by bytes or `file://`), `data/get` exports it, and `data/reset` empties it. `data/get` is how - agentenv reads what a task left the site with. +- **State.** The site keeps its users and notes itself, starting from `data/seed.json`, and serves them through its + data hooks: `data/add` adds a part's records by id (a data part, or a file part by bytes or `file://`), `data/get` + exports them, and `data/reset` empties them. `data/get` is how agentenv reads what a task left the site with. - **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header. It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the SDK. -- **Readiness.** `@ready_check seeded` holds `/healthz` at 503 until the store has users, for example after a - `data/reset` and before the next `data/add`. +- **Readiness.** website/v1's `ready`, through the card, says not ready (`@ready_check seeded`) until the site has + users, for example after a `data/reset` and before the next `data/add`. The site serves no health path. ## Run it @@ -77,13 +77,13 @@ the container before using it. ```bash agent-env env website put --id notes-site \ --server-dockerfile packages/agentenv-protocol/examples/website/notes/Dockerfile \ - --server-docker-context . --health-path /healthz + --server-docker-context . ``` Run it from the repo root. `environment_name` comes from the card (`@environment_card(name="notes")`). The stored env has a server image and no frontend image. Pass `--skip-validation` to register without deploying. -`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name`, `health` and +`env.toml` describes the same env for a bundle. Its keys are `type`, `environment_name` and `backend_image`. Domains are not one of them: they come from the card. A bundle builds `backend_image` with this folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a published release instead, register the site with `put` as above. diff --git a/packages/agentenv-protocol/examples/website/notes/env.toml b/packages/agentenv-protocol/examples/website/notes/env.toml index a1d019251..fd7b5cc5c 100644 --- a/packages/agentenv-protocol/examples/website/notes/env.toml +++ b/packages/agentenv-protocol/examples/website/notes/env.toml @@ -1,4 +1,3 @@ type = "website" environment_name = "notes" -health = "/healthz" backend_image = { dockerfile = "Dockerfile" } diff --git a/packages/agentenv-protocol/examples/website/notes/site.py b/packages/agentenv-protocol/examples/website/notes/site.py index 5eaafda0e..3cc3b610e 100644 --- a/packages/agentenv-protocol/examples/website/notes/site.py +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -1,17 +1,23 @@ """Notes: the reference site of ``urn:agentenv:website/v1``, a small notes app on WebsiteEnvironment. Each user keeps notes they create, edit, mark done and delete; a browser with no session is signed in as the acting user, the seed's -``current_user``, and can sign in as another seeded user. The site keeps its state in the default store, which the data -plane loads, exports and resets, and stamps each note with the universe's time. ``admin`` callers, as the gateway's +``current_user``, and can sign in as another seeded user. The site keeps its users and notes itself, starting from +``data/seed.json``, and serves them through the data plane (``data/get``, ``data/reset``, ``data/add``), as any +AgentEnvEnvironment does; it stamps each note with the universe's time. ``admin`` callers, as the gateway's ``AgentEnv-Role`` names them, can list every user's notes. Run it with ``python site.py`` after building ``web/`` into ``web/dist``; it serves on ``PORT`` (8000).""" from __future__ import annotations +import base64 +import copy +import json +from pathlib import Path from typing import Optional +from urllib.parse import unquote, urlparse from pydantic import BaseModel -from agentenv_protocol import environment_card +from agentenv_protocol import DataPart, FilePart, TextPart, add_data, environment_card, get_data, reset_data from agentenv_protocol.website import ( ApiContext, BadRequest, @@ -26,6 +32,7 @@ NOTES = "notes" USERS = "users" +SEED = Path(__file__).resolve().parent / "data" / "seed.json" class NewNote(BaseModel): @@ -44,22 +51,49 @@ class SignIn(BaseModel): @environment_card(name="notes") -@website(domains=["notes.test"], static="web/dist", health="/healthz", data="data/seed.json") +@website(domains=["notes.test"], static="web/dist") class Notes(WebsiteEnvironment): + def __init__(self) -> None: + super().__init__() + self.data: dict = json.loads(SEED.read_text(encoding="utf-8")) + + def acting_user(self) -> Optional[str]: + return self.data.get("current_user") + @ready_check def seeded(self) -> bool: - """The site is usable once its store knows who may sign in.""" - return bool(self.store[USERS]) + """The site is usable once it knows who may sign in.""" + return bool(self.data.get(USERS)) + + @reset_data + def reset(self) -> None: + self.data = {} + + @add_data + def add(self, parts: list) -> None: + """Each part's JSON, its collections' records added by id and its other fields set, as the seed is.""" + for part in parts: + for key, value in _part_data(part).items(): + if isinstance(value, list): + kept = {record["id"]: record for record in self.data.get(key, [])} + kept.update({record["id"]: copy.deepcopy(record) for record in value}) + self.data[key] = list(kept.values()) + else: + self.data[key] = copy.deepcopy(value) + + @get_data + def export(self) -> list: + return [DataPart(data=copy.deepcopy(self.data))] @api.get("/api/users", name="list_users") def list_users(self) -> list: """The users a browser can sign in as.""" - return self.store[USERS] + return self.data.get(USERS, []) @api.post("/api/sign-in", name="sign_in") def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: """Sign the browser in as one of the site's users.""" - if self.store.get(USERS, body.user) is None: + if not any(user["id"] == body.user for user in self.data.get(USERS, [])): raise NotFound(f"no user {body.user!r}") ctx.sign_in(body.user) return {"user": ctx.user} @@ -67,15 +101,18 @@ def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: @api.get("/api/notes", name="list_notes") def list_notes(self, ctx: ApiContext, done: Optional[bool] = None) -> list: """The signed-in user's notes, newest first; with ``done``, only those done or not.""" - match = {"owner": self._user(ctx)} if done is None else {"owner": self._user(ctx), "done": done} - return sorted(self.store.find(NOTES, **match), key=lambda note: note["created_at"], reverse=True) + user = self._user(ctx) + mine = [n for n in self.data.get(NOTES, []) if n["owner"] == user and (done is None or n["done"] == done)] + return sorted(mine, key=lambda note: note["created_at"], reverse=True) @api.post("/api/notes", name="create_note", status=201) def create_note(self, note: NewNote, ctx: ApiContext) -> dict: """Add a note for the signed-in user, not done, created and updated now.""" - now = iso(self.now()) - return self.store.insert(NOTES, {"owner": self._user(ctx), "title": _title(note.title), "body": note.body, - "done": False, "created_at": now, "updated_at": now}) + now, notes = iso(self.now()), self.data.setdefault(NOTES, []) + created = {"id": max((n["id"] for n in notes), default=0) + 1, "owner": self._user(ctx), + "title": _title(note.title), "body": note.body, "done": False, "created_at": now, "updated_at": now} + notes.append(created) + return created @api.get("/api/notes/{note_id:int}", name="get_note") def get_note(self, note_id: int, ctx: ApiContext) -> dict: @@ -100,13 +137,14 @@ def toggle_note(self, note_id: int, ctx: ApiContext) -> dict: @api.delete("/api/notes/{note_id:int}", name="delete_note") def delete_note(self, note_id: int, ctx: ApiContext) -> dict: - self.store.delete(NOTES, self._note(note_id, ctx)["id"]) + note = self._note(note_id, ctx) + self.data[NOTES] = [n for n in self.data[NOTES] if n is not note] return {"deleted": note_id} @api.get("/api/admin/notes", name="all_notes", roles=["admin"]) def all_notes(self) -> list: """Every user's notes, for an ``admin`` caller only.""" - return self.store[NOTES] + return self.data.get(NOTES, []) def _user(self, ctx: ApiContext) -> str: if ctx.user is None: @@ -115,12 +153,28 @@ def _user(self, ctx: ApiContext) -> str: def _note(self, note_id: int, ctx: ApiContext) -> dict: """The signed-in user's note ``note_id``; another user's is as missing as one that doesn't exist.""" - note = self.store.get(NOTES, note_id) + note = next((n for n in self.data.get(NOTES, []) if n["id"] == note_id), None) if note is None or note["owner"] != self._user(ctx): raise NotFound(f"no note {note_id}") return note +def _part_data(part) -> dict: + """A part's JSON: a data part's data, a text part's text, or a file part's bytes or ``file://`` file.""" + if isinstance(part, DataPart): + return part.data + if isinstance(part, TextPart): + return json.loads(part.text) + if isinstance(part, FilePart): + if getattr(part.file, "bytes", None) is not None: + return json.loads(base64.b64decode(part.file.bytes)) + uri = urlparse(part.file.uri) + if uri.scheme == "file": + return json.loads(Path(unquote(uri.path)).read_text(encoding="utf-8")) + raise ValueError(f"Notes reads a file part by its bytes or a file:// URI, not {part.file.uri!r}") + raise ValueError(f"Notes can't read a {type(part).__name__}") + + def _title(title: str) -> str: if not title.strip(): raise BadRequest("a note needs a title", code="empty_title") diff --git a/packages/agentenv-protocol/examples/website/notes/web/src/main.js b/packages/agentenv-protocol/examples/website/notes/web/src/main.js index 61ead9d31..a30303385 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/src/main.js +++ b/packages/agentenv-protocol/examples/website/notes/web/src/main.js @@ -245,7 +245,7 @@ async function start() { tick(); setInterval(tick, 1000); - setInterval(() => fetch("healthz").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS); + setInterval(() => fetch("api/users").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS); await render(); } diff --git a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js index d07ecd2c2..2fecfa631 100644 --- a/packages/agentenv-protocol/examples/website/notes/web/vite.config.js +++ b/packages/agentenv-protocol/examples/website/notes/web/vite.config.js @@ -5,7 +5,6 @@ export default defineConfig({ server: { proxy: { "/api": "http://127.0.0.1:8000", - "/healthz": "http://127.0.0.1:8000", }, }, }); diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 4d7110657..f4045b1de 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -1,7 +1,7 @@ """The Notes example site (examples/website/notes) as website/v1's reference site: it passes the conformance checker, and its own behavior holds: the acting user's auto sign-in and signing in as another user, notes kept per user, the -admin-only route, timestamps from the universe's clock, and the store's seed loaded, exported and reset through the -data plane.""" +admin-only route, timestamps from the universe's clock, and its seed loaded, exported and reset through the data +plane, readiness following it.""" from __future__ import annotations @@ -48,7 +48,7 @@ def rpc(client: TestClient, method: str, params: dict | None = None) -> dict: def notes_class(tmp_path: Path) -> type[WebsiteEnvironment]: """The example's class, serving a stand-in page instead of ``web/dist``, which only a Vite build makes.""" (tmp_path / "index.html").write_text("Notes") - declared = notes_site.Notes.declaration()._replace(static=str(tmp_path), data=str(NOTES_DIR / "data" / "seed.json")) + declared = notes_site.Notes.declaration()._replace(static=str(tmp_path)) return website(**declared._asdict())(type("Notes", (notes_site.Notes,), {})) @@ -101,21 +101,29 @@ def gateway_clock(): clock.close() +def ready(client: TestClient) -> dict: + """website/v1's ready, where the card says it is.""" + card = client.get("/.well-known/agent-env.json").json() + site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1") + return client.get(site["params"]["methods"]["ready"]["endpoint"]).json() + + def test_the_example_passes_every_conformance_check(client: TestClient) -> None: results = conformance.check("http://testserver", client=client) assert {r.name: r.status for r in results} == dict.fromkeys( ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results -def test_the_card_declares_the_site_its_health_path_and_its_routes_by_name(client: TestClient) -> None: +def test_the_card_declares_the_site_and_its_routes_by_name_and_it_is_ready_through_website_v1( + client: TestClient) -> None: card = client.get("/.well-known/agent-env.json").json() site = next(e for e in card["capabilities"]["extensions"] if e["uri"] == "urn:agentenv:website/v1")["params"] assert card["name"] == "notes" - assert (site["domains"], site["health"]) == (["notes.test"], "/healthz") + assert site["domains"] == ["notes.test"] and "health" not in site assert [r["name"] for r in site["routes"]] == ["list_users", "sign_in", "list_notes", "create_note", "get_note", "edit_note", "toggle_note", "delete_note", "all_notes"] assert next(r for r in site["routes"] if r["name"] == "all_notes")["roles"] == ["admin"] - assert client.get("/healthz").json() == {"status": "ok"} + assert ready(client)["ready"] is True def test_a_browser_is_signed_in_as_the_acting_user_and_can_sign_in_as_another(client: TestClient) -> None: @@ -181,12 +189,12 @@ def test_the_seed_loads_exports_and_resets_through_the_data_plane(client: TestCl rpc(client, "data/reset") assert rpc(client, "data/get")["parts"][0]["data"] == {} assert client.get("/api/notes").status_code == 401 - assert client.get("/healthz").status_code == 503 + assert ready(client)["ready"] is False seed_file = {"kind": "file", "file": {"uri": (NOTES_DIR / "data" / "seed.json").as_uri(), "mimeType": "application/json"}} rpc(client, "data/add", {"parts": [seed_file]}) - assert client.get("/healthz").status_code == 200 + assert ready(client)["ready"] is True assert rpc(client, "data/get")["parts"][0]["data"] == SEED assert client.get("/api/session").json()["user"] == "ann" From 48ac23c275ff411a225e52273429fcca13c11acb Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 15:06:17 -0800 Subject: [PATCH 07/13] fix(website): the Notes example changes its data under one lock, so concurrent writes keep every note (FD-4683) Plain def handlers run in worker threads, so two creates could choose one id, and a create could append to a list a delete was replacing. Every read and write of the site's data is under one lock, ids chosen and notes inserted together, and the handlers answer copies. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/website/notes/site.py | 89 ++++++++++++------- .../tests/test_website_notes_example.py | 33 +++++++ 2 files changed, 89 insertions(+), 33 deletions(-) diff --git a/packages/agentenv-protocol/examples/website/notes/site.py b/packages/agentenv-protocol/examples/website/notes/site.py index 3cc3b610e..6b98504f6 100644 --- a/packages/agentenv-protocol/examples/website/notes/site.py +++ b/packages/agentenv-protocol/examples/website/notes/site.py @@ -1,7 +1,7 @@ """Notes: the reference site of ``urn:agentenv:website/v1``, a small notes app on WebsiteEnvironment. Each user keeps notes they create, edit, mark done and delete; a browser with no session is signed in as the acting user, the seed's -``current_user``, and can sign in as another seeded user. The site keeps its users and notes itself, starting from -``data/seed.json``, and serves them through the data plane (``data/get``, ``data/reset``, ``data/add``), as any +``current_user``, and can sign in as another seeded user. The site keeps its users and notes itself, behind one lock +since plain ``def`` handlers run in worker threads, starting from ``data/seed.json``, and serves them through the data plane (``data/get``, ``data/reset``, ``data/add``), as any AgentEnvEnvironment does; it stamps each note with the universe's time. ``admin`` callers, as the gateway's ``AgentEnv-Role`` names them, can list every user's notes. @@ -11,6 +11,7 @@ import base64 import copy import json +import threading from pathlib import Path from typing import Optional from urllib.parse import unquote, urlparse @@ -55,45 +56,55 @@ class SignIn(BaseModel): class Notes(WebsiteEnvironment): def __init__(self) -> None: super().__init__() + self._lock = threading.Lock() self.data: dict = json.loads(SEED.read_text(encoding="utf-8")) def acting_user(self) -> Optional[str]: - return self.data.get("current_user") + with self._lock: + return self.data.get("current_user") @ready_check def seeded(self) -> bool: """The site is usable once it knows who may sign in.""" - return bool(self.data.get(USERS)) + with self._lock: + return bool(self.data.get(USERS)) @reset_data def reset(self) -> None: - self.data = {} + with self._lock: + self.data = {} @add_data def add(self, parts: list) -> None: """Each part's JSON, its collections' records added by id and its other fields set, as the seed is.""" - for part in parts: - for key, value in _part_data(part).items(): - if isinstance(value, list): - kept = {record["id"]: record for record in self.data.get(key, [])} - kept.update({record["id"]: copy.deepcopy(record) for record in value}) - self.data[key] = list(kept.values()) - else: - self.data[key] = copy.deepcopy(value) + loaded = [_part_data(part) for part in parts] + with self._lock: + for data in loaded: + for key, value in data.items(): + if isinstance(value, list): + kept = {record["id"]: record for record in self.data.get(key, [])} + kept.update({record["id"]: copy.deepcopy(record) for record in value}) + self.data[key] = list(kept.values()) + else: + self.data[key] = copy.deepcopy(value) @get_data def export(self) -> list: - return [DataPart(data=copy.deepcopy(self.data))] + with self._lock: + return [DataPart(data=copy.deepcopy(self.data))] @api.get("/api/users", name="list_users") def list_users(self) -> list: """The users a browser can sign in as.""" - return self.data.get(USERS, []) + with self._lock: + return copy.deepcopy(self.data.get(USERS, [])) @api.post("/api/sign-in", name="sign_in") def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: """Sign the browser in as one of the site's users.""" - if not any(user["id"] == body.user for user in self.data.get(USERS, [])): + with self._lock: + known = any(user["id"] == body.user for user in self.data.get(USERS, [])) + if not known: raise NotFound(f"no user {body.user!r}") ctx.sign_in(body.user) return {"user": ctx.user} @@ -102,49 +113,60 @@ def sign_in(self, body: SignIn, ctx: ApiContext) -> dict: def list_notes(self, ctx: ApiContext, done: Optional[bool] = None) -> list: """The signed-in user's notes, newest first; with ``done``, only those done or not.""" user = self._user(ctx) - mine = [n for n in self.data.get(NOTES, []) if n["owner"] == user and (done is None or n["done"] == done)] + with self._lock: + mine = [copy.deepcopy(n) for n in self.data.get(NOTES, []) + if n["owner"] == user and (done is None or n["done"] == done)] return sorted(mine, key=lambda note: note["created_at"], reverse=True) @api.post("/api/notes", name="create_note", status=201) def create_note(self, note: NewNote, ctx: ApiContext) -> dict: """Add a note for the signed-in user, not done, created and updated now.""" - now, notes = iso(self.now()), self.data.setdefault(NOTES, []) - created = {"id": max((n["id"] for n in notes), default=0) + 1, "owner": self._user(ctx), - "title": _title(note.title), "body": note.body, "done": False, "created_at": now, "updated_at": now} - notes.append(created) - return created + owner, title, now = self._user(ctx), _title(note.title), iso(self.now()) + with self._lock: + notes = self.data.setdefault(NOTES, []) + created = {"id": max((n["id"] for n in notes), default=0) + 1, "owner": owner, "title": title, + "body": note.body, "done": False, "created_at": now, "updated_at": now} + notes.append(created) + return copy.deepcopy(created) @api.get("/api/notes/{note_id:int}", name="get_note") def get_note(self, note_id: int, ctx: ApiContext) -> dict: - return self._note(note_id, ctx) + with self._lock: + return copy.deepcopy(self._note(note_id, ctx)) @api.patch("/api/notes/{note_id:int}", name="edit_note") def edit_note(self, note_id: int, edit: NoteEdit, ctx: ApiContext) -> dict: """Change a note's title, body or done, and its ``updated_at``.""" - note = self._note(note_id, ctx) fields = edit.model_dump(exclude_none=True) if "title" in fields: fields["title"] = _title(fields["title"]) - note.update(fields, updated_at=iso(self.now())) - return note + now = iso(self.now()) + with self._lock: + note = self._note(note_id, ctx) + note.update(fields, updated_at=now) + return copy.deepcopy(note) @api.post("/api/notes/{note_id:int}/toggle", name="toggle_note") def toggle_note(self, note_id: int, ctx: ApiContext) -> dict: """Mark a note done, or not done when it was.""" - note = self._note(note_id, ctx) - note.update(done=not note["done"], updated_at=iso(self.now())) - return note + now = iso(self.now()) + with self._lock: + note = self._note(note_id, ctx) + note.update(done=not note["done"], updated_at=now) + return copy.deepcopy(note) @api.delete("/api/notes/{note_id:int}", name="delete_note") def delete_note(self, note_id: int, ctx: ApiContext) -> dict: - note = self._note(note_id, ctx) - self.data[NOTES] = [n for n in self.data[NOTES] if n is not note] + with self._lock: + note = self._note(note_id, ctx) + self.data[NOTES] = [n for n in self.data[NOTES] if n is not note] return {"deleted": note_id} @api.get("/api/admin/notes", name="all_notes", roles=["admin"]) def all_notes(self) -> list: """Every user's notes, for an ``admin`` caller only.""" - return self.data.get(NOTES, []) + with self._lock: + return copy.deepcopy(self.data.get(NOTES, [])) def _user(self, ctx: ApiContext) -> str: if ctx.user is None: @@ -152,7 +174,8 @@ def _user(self, ctx: ApiContext) -> str: return ctx.user def _note(self, note_id: int, ctx: ApiContext) -> dict: - """The signed-in user's note ``note_id``; another user's is as missing as one that doesn't exist.""" + """The signed-in user's note ``note_id``, read under the lock; another user's is as missing as one that + doesn't exist.""" note = next((n for n in self.data.get(NOTES, []) if n["id"] == note_id), None) if note is None or note["owner"] != self._user(ctx): raise NotFound(f"no note {note_id}") diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index f4045b1de..53127567d 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -10,6 +10,7 @@ import re import sys import threading +from concurrent.futures import ThreadPoolExecutor from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urljoin, urlsplit @@ -162,6 +163,38 @@ def test_notes_are_created_edited_toggled_and_deleted_by_their_owner_only(app, c assert client.get(f"/api/notes/{note['id']}").status_code == 404 +def test_concurrent_creates_each_get_a_note_of_their_own(env: WebsiteEnvironment) -> None: + barrier = threading.Barrier(8) + real_max = max + + def slow_max(*args, **kwargs): + """Hold every create's id choice open together, as worker threads racing would.""" + found = real_max(*args, **kwargs) + try: + barrier.wait(timeout=0.2) + except threading.BrokenBarrierError: + pass + return found + + notes_site.max = slow_max + try: + with ThreadPoolExecutor(8) as pool: + made = list(pool.map(lambda i: env.create_note(notes_site.NewNote(title=f"n{i}"), _Signed("ann")), + range(8))) + finally: + del notes_site.max + ids = [note["id"] for note in made] + assert len(set(ids)) == 8, ids + assert len([n for n in env.data["notes"] if n["owner"] == "ann" and n["title"].startswith("n")]) == 8 + + +class _Signed: + """An ``ApiContext`` stand-in signed in as ``user``.""" + + def __init__(self, user: str) -> None: + self.user = user + + def test_every_users_notes_are_listed_for_an_admin_caller_only(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(ROLE_KEY_ENV, "k3y") From 4bab4b8f8de61cb09bb595a625cb64fee8ae5b0c Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 13:42:16 -0800 Subject: [PATCH 08/13] feat(website): a website env may be an MCP server too, its tools declared on its card (FD-4683) - A WebsiteEnvironment with @tool or @resource methods serves them at /mcp (streamable HTTP, the mcp package) beside its pages, @api routes and data plane, in the site's process and under its clock; its card declares that endpoint as its mcp interface and lists the tools. A site without them serves no /mcp and declares none (serves_mcp()). - The gateway reads each site's card when it starts and serves the tools of one whose card declares an mcp interface as it does an MCP env's, with the caller's role in ToolContext. A site's interfaces are among the paths its card advertises, so its browsing route never reaches its /mcp. - Conformance checks a declared mcp endpoint: it answers initialize and lists the card's tools; a site without one skips the check. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/agentenv-protocol/README.md | 25 +++ .../agentenv_protocol/website/conformance.py | 57 +++++- .../agentenv_protocol/website/environment.py | 23 ++- .../tests/test_website_conformance.py | 6 +- .../tests/test_website_mcp.py | 170 ++++++++++++++++++ src/agent_env/env/gateway/entrypoint.py | 51 +++++- src/agent_env/env/gateway/gateway.py | 6 +- tst/unit/env/gateway/website_mcp_test.py | 123 +++++++++++++ 8 files changed, 449 insertions(+), 12 deletions(-) create mode 100644 packages/agentenv-protocol/tests/test_website_mcp.py create mode 100644 tst/unit/env/gateway/website_mcp_test.py diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 560821c68..c166812c4 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -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//`) 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 diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index 9f6c7debc..bbf5f91fc 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -7,7 +7,9 @@ returned, data/get again), ``clock/v1``'s ``sync_time`` against a clock the checker serves, and 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, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``. +none tied to a domain, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``. A site whose +card declares an ``mcp`` interface, being an MCP server too, must answer ``initialize`` there and ``tools/list`` with +the tools its card lists (streamable HTTP, ``MCP_PROTOCOL_VERSION``); a site without one skips that check. Every request goes to the server under check: a card's path or URL naming another scheme, host or port fails the check that would have used it, with nothing sent there. The round trip leaves the server's data as it found it, adding the @@ -33,13 +35,14 @@ 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" @dataclasses.dataclass(frozen=True) @@ -96,6 +99,7 @@ def run(self) -> list[Result]: health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH steps = (("any_host", self._any_host), ("health", lambda: self._health(card, health)), ("universe_now", self._universe_now), ("data_plane", lambda: self._data_plane(card)), + ("mcp", lambda: self._mcp(card)), ("clock", lambda: self._clock(card)), ("forwarded", lambda: self._forwarded(site)), ("cookies", lambda: self._cookies(site))) outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"} @@ -198,6 +202,40 @@ 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 the card's tools; 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").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"}) + listed = _mcp_result(self.client.post(url, headers=headers, json={ + "jsonrpc": "2.0", "id": "tools/list", "method": "tools/list"}), "tools/list") + finally: + if session: + with contextlib.suppress(httpx.HTTPError): + self.client.delete(url, headers=headers) + served = sorted(t.get("name") for t in listed.get("tools") or [] if isinstance(t, dict)) + advertised = sorted(t.get("name") for t in (card.get("capabilities") or {}).get("tools") or [] + if isinstance(t, dict)) + if served != advertised: + return "failed", f"tools/list at {path} named {served}, the card {advertised}" + return "passed", f"{server.get('name') or 'the server'} 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}" @@ -275,6 +313,21 @@ 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) -> dict: + """The JSON-RPC result of an MCP request, its answer JSON or the last event of an event stream.""" + text = response.text + if response.headers.get("content-type", "").startswith("text/event-stream"): + events = [line[len("data:"):] for line in text.splitlines() if line.startswith("data:")] + text = events[-1] if events else "" + try: + body = json.loads(text) + except ValueError: + raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC answer: {text[:200]!r}") from None + if not isinstance(body, dict) or "result" not in body: + raise RuntimeError(f"{method} answered {response.status_code}: {str(body)[:200]}") + return body["result"] or {} + + 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" diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py index 9c4db1ad3..76b955c25 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/environment.py @@ -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).""" @@ -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 @@ -159,8 +166,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)), @@ -170,6 +184,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") diff --git a/packages/agentenv-protocol/tests/test_website_conformance.py b/packages/agentenv-protocol/tests/test_website_conformance.py index c475846f0..950e1f6bb 100644 --- a/packages/agentenv-protocol/tests/test_website_conformance.py +++ b/packages/agentenv-protocol/tests/test_website_conformance.py @@ -19,7 +19,7 @@ from agentenv_protocol.website import conformance from agentenv_protocol.website.clock import UNIVERSE_NOW_HEADER, iso -CHECKS = ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"] +CHECKS = ["card", "any_host", "health", "universe_now", "data_plane", "mcp", "clock", "forwarded", "cookies"] def statuses(results: list[conformance.Result]) -> dict[str, str]: @@ -67,7 +67,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"}]} @@ -168,7 +168,7 @@ async def sync_time(request: Request) -> 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"), [ diff --git a/packages/agentenv-protocol/tests/test_website_mcp.py b/packages/agentenv-protocol/tests/test_website_mcp.py new file mode 100644 index 000000000..c4326bc06 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_website_mcp.py @@ -0,0 +1,170 @@ +"""A website env that is an MCP server too: its @tool methods served at /mcp, declared on its card, beside its pages, +API and data plane; a site without tools serving no /mcp; and conformance checking the endpoint a card declares.""" +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from starlette.testclient import TestClient + +from agentenv_protocol import DataPart, ToolContext, add_data, environment_card, get_data, reset_data, tool +from agentenv_protocol.website import UNIVERSE_NOW_HEADER, WebsiteEnvironment, api, website +from agentenv_protocol.website import conformance + +MCP_HEADERS = {"accept": "application/json, text/event-stream", "content-type": "application/json"} + + +def _shop(*, tools: bool = True, static: Path | None = None) -> WebsiteEnvironment: + if static is not None: + (static / "index.html").write_text("shop") + declared = website(domains=["shop.test"], static=None if static is None else str(static)) + + class Shop(WebsiteEnvironment): + def __init__(self) -> None: + super().__init__() + self.orders: list[dict] = [] + + @api.get("/api/orders", name="list_orders") + def list_orders(self) -> list: + return self.orders + + @reset_data + def reset(self) -> None: + self.orders = [] + + @add_data + def add(self, parts: list) -> None: + for part in parts: + self.orders += part.data.get("orders", []) + + @get_data + def export(self) -> list: + return [DataPart(data={"orders": self.orders})] + + if not tools: + return environment_card(name="shop")(declared(Shop))() + + class ToolShop(Shop): + @tool() + def place_order(self, item: str, ctx: ToolContext) -> str: + """Place an order for item, as the caller.""" + self.orders.append({"item": item, "by": ctx.role, "at": self.now().isoformat()}) + return f"order {len(self.orders)} placed" + + return environment_card(name="shop")(declared(ToolShop))() + + +def _answer(response: httpx.Response) -> dict: + text = response.text + if response.headers.get("content-type", "").startswith("text/event-stream"): + text = [line[len("data:"):] for line in text.splitlines() if line.startswith("data:")][-1] + return json.loads(text) + + +def _mcp(client: TestClient) -> dict: + """Headers of an initialized MCP session with the site.""" + first = client.post("/mcp", headers=MCP_HEADERS, json={ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}) + assert first.status_code == 200, first.text + headers = {**MCP_HEADERS, "mcp-session-id": first.headers["mcp-session-id"]} + assert client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"}).is_success + return headers + + +def test_a_site_with_tools_declares_its_mcp_endpoint_and_tools_on_its_card() -> None: + with TestClient(_shop().create_app()) as client: + card = client.get("/.well-known/agent-env.json").json() + assert card["additionalInterfaces"] == [{"url": "/mcp", "transport": "mcp"}] + assert [t["name"] for t in card["capabilities"]["tools"]] == ["place_order"] + assert "ctx" not in card["capabilities"]["tools"][0]["inputSchema"]["properties"] + assert {e["uri"] for e in card["capabilities"]["extensions"]} >= {"urn:agentenv:website/v1", "urn:agentenv:clock/v1"} + + +def test_a_tool_call_acts_on_the_site_its_pages_and_data_plane_read() -> None: + site = _shop() + with TestClient(site.create_app()) as client: + headers = _mcp(client) + listed = _answer(client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"})) + assert [t["name"] for t in listed["result"]["tools"]] == ["place_order"] + called = client.post("/mcp", headers={**headers, "AgentEnv-Role": "world"}, json={ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "place_order", "arguments": {"item": "milk"}}}) + assert _answer(called)["result"]["content"][0]["text"] == "order 1 placed" + assert UNIVERSE_NOW_HEADER in called.headers + assert [o["item"] for o in client.get("/api/orders").json()] == ["milk"] + data = client.post("/agentenv", json={"jsonrpc": "2.0", "id": 1, "method": "data/get"}).json() + assert data["result"]["parts"][0]["data"]["orders"][0]["item"] == "milk" + assert site.orders[0]["by"] == "world" + + +def test_a_site_without_tools_serves_no_mcp_endpoint() -> None: + site = _shop(tools=False) + assert not site.serves_mcp() + with TestClient(site.create_app()) as client: + card = client.get("/.well-known/agent-env.json").json() + answer = client.post("/mcp", headers=MCP_HEADERS, json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + assert card["additionalInterfaces"] == [] + assert "tools" not in (card["capabilities"] or {}) or not card["capabilities"]["tools"] + assert answer.status_code == 404 + assert site.mcp is None + + +def test_a_site_with_tools_passes_conformance_with_its_mcp_endpoint_checked(tmp_path: Path) -> None: + with TestClient(_shop(static=tmp_path).create_app()) as client: + results = {r.name: r for r in conformance.check("http://testserver", client=client)} + assert results["mcp"].status == "passed", results["mcp"] + assert "1 tool(s)" in results["mcp"].detail + assert all(r.passed for r in results.values()), results + + +def test_a_site_without_tools_skips_the_mcp_check(tmp_path: Path) -> None: + with TestClient(_shop(tools=False, static=tmp_path).create_app()) as client: + results = {r.name: r for r in conformance.check("http://testserver", client=client)} + assert results["mcp"].status == "skipped" + + +def _declaring(mcp: Route | None) -> Starlette: + """A server whose card declares an MCP interface listing the tool ``a``, answering at /mcp as ``mcp`` does.""" + card = {"name": "x", "additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}], + "capabilities": {"tools": [{"name": "a", "inputSchema": {"type": "object"}}]}} + routes = [Route("/.well-known/agent-env.json", lambda request: JSONResponse(card))] + return Starlette(routes=routes + ([mcp] if mcp is not None else [])) + + +def _listing(*names: str) -> Route: + async def answer(request: Request) -> Response: + body = await request.json() + if body.get("method") == "initialize": + return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"serverInfo": {"name": "x"}}}) + if body.get("method") == "tools/list": + return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"tools": [{"name": n} for n in names]}}) + return Response(status_code=202) + + return Route("/mcp", answer, methods=["POST"]) + + +@pytest.mark.parametrize(("mcp", "status", "detail"), [ + (_listing("a"), "passed", "lists the card's 1 tool(s)"), + (_listing("a", "b"), "failed", "named ['a', 'b'], the card ['a']"), + (None, "failed", "initialize answered 404"), +]) +def test_the_mcp_check_reads_the_endpoint_the_card_declares(mcp: Route | None, status: str, detail: str) -> None: + client = TestClient(_declaring(mcp)) + card = client.get("/.well-known/agent-env.json").json() + found = conformance._outcome(lambda: conformance._Checker("http://testserver", client, "127.0.0.1")._mcp(card)) + assert found[0] == status and detail in found[1], found + + +def test_an_mcp_interface_on_another_server_fails_the_check_with_nothing_sent_there() -> None: + sent = [] + client = httpx.Client(transport=httpx.MockTransport(lambda request: sent.append(request) or httpx.Response(500))) + card = {"additionalInterfaces": [{"url": "http://elsewhere.test/mcp", "transport": "mcp"}]} + found = conformance._outcome(lambda: conformance._Checker("http://site.test", client, "127.0.0.1")._mcp(card)) + assert found[0] == "failed" and sent == [] diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 65cf7e3c3..1ded44d2b 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -7,7 +7,7 @@ import httpx from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer -from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV +from .constants import DEFAULT_MCP_SERVER_NAME, GATEWAY_PROVIDER_HEADERS_ENV, MCP_TRANSPORT, WELL_KNOWN_PATH from .gateway import Gateway logger = logging.getLogger(__name__) @@ -103,6 +103,48 @@ async def wait_for_internal_servers(servers: list[InternalMCPServer]) -> None: logger.info(f"All {len(servers)} internal MCP server(s) ready in {time.monotonic() - t0:.1f}s") +async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = frozenset(), + timeout_s: int = _INTERNAL_SERVER_TIMEOUT_S) -> list[InternalMCPServer]: + """The sites, by name at the address their server answers, that are MCP servers too: each as an internal server at + the MCP endpoint its card declares, so the gateway serves its tools as it does an MCP env's. A site whose card + declares no MCP interface, or that answers no card, is left out, as is one named as an internal server already + (``taken``). A site whose server can't be reached yet is asked again until ``timeout_s``.""" + found: list[InternalMCPServer] = [] + deadline = time.monotonic() + timeout_s + async with httpx.AsyncClient() as client: + for name, base_url in sites.items(): + if name in taken: + continue + base = base_url.rstrip("/") + card = await _site_card(client, name, base, deadline) + path = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] + if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) + if isinstance(path, str) and path.startswith("/"): + logger.info(f"Site {name} is an MCP server at {path}") + found.append(InternalMCPServer(name=name, mcp_url=f"{base}{path}")) + return found + + +async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: + """The card the site at ``base`` serves, None when it serves none; asked again while its server can't be reached + or answers 5xx, until ``deadline``.""" + while True: + try: + answer = await client.get(base + WELL_KNOWN_PATH, timeout=5) + except httpx.HTTPError: + answer = None + if answer is not None and answer.status_code < 500: + try: + card = answer.json() if answer.status_code == 200 else None + except ValueError: + return None + return card if isinstance(card, dict) else None + if time.monotonic() >= deadline: + logger.warning(f"site {name}'s card at {base} didn't answer in time; its MCP tools, if any, aren't served") + return None + await asyncio.sleep(_INTERNAL_SERVER_POLL_S) + + def parse_name_url_map(env_str: str) -> dict[str, str]: # Expected format: 'slack=http://slack-website-backend:8000,email=http://email-website-backend:8000' urls: dict[str, str] = {} @@ -137,10 +179,13 @@ def parse_name_url_map(env_str: str) -> dict[str, str]: spec=os.environ.get("INTERNAL_MCP_SERVERS", ""), port=int(os.environ.get("INTERNAL_MCP_PORT", str(AGENT_ENV_GATEWAY_MCP_PORT))), ) - asyncio.run(wait_for_internal_servers(internal_mcp_servers)) - website_urls = parse_name_url_map(os.environ.get("WEBSITE_URLS", "")) rest_proxy_urls = parse_name_url_map(os.environ.get("REST_PROXY_URLS", "")) + internal_mcp_servers += asyncio.run(website_mcp_servers( + {name: rest_proxy_urls.get(name, url) for name, url in website_urls.items()}, + taken=frozenset(server.name for server in internal_mcp_servers))) + asyncio.run(wait_for_internal_servers(internal_mcp_servers)) + gateway_mode = GatewayMode(os.environ.get("GATEWAY_MODE", GatewayMode.PERFORMANCE.value)) service_db_url = os.environ.get("SERVICE_DB_URL") gateway = Gateway( diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 79f6d1911..cec722b0a 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -1669,9 +1669,11 @@ def reserved_site_path(path: str, reserved: frozenset[str] = frozenset()) -> boo def site_reserved_paths(card: dict) -> frozenset[str]: - """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, and every - extension's endpoint and each of its methods'; each one a path of its own, not the site's root.""" + """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, + such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its + methods'; each one a path of its own, not the site's root.""" paths = [card.get("url")] + paths += [i.get("url") for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)] for ext in (card.get("capabilities") or {}).get("extensions") or []: params = ext.get("params") if isinstance(ext, dict) else None if not isinstance(params, dict): diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py new file mode 100644 index 000000000..f99928124 --- /dev/null +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -0,0 +1,123 @@ +"""A website env that is an MCP server too: the gateway finds its MCP endpoint on its card and serves its tools as it +does an MCP env's, while the site's route keeps that endpoint, like the rest of what its card advertises, out of an +agent's browsing reach.""" +from __future__ import annotations + +import httpx +import pytest +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client +from pytest_socket import enable_socket + +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, InternalMCPServer +from agent_env.env.gateway import entrypoint as entrypoint_module +from agent_env.env.gateway.entrypoint import website_mcp_servers +from agent_env.env.gateway.gateway import Gateway, site_reserved_paths +from agentenv_protocol import DataPart, ToolContext, environment_card, get_data, tool +from agentenv_protocol.website import WebsiteEnvironment, website +from tst.unit.env.gateway.process_scope_socket_test import _serve + +CARD = "/.well-known/agent-env.json" + + +def test_a_sites_mcp_interface_is_one_of_its_reserved_paths(): + card = {"url": "/agentenv", "additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}, {"transport": "x"}]} + assert site_reserved_paths(card) == frozenset({"/agentenv", "/mcp"}) + + +@pytest.mark.asyncio +async def test_the_sites_whose_cards_declare_an_mcp_interface_are_internal_servers(monkeypatch): + calls: dict[str, int] = {} + cards = { + "shop": {"additionalInterfaces": [{"url": "/tools/mcp", "transport": "mcp"}]}, + "mail": {"additionalInterfaces": []}, + "other": {"additionalInterfaces": [{"url": "/rpc", "transport": "grpc"}]}, + } + + def site(request: httpx.Request) -> httpx.Response: + host = request.url.host + calls[host] = calls.get(host, 0) + 1 + assert request.url.path == CARD + if host == "late" and calls[host] == 1: + raise httpx.ConnectError("not up yet") + if host == "late": + return httpx.Response(200, json={"additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}]}) + if host == "nocard": + return httpx.Response(404) + if host == "html": + return httpx.Response(200, text="") + return httpx.Response(200, json=cards[host]) + + real = httpx.AsyncClient + monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) + monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0) + sites = {name: f"http://{name}:8000/" for name in ("shop", "mail", "other", "late", "nocard", "html", "slack")} + + found = await website_mcp_servers(sites, taken=frozenset({"slack"})) + + assert found == [InternalMCPServer("shop", "http://shop:8000/tools/mcp"), InternalMCPServer("late", "http://late:8000/mcp")] + assert "slack" not in calls and calls["late"] == 2 + + +@pytest.mark.asyncio +async def test_a_site_whose_server_never_answers_is_left_out_once_the_time_is_up(monkeypatch): + def down(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("down") + + real = httpx.AsyncClient + monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(down), **kw)) + monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0) + assert await website_mcp_servers({"shop": "http://shop:8000"}, timeout_s=0) == [] + + +@environment_card(name="shop") +@website(domains=["shop.test"]) +class _Shop(WebsiteEnvironment): + def __init__(self) -> None: + super().__init__() + self.orders: list[dict] = [] + + @tool() + def place_order(self, item: str, ctx: ToolContext) -> str: + """Place an order for item, as the caller.""" + self.orders.append({"item": item, "by": ctx.role}) + return f"order {len(self.orders)} placed" + + @get_data + def export(self) -> list: + return [DataPart(data={"orders": self.orders})] + + +@pytest.mark.asyncio +async def test_the_gateway_serves_a_sites_tools_and_its_route_refuses_the_sites_mcp_endpoint(): + enable_socket() + site = _Shop() + site_server, site_url = await _serve(site.create_app()) + try: + servers = await website_mcp_servers({"shop": site_url}) + assert servers == [InternalMCPServer("shop", f"{site_url}/mcp")] + gw = Gateway(host="127.0.0.1", port=0, server_name="t", internal_mcp_servers=servers, + website_urls={"shop": site_url}, rest_proxy_urls={"shop": site_url}) + gw_server, gw_url = await _serve(gw._asgi_app()) + try: + async with ( + httpx.AsyncClient(headers={AGENT_ENV_ROLE_HEADER: "alice@example.com"}, timeout=30) as http, + streamable_http_client(f"{gw_url}/mcp", http_client=http) as (read, write, _), + ClientSession(read, write) as session, + ): + await session.initialize() + assert "place_order" in [t.name for t in (await session.list_tools()).tools] + result = await session.call_tool("place_order", {"item": "milk"}) + assert not result.isError and result.content[0].text == "order 1 placed" + async with httpx.AsyncClient(timeout=30) as http: + browsing = await http.post(f"{gw_url}/website/shop/mcp", json={"jsonrpc": "2.0", "id": 1, + "method": "tools/list"}) + data = await http.post(f"{gw_url}/svc/shop/agentenv", json={"jsonrpc": "2.0", "id": 1, + "method": "data/get"}) + finally: + await gw_server.shutdown() + finally: + await site_server.shutdown() + assert site.orders == [{"item": "milk", "by": "alice@example.com"}] + assert browsing.status_code == 404 + assert data.json()["result"]["parts"][0]["data"]["orders"][0]["item"] == "milk" From dac69e28d75117c872937b31a442ff898a13e7f3 Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 14:34:30 -0800 Subject: [PATCH 09/13] fix(website): a site's MCP calls through /svc carry the gateway's role, sites are found at once, and conformance reads every page - A call to a site's MCP endpoint through /svc// gets the gateway's role in its JSON-RPC _meta, as an MCP server's does, at the endpoint its card declares (site_mcp_endpoints), so a caller can't claim another role there. - The gateway asks every site for its card at once, so a site that never comes up leaves another its own retries. - Conformance reads every page of tools/list, fails a listed tool the card doesn't name, and warns on a card's tool it doesn't list, as a hidden-by-default one is listed to no caller. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv_protocol/website/conformance.py | 40 +++++++++++++------ .../tests/test_website_mcp.py | 32 +++++++++++++-- src/agent_env/env/gateway/entrypoint.py | 23 +++++------ src/agent_env/env/gateway/gateway.py | 21 ++++++++-- src/agent_env/env/gateway/rest_proxy.py | 20 ++++++---- tst/unit/env/gateway/website_mcp_test.py | 40 +++++++++++++++++-- 6 files changed, 134 insertions(+), 42 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index bbf5f91fc..2d777d84e 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -8,8 +8,10 @@ ``sync_time`` against a clock the checker serves, and 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, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``. A site whose -card declares an ``mcp`` interface, being an MCP server too, must answer ``initialize`` there and ``tools/list`` with -the tools its card lists (streamable HTTP, ``MCP_PROTOCOL_VERSION``); a site without one skips that check. +card declares an ``mcp`` interface, being an MCP server too, must answer ``initialize`` there and list, 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 a warning, since a hidden-by-default tool is listed to no caller until one enables it. A site without one +skips that check. Every request goes to the server under check: a card's path or URL naming another scheme, host or port fails the check that would have used it, with nothing sent there. The round trip leaves the server's data as it found it, adding the @@ -43,6 +45,7 @@ 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) @@ -203,8 +206,9 @@ def _data_plane(self, card: dict) -> tuple[Status, str]: 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 the card's tools; its answers needn't - carry ``X-Universe-Now``, which the MCP transport doesn't promise.""" + """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: @@ -223,18 +227,30 @@ def _mcp(self, card: dict) -> tuple[Status, str]: headers["Mcp-Session-Id"] = session try: self.client.post(url, headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"}) - listed = _mcp_result(self.client.post(url, headers=headers, json={ - "jsonrpc": "2.0", "id": "tools/list", "method": "tools/list"}), "tools/list") + 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") + served += [t.get("name") for t in listed.get("tools") or [] 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) - served = sorted(t.get("name") for t in listed.get("tools") or [] if isinstance(t, dict)) - advertised = sorted(t.get("name") for t in (card.get("capabilities") or {}).get("tools") or [] - if isinstance(t, dict)) - if served != advertised: - return "failed", f"tools/list at {path} named {served}, the card {advertised}" - return "passed", f"{server.get('name') or 'the server'} at {path} lists the card's {len(served)} tool(s)" + 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: diff --git a/packages/agentenv-protocol/tests/test_website_mcp.py b/packages/agentenv-protocol/tests/test_website_mcp.py index c4326bc06..5a82cdc3a 100644 --- a/packages/agentenv-protocol/tests/test_website_mcp.py +++ b/packages/agentenv-protocol/tests/test_website_mcp.py @@ -138,21 +138,27 @@ def _declaring(mcp: Route | None) -> Starlette: return Starlette(routes=routes + ([mcp] if mcp is not None else [])) -def _listing(*names: str) -> Route: +def _listing(*pages: tuple[str, ...]) -> Route: + """An MCP endpoint whose tools/list answers ``pages`` in turn, each pointing at the next by its cursor.""" async def answer(request: Request) -> Response: body = await request.json() if body.get("method") == "initialize": return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"serverInfo": {"name": "x"}}}) if body.get("method") == "tools/list": - return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": {"tools": [{"name": n} for n in names]}}) + at = int((body.get("params") or {}).get("cursor") or 0) + result = {"tools": [{"name": n} for n in pages[at]], **({"nextCursor": str(at + 1)} if at + 1 < len(pages) + else {})} + return JSONResponse({"jsonrpc": "2.0", "id": body["id"], "result": result}) return Response(status_code=202) return Route("/mcp", answer, methods=["POST"]) @pytest.mark.parametrize(("mcp", "status", "detail"), [ - (_listing("a"), "passed", "lists the card's 1 tool(s)"), - (_listing("a", "b"), "failed", "named ['a', 'b'], the card ['a']"), + (_listing(("a",)), "passed", "lists the card's 1 tool(s)"), + (_listing((), ("a",)), "passed", "lists the card's 1 tool(s)"), + (_listing(("a", "b")), "failed", "named ['b'], which the card doesn't list"), + (_listing(()), "warned", "lists 0 of the card's tools, not ['a']: hidden by default, or missing"), (None, "failed", "initialize answered 404"), ]) def test_the_mcp_check_reads_the_endpoint_the_card_declares(mcp: Route | None, status: str, detail: str) -> None: @@ -162,6 +168,24 @@ def test_the_mcp_check_reads_the_endpoint_the_card_declares(mcp: Route | None, s assert found[0] == status and detail in found[1], found +def test_a_hidden_by_default_tool_on_the_card_warns_without_failing_the_run(tmp_path: Path) -> None: + site = _shop(static=tmp_path) + + class Gated(type(site)): + tool_access = True + + @tool(hidden_by_default=True) + def refund(self, item: str) -> str: + """Refund an order.""" + return "refunded" + + gated = environment_card(name="shop")(Gated)() + with TestClient(gated.create_app()) as client: + results = {r.name: r for r in conformance.check("http://testserver", client=client)} + assert results["mcp"].status == "warned" and "['refund']" in results["mcp"].detail + assert all(r.passed for r in results.values()), results + + def test_an_mcp_interface_on_another_server_fails_the_check_with_nothing_sent_there() -> None: sent = [] client = httpx.Client(transport=httpx.MockTransport(lambda request: sent.append(request) or httpx.Response(500))) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 1ded44d2b..7561d9082 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -108,20 +108,19 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro """The sites, by name at the address their server answers, that are MCP servers too: each as an internal server at the MCP endpoint its card declares, so the gateway serves its tools as it does an MCP env's. A site whose card declares no MCP interface, or that answers no card, is left out, as is one named as an internal server already - (``taken``). A site whose server can't be reached yet is asked again until ``timeout_s``.""" - found: list[InternalMCPServer] = [] + (``taken``). Every site is asked at once, and one whose server can't be reached yet is asked again until + ``timeout_s``.""" + asked = {name: url.rstrip("/") for name, url in sites.items() if name not in taken} deadline = time.monotonic() + timeout_s async with httpx.AsyncClient() as client: - for name, base_url in sites.items(): - if name in taken: - continue - base = base_url.rstrip("/") - card = await _site_card(client, name, base, deadline) - path = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] - if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) - if isinstance(path, str) and path.startswith("/"): - logger.info(f"Site {name} is an MCP server at {path}") - found.append(InternalMCPServer(name=name, mcp_url=f"{base}{path}")) + cards = await asyncio.gather(*(_site_card(client, name, base, deadline) for name, base in asked.items())) + found: list[InternalMCPServer] = [] + for (name, base), card in zip(asked.items(), cards): + path = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] + if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) + if isinstance(path, str) and path.startswith("/"): + logger.info(f"Site {name} is an MCP server at {path}") + found.append(InternalMCPServer(name=name, mcp_url=f"{base}{path}")) return found diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index cec722b0a..d92984657 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -153,6 +153,7 @@ def __init__( self.internal_mcp_servers = internal_mcp_servers self.website_urls = website_urls or {} self.rest_proxy_urls = rest_proxy_urls or {} + self._site_mcp_endpoints = site_mcp_endpoints(internal_mcp_servers, self.website_urls, self.rest_proxy_urls) self._site_reserved: dict[str, frozenset[str]] = {} self._withheld = frozenset(name.strip().lower().encode() for name in provider_headers if name.strip()) self._browsers = BrowserRoutes(self.rest_proxy_urls, self.REST_PROXY_TIMEOUT, self._log_event, @@ -651,12 +652,14 @@ async def _proxy_rest_request(self, request: Request, url_map: dict[str, str] | if base_url is None: return self._json_response({"error": f"Unknown service: {service_name}"}, 404) role = self._request_role(request) - mcp_server = url_map is self.rest_proxy_urls and service_name.startswith("mcp-") + site_mcp = self._site_mcp_endpoints.get(service_name) if url_map is self.rest_proxy_urls else None + mcp_server = url_map is self.rest_proxy_urls and (service_name.startswith("mcp-") or site_mcp is not None) + mcp_endpoint = site_mcp or "mcp" if url_map is self.rest_proxy_urls and (cdp := await self._browsers.cdp_answer(request, base_url, role)) is not None: return cdp if request.method in _SAFE_METHODS: return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server, - withheld=self._withheld) + withheld=self._withheld, mcp_endpoint=mcp_endpoint) changelog_before = self._query_changelog_id() attribution = self._attribution(role) # at the start: a fire may be gone by the time the response ends @@ -681,7 +684,7 @@ async def log_write(status: int) -> None: }) return await forward(request, base_url, role=role, timeout=self.REST_PROXY_TIMEOUT, mcp_server=mcp_server, - on_done=log_write, withheld=self._withheld) + on_done=log_write, withheld=self._withheld, mcp_endpoint=mcp_endpoint) async def _proxy_website_request(self, request: Request) -> Response: """Proxy a website request; with a website or state trigger registered, feed it to the engine and hold barriers. @@ -1668,6 +1671,18 @@ def reserved_site_path(path: str, reserved: frozenset[str] = frozenset()) -> boo for base in (RPC_PATH, *reserved)) +def site_mcp_endpoints(servers: list[InternalMCPServer], website_urls: dict[str, str], + rest_proxy_urls: dict[str, str]) -> dict[str, str]: + """The MCP endpoint, as a path under its /svc// base, of each site that is an internal MCP server too, by + name: where a call through /svc// gets the gateway's role in its JSON-RPC ``_meta``, as an MCP server's does.""" + endpoints = {} + for server in servers: + base = rest_proxy_urls.get(server.name, "").rstrip("/") + if server.name in website_urls and base and server.mcp_url.startswith(base + "/"): + endpoints[server.name] = server.mcp_url[len(base):] + return endpoints + + def site_reserved_paths(card: dict) -> frozenset[str]: """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its diff --git a/src/agent_env/env/gateway/rest_proxy.py b/src/agent_env/env/gateway/rest_proxy.py index 6dbe137f9..4278512b4 100644 --- a/src/agent_env/env/gateway/rest_proxy.py +++ b/src/agent_env/env/gateway/rest_proxy.py @@ -86,11 +86,11 @@ async def forward(request: Request, base_url: str, *, role: str, timeout: httpx.Timeout, mcp_server: bool, on_done: Callable[[int], Awaitable[None]] | None = None, - withheld: frozenset[bytes] = frozenset()) -> Response: + withheld: frozenset[bytes] = frozenset(), mcp_endpoint: str = "mcp") -> Response: """Forward `request` to the service at `base_url` as `role`, without the headers named in `withheld`, and stream the response through. `mcp_server` says - the service is a backing MCP server, whose own endpoint takes the role in its JSON-RPC `_meta` as well as in the - header. A 400 names a request-target the proxy cannot route; a 502 names an upstream that never answered. + the service is a backing MCP server, whose own endpoint, `mcp_endpoint` under it, takes the role in its JSON-RPC + `_meta` as well as in the header. A 400 names a request-target the proxy cannot route; a 502 names an upstream that never answered. `on_done` is awaited with the status once the response has ended, after its last byte when streamed.""" try: target_url = upstream_url(base_url, request) @@ -98,7 +98,7 @@ async def forward(request: Request, base_url: str, *, role: str, timeout: httpx. return await _done(_json_response({"error": str(e)}, 400), on_done) content = await request.body() # /svc and /website request bodies are small (JSON-RPC, forms) headers = upstream_headers(request, role, withheld=withheld) - if mcp_server and request.method == "POST" and is_child_mcp_path(request.path_params.get("path", "")): + if mcp_server and request.method == "POST" and is_child_mcp_path(request.path_params.get("path", ""), mcp_endpoint): # The gateway is the trust boundary for identity: a child's proxied MCP endpoint stays reachable, but the # role in its JSON-RPC `_meta` is the gateway's to write, like the header above. content = stamp_jsonrpc_role(content, role) @@ -187,15 +187,19 @@ def route_prefix(request: HTTPConnection) -> str: return raw_path.decode("latin-1").rstrip("/") -def is_child_mcp_path(decoded_rest: str) -> bool: - """Whether a /svc/{service}/{path} remainder reaches the backing server's MCP endpoint (`mcp` or anything under - it), judged after dot-segment normalisation, since httpx collapses `x/../mcp` to `/mcp` on the way upstream. +def is_child_mcp_path(decoded_rest: str, endpoint: str = "mcp") -> bool: + """Whether a /svc/{service}/{path} remainder reaches the backing server's MCP endpoint (`endpoint`, `mcp` unless + its card declares another, or anything under it), judged after dot-segment normalisation, since httpx collapses + `x/../mcp` to `/mcp` on the way upstream. >>> [is_child_mcp_path(p) for p in ("mcp", "mcp/", "mcp/x", "x/../mcp", "./mcp", "/mcp", "mcpx", "agentenv", "")] [True, True, True, True, True, True, False, False, False] + >>> [is_child_mcp_path(p, "/tools/mcp/") for p in ("tools/mcp", "tools/x/../mcp", "mcp")] + [True, True, False] """ normalized = posixpath.normpath("/" + decoded_rest.lstrip("/")) - return normalized == "/mcp" or normalized.startswith("/mcp/") + at = posixpath.normpath("/" + endpoint.strip("/")) + return normalized == at or normalized.startswith(at + "/") def stamp_jsonrpc_role(body: bytes, role: str) -> bytes: diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index f99928124..6aa9f17e0 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -9,10 +9,10 @@ from mcp.client.streamable_http import streamable_http_client from pytest_socket import enable_socket -from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, InternalMCPServer +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER, AGENT_ENV_ROLE_META_KEY, InternalMCPServer from agent_env.env.gateway import entrypoint as entrypoint_module from agent_env.env.gateway.entrypoint import website_mcp_servers -from agent_env.env.gateway.gateway import Gateway, site_reserved_paths +from agent_env.env.gateway.gateway import Gateway, site_mcp_endpoints, site_reserved_paths from agentenv_protocol import DataPart, ToolContext, environment_card, get_data, tool from agentenv_protocol.website import WebsiteEnvironment, website from tst.unit.env.gateway.process_scope_socket_test import _serve @@ -25,6 +25,14 @@ def test_a_sites_mcp_interface_is_one_of_its_reserved_paths(): assert site_reserved_paths(card) == frozenset({"/agentenv", "/mcp"}) +def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): + servers = [InternalMCPServer("shop", "http://shop-backend:8000/tools/mcp"), + InternalMCPServer("slack", "http://slack:18765/mcp"), + InternalMCPServer("odd", "http://elsewhere:9/mcp")] + sites = {"shop": "http://shop-backend:8000", "odd": "http://odd-backend:8000"} + assert site_mcp_endpoints(servers, sites, {**sites, "mcp-slack": "http://slack:18765"}) == {"shop": "/tools/mcp"} + + @pytest.mark.asyncio async def test_the_sites_whose_cards_declare_an_mcp_interface_are_internal_servers(monkeypatch): calls: dict[str, int] = {} @@ -70,6 +78,24 @@ def down(request: httpx.Request) -> httpx.Response: assert await website_mcp_servers({"shop": "http://shop:8000"}, timeout_s=0) == [] +@pytest.mark.asyncio +async def test_sites_are_asked_at_once_so_one_never_up_leaves_another_its_retries(monkeypatch): + calls: dict[str, int] = {} + + def site(request: httpx.Request) -> httpx.Response: + host = request.url.host + calls[host] = calls.get(host, 0) + 1 + if host == "down" or calls[host] < 3: + raise httpx.ConnectError("not up yet") + return httpx.Response(200, json={"additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}]}) + + real = httpx.AsyncClient + monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) + monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0.01) + found = await website_mcp_servers({"down": "http://down", "late": "http://late"}, timeout_s=1) + assert found == [InternalMCPServer("late", "http://late/mcp")] + + @environment_card(name="shop") @website(domains=["shop.test"]) class _Shop(WebsiteEnvironment): @@ -109,6 +135,14 @@ async def test_the_gateway_serves_a_sites_tools_and_its_route_refuses_the_sites_ assert "place_order" in [t.name for t in (await session.list_tools()).tools] result = await session.call_tool("place_order", {"item": "milk"}) assert not result.isError and result.content[0].text == "order 1 placed" + forged = {AGENT_ENV_ROLE_META_KEY: "mallory@example.com"} + async with ( + httpx.AsyncClient(headers={AGENT_ENV_ROLE_HEADER: "bob@example.com"}, timeout=30) as http, + streamable_http_client(f"{gw_url}/svc/shop/mcp", http_client=http) as (read, write, _), + ClientSession(read, write) as session, + ): + await session.initialize() + assert not (await session.call_tool("place_order", {"item": "eggs"}, meta=forged)).isError async with httpx.AsyncClient(timeout=30) as http: browsing = await http.post(f"{gw_url}/website/shop/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) @@ -118,6 +152,6 @@ async def test_the_gateway_serves_a_sites_tools_and_its_route_refuses_the_sites_ await gw_server.shutdown() finally: await site_server.shutdown() - assert site.orders == [{"item": "milk", "by": "alice@example.com"}] + assert site.orders == [{"item": "milk", "by": "alice@example.com"}, {"item": "eggs", "by": "bob@example.com"}] assert browsing.status_code == 404 assert data.json()["result"]["parts"][0]["data"]["orders"][0]["item"] == "milk" From 7a909f17a6b952a302770e18ead7ae1d508d20bf Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 14:58:34 -0800 Subject: [PATCH 10/13] test(website): the Notes example skips the mcp check, serving no tools Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv-protocol/tests/test_website_notes_example.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agentenv-protocol/tests/test_website_notes_example.py b/packages/agentenv-protocol/tests/test_website_notes_example.py index 53127567d..25a5d109a 100644 --- a/packages/agentenv-protocol/tests/test_website_notes_example.py +++ b/packages/agentenv-protocol/tests/test_website_notes_example.py @@ -111,8 +111,9 @@ def ready(client: TestClient) -> dict: def test_the_example_passes_every_conformance_check(client: TestClient) -> None: results = conformance.check("http://testserver", client=client) - assert {r.name: r.status for r in results} == dict.fromkeys( - ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), results + assert {r.name: r.status for r in results} == {**dict.fromkeys( + ["card", "any_host", "health", "universe_now", "data_plane", "clock", "forwarded", "cookies"], "passed"), + "mcp": "skipped"}, results def test_the_card_declares_the_site_and_its_routes_by_name_and_it_is_ready_through_website_v1( From 199d1f275ba386de8a538e7731460aad40766330 Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 15:16:31 -0800 Subject: [PATCH 11/13] fix(website): a site's MCP endpoint may be a full URL, is reserved however its card writes it, and conformance reads whole events - The gateway takes a card's MCP endpoint written as a full URL when it names the site's own server, joining a path under the site's address as before, and leaves out, saying so, one on another server. - A site's reserved paths come from each URL its card advertises, a full one or one with a query as well as a bare path, so its browsing route never reaches /mcp however the card writes it. - Conformance reads an event stream's events whole, their data lines joined, and takes the reply to its own request, as ToolSession does, so a reply split over lines or beside notifications is read right. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agentenv_protocol/website/conformance.py | 53 ++++++++++++++----- .../tests/test_website_mcp.py | 24 +++++++++ src/agent_env/env/gateway/entrypoint.py | 29 ++++++++-- src/agent_env/env/gateway/gateway.py | 27 +++++++--- tst/unit/env/gateway/website_mcp_test.py | 15 ++++-- .../gateway/website_reserved_paths_test.py | 4 +- 6 files changed, 120 insertions(+), 32 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py index 2d777d84e..9d912f2cd 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website/conformance.py @@ -222,7 +222,7 @@ def _mcp(self, card: dict) -> tuple[Status, str]: "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").get("serverInfo") or {} + server = _mcp_result(first, "initialize", "initialize").get("serverInfo") or {} if session := first.headers.get("mcp-session-id"): headers["Mcp-Session-Id"] = session try: @@ -232,7 +232,7 @@ def _mcp(self, card: dict) -> tuple[Status, str]: 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") + **({"params": {"cursor": cursor}} if cursor else {})}), "tools/list", "tools/list") served += [t.get("name") for t in listed.get("tools") or [] if isinstance(t, dict)] cursor = listed.get("nextCursor") if not cursor: @@ -329,21 +329,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) -> dict: - """The JSON-RPC result of an MCP request, its answer JSON or the last event of an event stream.""" - text = response.text +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"): - events = [line[len("data:"):] for line in text.splitlines() if line.startswith("data:")] - text = events[-1] if events else "" - try: - body = json.loads(text) - except ValueError: - raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC answer: {text[:200]!r}") from None - if not isinstance(body, dict) or "result" not in body: - raise RuntimeError(f"{method} answered {response.status_code}: {str(body)[:200]}") + 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" diff --git a/packages/agentenv-protocol/tests/test_website_mcp.py b/packages/agentenv-protocol/tests/test_website_mcp.py index 5a82cdc3a..b57610731 100644 --- a/packages/agentenv-protocol/tests/test_website_mcp.py +++ b/packages/agentenv-protocol/tests/test_website_mcp.py @@ -186,6 +186,30 @@ def refund(self, item: str) -> str: assert all(r.passed for r in results.values()), results +def _streaming(*events: str) -> Route: + """An MCP endpoint answering each request with an event stream of ``events``, ``{id}`` the request's id.""" + async def answer(request: Request) -> Response: + body = await request.json() + if "id" not in body: + return Response(status_code=202) + stream = "".join(event.replace("{id}", json.dumps(body["id"])) for event in events) + return Response(stream, media_type="text/event-stream") + + return Route("/mcp", answer, methods=["POST"]) + + +def test_the_mcp_check_reads_whole_events_and_the_reply_to_its_own_request() -> None: + reply = '{"jsonrpc": "2.0", "id": {id},\n"result": {"serverInfo": {"name": "x"}, "tools": [{"name": "a"}]}}' + mcp = _streaming('event: message\ndata: {"jsonrpc": "2.0", "method": "notifications/message", "params": {}}\n\n', + ": a comment\n\n", + "".join(f"data: {line}\n" for line in reply.split("\n")) + "\n", + 'data: {"jsonrpc": "2.0", "method": "notifications/progress", "params": {}}\n\n') + client = TestClient(_declaring(mcp)) + card = client.get("/.well-known/agent-env.json").json() + found = conformance._outcome(lambda: conformance._Checker("http://testserver", client, "127.0.0.1")._mcp(card)) + assert found == ("passed", "x at /mcp lists the card's 1 tool(s)") + + def test_an_mcp_interface_on_another_server_fails_the_check_with_nothing_sent_there() -> None: sent = [] client = httpx.Client(transport=httpx.MockTransport(lambda request: sent.append(request) or httpx.Response(500))) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 7561d9082..a588acdce 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -3,6 +3,7 @@ import logging import os import time +from urllib.parse import urljoin, urlsplit import httpx @@ -45,6 +46,7 @@ def parse_internal_servers(spec: str, port: int) -> list[InternalMCPServer]: # docker-compose `depends_on`), so the old 30s ceiling was far too short. _INTERNAL_SERVER_TIMEOUT_S = int(os.environ.get("GATEWAY_INTERNAL_SERVER_TIMEOUT_S", "180")) _INTERNAL_SERVER_POLL_S = 2 +_DEFAULT_PORTS = {"http": 80, "https": 443} def _ready_url(mcp_url: str) -> str: @@ -116,14 +118,31 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro cards = await asyncio.gather(*(_site_card(client, name, base, deadline) for name, base in asked.items())) found: list[InternalMCPServer] = [] for (name, base), card in zip(asked.items(), cards): - path = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] - if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) - if isinstance(path, str) and path.startswith("/"): - logger.info(f"Site {name} is an MCP server at {path}") - found.append(InternalMCPServer(name=name, mcp_url=f"{base}{path}")) + declared = next((i.get("url") for i in (card or {}).get("additionalInterfaces") or [] + if isinstance(i, dict) and i.get("transport") == MCP_TRANSPORT and i.get("url")), None) + if isinstance(declared, str) and (mcp_url := _site_mcp_url(base, declared)) is not None: + logger.info(f"Site {name} is an MCP server at {mcp_url}") + found.append(InternalMCPServer(name=name, mcp_url=mcp_url)) + elif declared is not None: + logger.warning(f"Site {name}'s card declares its MCP endpoint at {declared!r}, not on its own server; its " + "tools aren't served") return found +def _site_mcp_url(base: str, declared: str) -> str | None: + """Where the site at ``base`` serves the MCP endpoint its card declares at ``declared``: a path joined under the + site's address, or a full URL taken as it is when it names that same server; None for another server.""" + if declared.startswith("/"): + return f"{base}{declared}" + try: + given, own = urlsplit(urljoin(base + "/", declared)), urlsplit(base) + same = (given.scheme, given.hostname, given.port or _DEFAULT_PORTS.get(given.scheme)) == ( + own.scheme, own.hostname, own.port or _DEFAULT_PORTS.get(own.scheme)) + except ValueError: + return None + return given.geturl() if same and given.path.startswith("/") else None + + async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: """The card the site at ``base`` serves, None when it serves none; asked again while its server can't be reached or answers 5xx, until ``deadline``.""" diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index d92984657..75c1e91c5 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qsl +from urllib.parse import parse_qsl, urlsplit from uuid import uuid4 import anyio @@ -1679,23 +1679,34 @@ def site_mcp_endpoints(servers: list[InternalMCPServer], website_urls: dict[str, for server in servers: base = rest_proxy_urls.get(server.name, "").rstrip("/") if server.name in website_urls and base and server.mcp_url.startswith(base + "/"): - endpoints[server.name] = server.mcp_url[len(base):] + endpoints[server.name] = _url_path(server.mcp_url[len(base):]) return endpoints def site_reserved_paths(card: dict) -> frozenset[str]: """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its - methods'; each one a path of its own, not the site's root.""" - paths = [card.get("url")] - paths += [i.get("url") for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)] + methods'; each one a path of its own, not the site's root, read from a full URL or one with a query as well as from + a bare path. A full URL's path is reserved whatever its host, since a site is reached under names it never sees in + advance.""" + urls = [card.get("url")] + urls += [i.get("url") for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)] for ext in (card.get("capabilities") or {}).get("extensions") or []: params = ext.get("params") if isinstance(ext, dict) else None if not isinstance(params, dict): continue - paths += [endpoint for *_, endpoint in operations(params)] - paths.append(params.get("endpoint")) - return frozenset(_normalized(p) for p in paths if isinstance(p, str) and p.startswith("/") and _normalized(p) != "/") + urls += [endpoint for *_, endpoint in operations(params)] + urls.append(params.get("endpoint")) + paths = (_url_path(url) for url in urls if isinstance(url, str)) + return frozenset(_normalized(p) for p in paths if p.startswith("/") and _normalized(p) != "/") + + +def _url_path(url: str) -> str: + """The path of ``url``, a bare path or a full URL, without its query or fragment; "" for one that has none.""" + try: + return urlsplit(url).path + except ValueError: + return "" def site_request_path(request: HTTPConnection) -> str | None: diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index 6aa9f17e0..b56c280ca 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -20,13 +20,14 @@ CARD = "/.well-known/agent-env.json" -def test_a_sites_mcp_interface_is_one_of_its_reserved_paths(): - card = {"url": "/agentenv", "additionalInterfaces": [{"url": "/mcp", "transport": "mcp"}, {"transport": "x"}]} +@pytest.mark.parametrize("declared", ["/mcp", "http://shop:8000/mcp", "/mcp?transport=http", "http://shop:8000/mcp/#x"]) +def test_a_sites_mcp_interface_is_one_of_its_reserved_paths_however_its_card_writes_it(declared): + card = {"url": "/agentenv", "additionalInterfaces": [{"url": declared, "transport": "mcp"}, {"transport": "x"}]} assert site_reserved_paths(card) == frozenset({"/agentenv", "/mcp"}) def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): - servers = [InternalMCPServer("shop", "http://shop-backend:8000/tools/mcp"), + servers = [InternalMCPServer("shop", "http://shop-backend:8000/tools/mcp?transport=http"), InternalMCPServer("slack", "http://slack:18765/mcp"), InternalMCPServer("odd", "http://elsewhere:9/mcp")] sites = {"shop": "http://shop-backend:8000", "odd": "http://odd-backend:8000"} @@ -40,6 +41,8 @@ async def test_the_sites_whose_cards_declare_an_mcp_interface_are_internal_serve "shop": {"additionalInterfaces": [{"url": "/tools/mcp", "transport": "mcp"}]}, "mail": {"additionalInterfaces": []}, "other": {"additionalInterfaces": [{"url": "/rpc", "transport": "grpc"}]}, + "full": {"additionalInterfaces": [{"url": "http://full:8000/tools/mcp", "transport": "mcp"}]}, + "away": {"additionalInterfaces": [{"url": "http://elsewhere:8000/mcp", "transport": "mcp"}]}, } def site(request: httpx.Request) -> httpx.Response: @@ -59,11 +62,13 @@ def site(request: httpx.Request) -> httpx.Response: real = httpx.AsyncClient monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) monkeypatch.setattr(entrypoint_module, "_INTERNAL_SERVER_POLL_S", 0) - sites = {name: f"http://{name}:8000/" for name in ("shop", "mail", "other", "late", "nocard", "html", "slack")} + sites = {name: f"http://{name}:8000/" for name in ("shop", "mail", "other", "late", "nocard", "html", "full", "away", + "slack")} found = await website_mcp_servers(sites, taken=frozenset({"slack"})) - assert found == [InternalMCPServer("shop", "http://shop:8000/tools/mcp"), InternalMCPServer("late", "http://late:8000/mcp")] + assert found == [InternalMCPServer("shop", "http://shop:8000/tools/mcp"), InternalMCPServer("late", "http://late:8000/mcp"), + InternalMCPServer("full", "http://full:8000/tools/mcp")] assert "slack" not in calls and calls["late"] == 2 diff --git a/tst/unit/env/gateway/website_reserved_paths_test.py b/tst/unit/env/gateway/website_reserved_paths_test.py index 306a31d76..b5299a29e 100644 --- a/tst/unit/env/gateway/website_reserved_paths_test.py +++ b/tst/unit/env/gateway/website_reserved_paths_test.py @@ -62,7 +62,9 @@ async def _streamed(): def test_a_sites_reserved_paths_are_the_ones_its_card_advertises_each_a_path_of_its_own(): assert site_reserved_paths(_CARD) == {"/agentenv", "/agentenv/ext/website/ready", "/grade", "/grading/now", "/hooks"} - assert site_reserved_paths({"name": "x", "url": "http://elsewhere/agentenv"}) == frozenset() + # a full URL's path is reserved whatever its host: the site is reached under names it never sees in advance + assert site_reserved_paths({"name": "x", "url": "http://elsewhere/agentenv?v=1"}) == frozenset({"/agentenv"}) + assert site_reserved_paths({"name": "x", "url": "http://elsewhere"}) == frozenset() reserved = site_reserved_paths(_CARD) for path in ("/grade", "/grade/", "/x/../grade", "/grading/now", "/hooks/a"): assert reserved_site_path(path, reserved), path From 31b695a65b0912b0140f320753ae7f1e3412aa06 Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 15:22:53 -0800 Subject: [PATCH 12/13] fix(website): a site's MCP endpoint keeps the site's own spelling, and the /svc role stamp matches it by address A card may write its own server differently from the gateway's base (https://shop.test:443/mcp beside https://shop.test). Discovery now returns the endpoint under the base's own spelling, and site_mcp_endpoints compares parsed scheme, host and port rather than a string prefix, so a call through /svc// to that endpoint still gets the gateway's role in its _meta. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/gateway/entrypoint.py | 7 +++++-- src/agent_env/env/gateway/gateway.py | 17 +++++++++++++++-- tst/unit/env/gateway/website_mcp_test.py | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index a588acdce..0b651062b 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -131,7 +131,8 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro def _site_mcp_url(base: str, declared: str) -> str | None: """Where the site at ``base`` serves the MCP endpoint its card declares at ``declared``: a path joined under the - site's address, or a full URL taken as it is when it names that same server; None for another server.""" + site's address, or a full URL's path and query under it when the URL names that same server, so the URL keeps the + address's own spelling that the gateway's /svc routes use; None for another server.""" if declared.startswith("/"): return f"{base}{declared}" try: @@ -140,7 +141,9 @@ def _site_mcp_url(base: str, declared: str) -> str | None: own.scheme, own.hostname, own.port or _DEFAULT_PORTS.get(own.scheme)) except ValueError: return None - return given.geturl() if same and given.path.startswith("/") else None + if not (same and given.path.startswith("/")): + return None + return f"{base}{given.path}" + (f"?{given.query}" if given.query else "") async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 75c1e91c5..5068bf7b9 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -1678,11 +1678,24 @@ def site_mcp_endpoints(servers: list[InternalMCPServer], website_urls: dict[str, endpoints = {} for server in servers: base = rest_proxy_urls.get(server.name, "").rstrip("/") - if server.name in website_urls and base and server.mcp_url.startswith(base + "/"): - endpoints[server.name] = _url_path(server.mcp_url[len(base):]) + if server.name not in website_urls or not base: + continue + try: + given, own = urlsplit(server.mcp_url), urlsplit(base) + same = _address(given) == _address(own) + except ValueError: + continue + under = own.path.rstrip("/") + if same and (given.path == under or given.path.startswith(under + "/")): + endpoints[server.name] = given.path[len(under):] or "/" return endpoints +def _address(url) -> tuple: + """A split URL's scheme, host and port, its scheme's default port when it names none.""" + return url.scheme, url.hostname, url.port or {"http": 80, "https": 443}.get(url.scheme) + + def site_reserved_paths(card: dict) -> frozenset[str]: """The paths a site's card advertises for agentenv, which its route never reaches: its ``url``, every interface's, such as the MCP endpoint of a site that is an MCP server too, and every extension's endpoint and each of its diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index b56c280ca..930bfa494 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -32,6 +32,23 @@ def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): InternalMCPServer("odd", "http://elsewhere:9/mcp")] sites = {"shop": "http://shop-backend:8000", "odd": "http://odd-backend:8000"} assert site_mcp_endpoints(servers, sites, {**sites, "mcp-slack": "http://slack:18765"}) == {"shop": "/tools/mcp"} + spelled = [InternalMCPServer("tls", "https://TLS.test:443/mcp"), InternalMCPServer("deep", "http://d:80/base/mcp")] + bases = {"tls": "https://tls.test", "deep": "http://d/base/"} + assert site_mcp_endpoints(spelled, bases, bases) == {"tls": "/mcp", "deep": "/mcp"} + + +@pytest.mark.parametrize(("base", "declared", "mcp_url"), [ + ("https://shop.test", "https://shop.test:443/mcp", "https://shop.test/mcp"), + ("https://shop.test", "https://SHOP.test/mcp?transport=http", "https://shop.test/mcp?transport=http"), + ("http://shop:8000", "/mcp", "http://shop:8000/mcp"), + ("http://shop:8000", "http://shop:8001/mcp", None), + ("http://shop:8000", "https://shop:8000/mcp", None), +]) +def test_a_declared_mcp_url_on_the_sites_server_keeps_the_sites_own_spelling(base, declared, mcp_url): + assert entrypoint_module._site_mcp_url(base, declared) == mcp_url + if mcp_url is not None: + servers = [InternalMCPServer("shop", mcp_url)] + assert site_mcp_endpoints(servers, {"shop": base}, {"shop": base}) == {"shop": "/mcp"} @pytest.mark.asyncio From 3d9e4aa404cc36f532e307093d3f2693083e615b Mon Sep 17 00:00:00 2001 From: William Loo Date: Thu, 5 Nov 2026 15:33:44 -0800 Subject: [PATCH 13/13] fix(website): a full MCP URL on a site behind a path prefix keeps its own path A card's full URL already names its path from the server's root, so the gateway keeps that path on the site's scheme and host instead of joining it under the site's base path, which a prefixed base doubled. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/env/gateway/entrypoint.py | 7 ++++--- tst/unit/env/gateway/website_mcp_test.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/agent_env/env/gateway/entrypoint.py b/src/agent_env/env/gateway/entrypoint.py index 0b651062b..9c0e320b4 100644 --- a/src/agent_env/env/gateway/entrypoint.py +++ b/src/agent_env/env/gateway/entrypoint.py @@ -131,8 +131,9 @@ async def website_mcp_servers(sites: dict[str, str], taken: frozenset[str] = fro def _site_mcp_url(base: str, declared: str) -> str | None: """Where the site at ``base`` serves the MCP endpoint its card declares at ``declared``: a path joined under the - site's address, or a full URL's path and query under it when the URL names that same server, so the URL keeps the - address's own spelling that the gateway's /svc routes use; None for another server.""" + site's address, as a card's paths are relative to where it was read, or, when a full URL names that same server, + the URL's own path and query on the address's scheme and host as the address spells them, which the gateway's /svc + routes use; None for another server.""" if declared.startswith("/"): return f"{base}{declared}" try: @@ -143,7 +144,7 @@ def _site_mcp_url(base: str, declared: str) -> str | None: return None if not (same and given.path.startswith("/")): return None - return f"{base}{given.path}" + (f"?{given.query}" if given.query else "") + return f"{own.scheme}://{own.netloc}{given.path}" + (f"?{given.query}" if given.query else "") async def _site_card(client: httpx.AsyncClient, name: str, base: str, deadline: float) -> dict | None: diff --git a/tst/unit/env/gateway/website_mcp_test.py b/tst/unit/env/gateway/website_mcp_test.py index 930bfa494..3245ae16b 100644 --- a/tst/unit/env/gateway/website_mcp_test.py +++ b/tst/unit/env/gateway/website_mcp_test.py @@ -43,6 +43,8 @@ def test_a_sites_mcp_endpoint_is_its_internal_servers_path_under_its_svc_base(): ("http://shop:8000", "/mcp", "http://shop:8000/mcp"), ("http://shop:8000", "http://shop:8001/mcp", None), ("http://shop:8000", "https://shop:8000/mcp", None), + ("http://shop/base", "http://shop/base/mcp", "http://shop/base/mcp"), + ("http://shop/base", "/mcp", "http://shop/base/mcp"), ]) def test_a_declared_mcp_url_on_the_sites_server_keeps_the_sites_own_spelling(base, declared, mcp_url): assert entrypoint_module._site_mcp_url(base, declared) == mcp_url @@ -51,6 +53,17 @@ def test_a_declared_mcp_url_on_the_sites_server_keeps_the_sites_own_spelling(bas assert site_mcp_endpoints(servers, {"shop": base}, {"shop": base}) == {"shop": "/mcp"} +@pytest.mark.asyncio +async def test_a_site_behind_a_path_prefix_is_found_at_the_full_url_its_card_declares(monkeypatch): + def site(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/base" + CARD + return httpx.Response(200, json={"additionalInterfaces": [{"url": "http://shop/base/mcp", "transport": "mcp"}]}) + + real = httpx.AsyncClient + monkeypatch.setattr(entrypoint_module.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(site), **kw)) + assert await website_mcp_servers({"shop": "http://shop/base"}) == [InternalMCPServer("shop", "http://shop/base/mcp")] + + @pytest.mark.asyncio async def test_the_sites_whose_cards_declare_an_mcp_interface_are_internal_servers(monkeypatch): calls: dict[str, int] = {}