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..f57746b 100644 --- a/src/mcp_server_appwrite/constants.py +++ b/src/mcp_server_appwrite/constants.py @@ -127,6 +127,37 @@ 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 +# 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" META_MEMBER = "meta.json" diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index eae08b0..1b5a96b 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,8 @@ SERVER_SUMMARY, SERVER_VERSION, SERVER_WEBSITE_URL, + WEBMCP_MAX_BODY_BYTES, + WEBMCP_ORIGINS, ) from .docs_search import DocsSearch from .events import protocol as events_protocol @@ -497,6 +502,46 @@ 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"}``. 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) + 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 + ) + 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) + + async def health_endpoint(request: Request) -> PlainTextResponse: return PlainTextResponse(f"appwrite-mcp {SERVER_VERSION} ok") @@ -639,6 +684,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..472ab08 100644 --- a/tests/e2e/test_tools.py +++ b/tests/e2e/test_tools.py @@ -132,6 +132,107 @@ 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", + "Origin": "https://appwrite.io", + }, + ) + 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, + ) + + # 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" + 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