Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
31 changes: 31 additions & 0 deletions src/mcp_server_appwrite/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 50 additions & 0 deletions src/mcp_server_appwrite/http_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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 []),
*(
[
Expand Down
41 changes: 40 additions & 1 deletion src/mcp_server_appwrite/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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",
Expand Down
101 changes: 101 additions & 0 deletions tests/e2e/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading