From 379cd0a61e344d2f279bdf1c7d8071c9cfaf7708 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 12 Nov 2026 15:56:07 +0000 Subject: [PATCH 1/2] feat(telemetry): count appwrite.io WebMCP tool calls appwrite.io registers WebMCP tools that browser agents call in the page, so the server never sees those calls. The page now reports each one to POST /webmcp/events as a text/plain beacon, and the server records mcp.webmcp.calls (tool, status, error code) and mcp.webmcp.duration for the MCP dashboard. Labels are allowlisted, so a caller cannot create new series. --- AGENTS.md | 2 +- src/mcp_server_appwrite/constants.py | 29 ++++++++++ src/mcp_server_appwrite/http_app.py | 47 ++++++++++++++++ src/mcp_server_appwrite/telemetry.py | 41 +++++++++++++- tests/e2e/test_tools.py | 81 ++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa4e757..95d916f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Source lives in `src/mcp_server_appwrite/`: | File | Responsibility | | --- | --- | | `__main__.py` / `server.py` | Entry point, CLI args, transport selection (`--transport stdio\|http`), service registration, low-level MCP server. | -| `http_app.py` | Hosted Streamable-HTTP transport: `/` plus the `/mcp` alias, RFC 9728 protected-resource metadata, `/healthz`. | +| `http_app.py` | Hosted Streamable-HTTP transport: `/` plus the `/mcp` alias, RFC 9728 protected-resource metadata, `/healthz`, and the `/webmcp/events` beacon appwrite.io uses to report WebMCP tool calls. | | `auth.py` | OAuth 2.1 resource-server layer — bearer-token validation against the project's Appwrite authorization server. | | `flags.py` | Central registry of tester/testing flags (CLI arg + env var per flag). Enabling and testing them is documented in [docs/flags.md](docs/flags.md). | | `service.py` | `Service` base class: introspects an Appwrite SDK service and turns its methods into MCP tool definitions. | diff --git a/src/mcp_server_appwrite/constants.py b/src/mcp_server_appwrite/constants.py index 4f33b7d..f540f1e 100644 --- a/src/mcp_server_appwrite/constants.py +++ b/src/mcp_server_appwrite/constants.py @@ -127,6 +127,35 @@ def _resolve_server_version() -> str: DOCS_MAX_QUERY_LENGTH = 500 DOCS_MAX_BODY_BYTES = 16 * 1024 +# appwrite.io's in-page WebMCP tools (appwrite/appwrite apps/console +# src/lib/webmcp/site/tools.ts). The page reports each call to +# /webmcp/events; anything outside these sets is counted as "other", so a +# caller cannot mint new label values. +WEBMCP_TOOLS = frozenset( + { + "search_docs", + "get_doc_page", + "get_pricing", + "get_product_overview", + "list_changelog", + "search_community_threads", + "navigate_to_page", + "open_sign_up", + } +) +WEBMCP_STATUSES = frozenset({"success", "error", "exception"}) +WEBMCP_ERROR_CODES = frozenset( + { + "invalid_path", + "invalid_product", + "not_found", + "network_error", + "fetch_failed", + "search_failed", + } +) +WEBMCP_MAX_BODY_BYTES = 1024 + DATA_DIR = Path(__file__).parent / "data" INDEX_FILE = "docs_index.npz" META_MEMBER = "meta.json" diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index eae08b0..9cc42c0 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -19,6 +19,9 @@ server, whose clients have no user to sign in. Results and limits are fixed, so a call costs one small embedding. * ``/.well-known/openai-apps-challenge`` — public domain verification token. +* ``/webmcp/events`` — public beacon where appwrite.io reports each WebMCP tool + call a browser agent makes, counted as ``mcp.webmcp.*`` metrics with + allowlisted labels. * ``/appwrite/webhooks/{id}`` — MCP Events ingress for Appwrite webhooks, only with the events flag on. Unauthenticated by bearer token: the Appwrite signature authenticates it (see :mod:`.events.ingress`). @@ -90,6 +93,7 @@ SERVER_SUMMARY, SERVER_VERSION, SERVER_WEBSITE_URL, + WEBMCP_MAX_BODY_BYTES, ) from .docs_search import DocsSearch from .events import protocol as events_protocol @@ -497,6 +501,44 @@ async def endpoint(request: Request) -> JSONResponse: return endpoint +async def webmcp_events_endpoint(request: Request) -> Response: + """One WebMCP tool call a browser agent made on appwrite.io. The page sends + it with ``navigator.sendBeacon``: a text/plain body, so no CORS preflight. + Body: ``{"tool", "status", "code"?, "durationMs"}``. Labels are + allowlisted in ``telemetry.record_webmcp_call``.""" + headers = dict(CORS_HEADERS) + if request.method == "OPTIONS": + return Response(status_code=204, headers=headers) + raw = b"" + async for chunk in request.stream(): + raw += chunk + if len(raw) > WEBMCP_MAX_BODY_BYTES: + return JSONResponse( + {"error": "body_too_large"}, status_code=413, headers=headers + ) + try: + body = json.loads(raw) + tool = body["tool"] + status = body["status"] + code = body.get("code") + duration_ms = body["durationMs"] + if ( + not isinstance(tool, str) + or not isinstance(status, str) + or not (code is None or isinstance(code, str)) + or isinstance(duration_ms, bool) + or not isinstance(duration_ms, (int, float)) + or not 0 <= duration_ms <= 600_000 + ): + raise ValueError + except (ValueError, TypeError, KeyError): + return JSONResponse( + {"error": "invalid_event"}, status_code=400, headers=headers + ) + telemetry.record_webmcp_call(tool, status, code, duration_ms / 1000) + return Response(status_code=204, headers=headers) + + async def health_endpoint(request: Request) -> PlainTextResponse: return PlainTextResponse(f"appwrite-mcp {SERVER_VERSION} ok") @@ -639,6 +681,11 @@ async def lifespan(app: Starlette): Route("/favicon.svg", endpoint=favicon_svg_endpoint, methods=["GET"]), Route("/favicon.ico", endpoint=favicon_ico_endpoint, methods=["GET"]), Route("/healthz", endpoint=health_endpoint, methods=["GET"]), + Route( + "/webmcp/events", + endpoint=webmcp_events_endpoint, + methods=["POST", "OPTIONS"], + ), *([ingress.route] if ingress is not None else []), *( [ diff --git a/src/mcp_server_appwrite/telemetry.py b/src/mcp_server_appwrite/telemetry.py index b4b3a41..2dfa6a9 100644 --- a/src/mcp_server_appwrite/telemetry.py +++ b/src/mcp_server_appwrite/telemetry.py @@ -55,7 +55,13 @@ from contextvars import ContextVar from typing import Any, Iterable -from .constants import ACTIVE_WINDOW_SECONDS, KNOWN_MCP_CLIENTS +from .constants import ( + ACTIVE_WINDOW_SECONDS, + KNOWN_MCP_CLIENTS, + WEBMCP_ERROR_CODES, + WEBMCP_STATUSES, + WEBMCP_TOOLS, +) from .error_classification import ERROR_CATEGORIES, ErrorCategory _enabled = False @@ -325,6 +331,21 @@ def _build_instruments(meter: Any, transport: str, version: str) -> None: description="MCP Apps appwrite_view_write calls by action and status.", ) + _instruments["webmcp_calls"] = meter.create_counter( + "mcp.webmcp.calls", + unit="{call}", + description=( + "WebMCP tool calls browser agents make on appwrite.io, reported by " + "the page, by tool, status and error code." + ), + ) + _instruments["webmcp_duration"] = _histogram( + meter, + "mcp.webmcp.duration", + unit="s", + description="WebMCP tool duration in the browser, as the page reported it.", + ) + _instruments["token_usage"] = meter.create_counter( "mcp.token.usage", unit="{token}", @@ -749,6 +770,24 @@ def record_view_write(action: str, status: str) -> None: ) +def record_webmcp_call( + tool: str, status: str, code: str | None, duration_s: float +) -> None: + """One WebMCP tool call reported by appwrite.io. Values outside the + allowlists in ``constants`` are counted as ``other``.""" + tool_name = tool if tool in WEBMCP_TOOLS else "other" + status = status if status in WEBMCP_STATUSES else "other" + error_code = "" + if status == "error": + error_code = code if code in WEBMCP_ERROR_CODES else "other" + _safe_add( + "webmcp_calls", + 1, + {"tool_name": tool_name, "status": status, "error_code": error_code}, + ) + _safe_record("webmcp_duration", duration_s, {"tool_name": tool_name}) + + def record_hallucination(attempted_tool: Any) -> None: _safe_add( "tool_hallucination", diff --git a/tests/e2e/test_tools.py b/tests/e2e/test_tools.py index 9f2b7f1..8b85072 100644 --- a/tests/e2e/test_tools.py +++ b/tests/e2e/test_tools.py @@ -132,6 +132,87 @@ def test_rejects_invalid_queries_before_embedding_them(self): self.assertEqual(oversized.status_code, 413) +class WebMcpEventsRoute(unittest.TestCase): + """``POST /webmcp/events``, where appwrite.io reports each WebMCP tool call + a browser agent makes. The page sends a text/plain beacon, so the route + parses any content type.""" + + CALLS = "mcp.webmcp.calls" + + def test_counts_reported_calls_with_allowlisted_labels(self): + with Server({"APPWRITE_ENDPOINT": cloud.endpoint}) as server: + url = f"{server.url}/webmcp/events" + searched = collector().count( + self.CALLS, tool_name="search_docs", status="success" + ) + missing = collector().count( + self.CALLS, + tool_name="get_doc_page", + status="error", + error_code="not_found", + ) + invented = collector().count(self.CALLS, tool_name="other", status="other") + + for event in ( + {"tool": "search_docs", "status": "success", "durationMs": 12}, + { + "tool": "get_doc_page", + "status": "error", + "code": "not_found", + "durationMs": 40.5, + }, + {"tool": "made_up_tool", "status": "whatever", "durationMs": 1}, + ): + beacon = httpx.post( + url, + content=json.dumps(event), + headers={"Content-Type": "text/plain;charset=UTF-8"}, + ) + self.assertEqual(beacon.status_code, 204, event) + self.assertEqual(beacon.headers["access-control-allow-origin"], "*") + + self.assertEqual( + collector().wait( + self.CALLS, searched + 1, tool_name="search_docs", status="success" + ), + searched + 1, + ) + self.assertEqual( + collector().wait( + self.CALLS, + missing + 1, + tool_name="get_doc_page", + status="error", + error_code="not_found", + ), + missing + 1, + ) + self.assertEqual( + collector().wait( + self.CALLS, invented + 1, tool_name="other", status="other" + ), + invented + 1, + ) + + def test_rejects_malformed_events(self): + with Server({"APPWRITE_ENDPOINT": cloud.endpoint}) as server: + url = f"{server.url}/webmcp/events" + for invalid in ( + {"tool": "search_docs", "status": "success"}, + {"tool": "search_docs", "status": "success", "durationMs": -1}, + {"tool": "search_docs", "status": "success", "durationMs": True}, + {"tool": 1, "status": "success", "durationMs": 1}, + {"tool": "search_docs", "status": "error", "code": 1, "durationMs": 1}, + ["search_docs"], + ): + refused = httpx.post(url, content=json.dumps(invalid)) + self.assertEqual(refused.status_code, 400, invalid) + + self.assertEqual(httpx.post(url, content="nope").status_code, 400) + self.assertEqual(httpx.post(url, content=b"x" * 1025).status_code, 413) + self.assertEqual(httpx.options(url).status_code, 204) + + class ToolsFlow(unittest.TestCase): server: Server client: Client From 3b4e61cd6818e9e719873918b8c33007f3f8b72e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 12 Nov 2026 15:57:00 +0000 Subject: [PATCH 2/2] feat(telemetry): count only WebMCP beacons from appwrite.io Preview, staging and local builds of the site run the same reporting code; their beacons are accepted but not counted, so the dashboard shows production traffic only. --- src/mcp_server_appwrite/constants.py | 2 ++ src/mcp_server_appwrite/http_app.py | 9 ++++++--- tests/e2e/test_tools.py | 22 +++++++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/mcp_server_appwrite/constants.py b/src/mcp_server_appwrite/constants.py index f540f1e..f57746b 100644 --- a/src/mcp_server_appwrite/constants.py +++ b/src/mcp_server_appwrite/constants.py @@ -155,6 +155,8 @@ def _resolve_server_version() -> str: } ) WEBMCP_MAX_BODY_BYTES = 1024 +# Beacons from previews, staging and local builds are accepted but not counted. +WEBMCP_ORIGINS = frozenset({"https://appwrite.io"}) DATA_DIR = Path(__file__).parent / "data" INDEX_FILE = "docs_index.npz" diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index 9cc42c0..1b5a96b 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -94,6 +94,7 @@ SERVER_VERSION, SERVER_WEBSITE_URL, WEBMCP_MAX_BODY_BYTES, + WEBMCP_ORIGINS, ) from .docs_search import DocsSearch from .events import protocol as events_protocol @@ -504,8 +505,9 @@ async def endpoint(request: Request) -> JSONResponse: async def webmcp_events_endpoint(request: Request) -> Response: """One WebMCP tool call a browser agent made on appwrite.io. The page sends it with ``navigator.sendBeacon``: a text/plain body, so no CORS preflight. - Body: ``{"tool", "status", "code"?, "durationMs"}``. Labels are - allowlisted in ``telemetry.record_webmcp_call``.""" + Body: ``{"tool", "status", "code"?, "durationMs"}``. Only calls from + an origin in ``WEBMCP_ORIGINS`` are counted; labels are allowlisted in + ``telemetry.record_webmcp_call``.""" headers = dict(CORS_HEADERS) if request.method == "OPTIONS": return Response(status_code=204, headers=headers) @@ -535,7 +537,8 @@ async def webmcp_events_endpoint(request: Request) -> Response: return JSONResponse( {"error": "invalid_event"}, status_code=400, headers=headers ) - telemetry.record_webmcp_call(tool, status, code, duration_ms / 1000) + if request.headers.get("origin") in WEBMCP_ORIGINS: + telemetry.record_webmcp_call(tool, status, code, duration_ms / 1000) return Response(status_code=204, headers=headers) diff --git a/tests/e2e/test_tools.py b/tests/e2e/test_tools.py index 8b85072..472ab08 100644 --- a/tests/e2e/test_tools.py +++ b/tests/e2e/test_tools.py @@ -166,7 +166,10 @@ def test_counts_reported_calls_with_allowlisted_labels(self): beacon = httpx.post( url, content=json.dumps(event), - headers={"Content-Type": "text/plain;charset=UTF-8"}, + headers={ + "Content-Type": "text/plain;charset=UTF-8", + "Origin": "https://appwrite.io", + }, ) self.assertEqual(beacon.status_code, 204, event) self.assertEqual(beacon.headers["access-control-allow-origin"], "*") @@ -194,6 +197,23 @@ def test_counts_reported_calls_with_allowlisted_labels(self): invented + 1, ) + # Previews, staging and local builds report too, but are not counted. + for origin in ("https://stage.appwrite.io", None): + ignored = httpx.post( + url, + content=json.dumps( + {"tool": "search_docs", "status": "success", "durationMs": 5} + ), + headers={"Origin": origin} if origin else {}, + ) + self.assertEqual(ignored.status_code, 204, origin) + self.assertEqual( + collector().count( + self.CALLS, tool_name="search_docs", status="success" + ), + searched + 1, + ) + def test_rejects_malformed_events(self): with Server({"APPWRITE_ENDPOINT": cloud.endpoint}) as server: url = f"{server.url}/webmcp/events"