Skip to content

Commit 7e589fd

Browse files
authored
Merge pull request #50 from appwrite/feat/opentelemetry-metrics
feat: add OpenTelemetry metrics for the hosted MCP server
2 parents d5956a2 + 56a5f43 commit 7e589fd

11 files changed

Lines changed: 1102 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,19 @@ Source lives in `src/mcp_server_appwrite/`:
3030
| `operator.py` | The compact "operator" surface — `appwrite_search_tools`, `appwrite_call_tool`, result/resource storage, write confirmation. |
3131
| `context.py` | `appwrite_get_context` — workspace summary (project, services, account/org for OAuth). |
3232
| `docs_search.py` | In-process semantic docs search (`appwrite_search_docs`) over a prebuilt index. |
33+
| `telemetry.py` | OpenTelemetry metrics layer (OTLP/HTTP). No-op unless an OTLP endpoint is configured and the transport is `http`. |
3334
| `data/` | Committed docs index artifact (`docs_index.npz`, `docs_index_meta.json`), shipped in the wheel/image. |
3435

3536
`scripts/build_docs_index.py` rebuilds the docs index (requires `OPENAI_API_KEY`).
3637

38+
### Telemetry (metrics)
39+
40+
`telemetry.py` emits OpenTelemetry metrics (prefixed `mcp.`) over OTLP/HTTP. It is a
41+
no-op unless the transport is `http` and `OTEL_EXPORTER_OTLP_ENDPOINT` is set, so
42+
`stdio` and unconfigured servers stay silent. In the cluster that endpoint is the
43+
Alloy collector, which adds the `deployment.*` labels and forwards upstream — the app
44+
needs no credentials. Dashboards live in the `dashboards` repo under `MCP/`.
45+
3746
### Tool surface (key design point)
3847

3948
The server boots in a compact workflow: the client sees up to 4 tools

compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ services:
1515
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
1616
DOCS_SEARCH_MIN_SCORE: ${DOCS_SEARCH_MIN_SCORE:-0.25}
1717
DOCS_SEARCH_LIMIT: ${DOCS_SEARCH_LIMIT:-5}
18+
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
1819
healthcheck:
1920
test:
2021
[

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ dependencies = [
1515
"pyjwt[crypto]>=2.9.0",
1616
"numpy>=2.0",
1717
"openai>=1.40",
18+
"opentelemetry-api>=1.27",
19+
"opentelemetry-sdk>=1.27",
20+
"opentelemetry-exporter-otlp-proto-http>=1.27",
1821
]
1922

2023
[project.optional-dependencies]

src/mcp_server_appwrite/auth.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from jwt import PyJWKClient
2626
from mcp.server.auth.provider import AccessToken, TokenVerifier
2727

28+
from . import telemetry
29+
2830
DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"
2931
DEFAULT_PROJECT_ID = "console"
3032

@@ -198,13 +200,15 @@ def _verify_sync(self, token: str) -> AccessToken | None:
198200
try:
199201
unverified = jwt.decode(token, options={"verify_signature": False})
200202
except jwt.PyJWTError:
203+
telemetry.record_auth(outcome="rejected", reason="malformed")
201204
return None
202205

203206
issuer = unverified.get("iss")
204207
try:
205208
metadata = authorization_server_metadata_sync()
206209
except Exception as exc:
207210
_log(f"Rejecting token: authorization server discovery failed ({exc}).")
211+
telemetry.record_auth(outcome="rejected", reason="discovery_failed")
208212
return None
209213

210214
expected_issuer = metadata["issuer"]
@@ -213,17 +217,20 @@ def _verify_sync(self, token: str) -> AccessToken | None:
213217
f"Rejecting token: issuer {issuer!r} does not match discovered "
214218
f"issuer {expected_issuer!r}."
215219
)
220+
telemetry.record_auth(outcome="rejected", reason="issuer_mismatch")
216221
return None
217222

218223
project_id = project_id_from_issuer(issuer)
219224
if not project_id:
220225
_log("Rejecting token: issuer is not an Appwrite OAuth issuer.")
226+
telemetry.record_auth(outcome="rejected", reason="issuer_mismatch")
221227
return None
222228
if project_id != configured_project_id():
223229
_log(
224230
f"Rejecting token: issuer project {project_id!r} is not the served "
225231
f"project {configured_project_id()!r}."
226232
)
233+
telemetry.record_auth(outcome="rejected", reason="project_mismatch")
227234
return None
228235

229236
try:
@@ -238,10 +245,12 @@ def _verify_sync(self, token: str) -> AccessToken | None:
238245
)
239246
except jwt.PyJWTError as exc:
240247
_log(f"Rejecting token: verification failed ({exc}).")
248+
telemetry.record_auth(outcome="rejected", reason="signature")
241249
return None
242250

243251
expected_resource = canonical_resource()
244252
if not self._audience_ok(claims.get("aud"), expected_resource):
253+
telemetry.record_auth(outcome="rejected", reason="audience")
245254
return None
246255

247256
scope_claim = claims.get("scope") or claims.get("scp") or ""
@@ -276,9 +285,18 @@ def _audience_ok(self, aud, expected_resource: str) -> bool:
276285
return False
277286

278287
async def verify_token(self, token: str) -> AccessToken | None:
288+
start = time.monotonic()
279289
access_token = await anyio.to_thread.run_sync(self._verify_sync, token)
290+
duration = time.monotonic() - start
280291
if access_token is None:
292+
# The specific rejection reason was already counted in _verify_sync;
293+
# here we only attach the duration to the rejected outcome.
294+
telemetry.record_auth(outcome="rejected", duration_s=duration, count=False)
281295
return None
282296
if access_token.expires_at and access_token.expires_at < int(time.time()):
297+
telemetry.record_auth(
298+
outcome="rejected", reason="expired", duration_s=duration
299+
)
283300
return None
301+
telemetry.record_auth(outcome="success", duration_s=duration)
284302
return access_token

src/mcp_server_appwrite/docs_search.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515

1616
import json
1717
import os
18+
import time
1819
from pathlib import Path
1920
from typing import Any, Callable
2021

2122
import mcp.types as types
2223

24+
from . import telemetry
25+
2326
ToolContent = types.TextContent | types.ImageContent | types.EmbeddedResource
2427

2528
TOOL_NAME = "appwrite_search_docs"
@@ -155,7 +158,12 @@ def search(self, arguments: dict[str, Any] | None) -> list[ToolContent]:
155158
)
156159

157160
limit = _clamp_limit(arguments.get("limit"), self._default_limit)
158-
results = self._rank(query, limit)
161+
results, embedding_duration_s = self._rank(query, limit)
162+
telemetry.record_search_docs(
163+
outcome="success",
164+
match_count=len(results),
165+
embedding_duration_s=embedding_duration_s,
166+
)
159167

160168
if not results:
161169
return [
@@ -173,13 +181,16 @@ def search(self, arguments: dict[str, Any] | None) -> list[ToolContent]:
173181
)
174182
]
175183

176-
def _rank(self, query: str, limit: int) -> list[dict[str, Any]]:
184+
def _rank(self, query: str, limit: int) -> tuple[list[dict[str, Any]], float]:
185+
"""Return the ranked pages and the embedding call's duration in seconds."""
177186
import numpy as np
178187

188+
embed_start = time.monotonic()
179189
embedding = np.asarray(self._embedder(query), dtype=np.float32)
190+
embedding_duration_s = time.monotonic() - embed_start
180191
norm = float(np.linalg.norm(embedding))
181192
if norm == 0.0:
182-
return []
193+
return [], embedding_duration_s
183194
embedding /= norm
184195

185196
scores = self._vectors @ embedding # cosine similarity (both normalized)
@@ -207,4 +218,4 @@ def _rank(self, query: str, limit: int) -> list[dict[str, Any]]:
207218
"content": page.get("content", ""),
208219
}
209220
)
210-
return results
221+
return results, embedding_duration_s

src/mcp_server_appwrite/http_app.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from starlette.routing import Route
3232
from starlette.types import Receive, Scope, Send
3333

34+
from . import telemetry
3435
from .auth import (
3536
AppwriteTokenVerifier,
3637
protected_resource_metadata,
@@ -111,6 +112,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
111112

112113
user = scope.get("user")
113114
if not isinstance(user, AuthenticatedUser):
115+
# A presented-but-invalid token was already counted (with its specific
116+
# reason) by the token verifier. Only count the no-token case here so we
117+
# don't double-count rejections.
118+
if not _has_authorization_header(scope):
119+
telemetry.record_auth(outcome="rejected", reason="missing")
114120
await _send_401(send)
115121
return
116122

@@ -122,6 +128,13 @@ async def _preflight(self, send: Send) -> None:
122128
await send({"type": "http.response.body", "body": b""})
123129

124130

131+
def _has_authorization_header(scope: Scope) -> bool:
132+
for name, _value in scope.get("headers", []):
133+
if name == b"authorization":
134+
return True
135+
return False
136+
137+
125138
async def protected_resource_metadata_endpoint(request: Request) -> JSONResponse:
126139
metadata = await protected_resource_metadata()
127140
return JSONResponse(metadata, headers=_CORS_HEADERS)
@@ -132,6 +145,7 @@ async def health_endpoint(request: Request) -> PlainTextResponse:
132145

133146

134147
def build_app() -> Starlette:
148+
telemetry.init_telemetry("http", SERVER_VERSION)
135149
tools_manager = build_catalog_tools_manager()
136150
operator = build_operator(tools_manager)
137151
server = build_mcp_server(operator, transport="http")

src/mcp_server_appwrite/operator.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import re
5+
import time
56
from collections import OrderedDict
67
from dataclasses import dataclass
78
from typing import Any, Callable
@@ -11,6 +12,7 @@
1112
import mcp.types as types
1213
from mcp.server.lowlevel.helper_types import ReadResourceContents
1314

15+
from . import telemetry
1416
from .docs_search import DocsSearch
1517
from .tool_manager import ToolManager
1618

@@ -253,6 +255,19 @@ def has_public_tool(self, name: str) -> bool:
253255

254256
def execute_public_tool(
255257
self, name: str, arguments: dict[str, Any] | None
258+
) -> list[ToolContent]:
259+
start = time.monotonic()
260+
outcome = "success"
261+
try:
262+
return self._dispatch_public_tool(name, arguments)
263+
except Exception:
264+
outcome = "error"
265+
raise
266+
finally:
267+
telemetry.record_tool_call(name, outcome, time.monotonic() - start)
268+
269+
def _dispatch_public_tool(
270+
self, name: str, arguments: dict[str, Any] | None
256271
) -> list[ToolContent]:
257272
if name == "appwrite_get_context":
258273
return self._get_context(arguments or {})
@@ -269,6 +284,15 @@ def _get_context(self, arguments: dict[str, Any]) -> list[ToolContent]:
269284
if self._context_provider is None:
270285
raise RuntimeError("Appwrite context provider is not configured.")
271286
context = self._context_provider(arguments)
287+
mode = "unknown"
288+
if isinstance(context, dict):
289+
connection = context.get("connection")
290+
if isinstance(connection, dict):
291+
mode = str(connection.get("mode", "unknown"))
292+
include_services = bool(
293+
arguments.get("include_services", arguments.get("includeServices", True))
294+
)
295+
telemetry.record_context_request(mode=mode, include_services=include_services)
272296
return [types.TextContent(type="text", text=json.dumps(context, indent=2))]
273297

274298
def list_resources(self) -> list[types.Resource]:
@@ -381,6 +405,9 @@ def _search_tools(self, arguments: dict[str, Any]) -> list[ToolContent]:
381405
include_mutating=include_mutating,
382406
limit=_normalize_limit(arguments.get("limit"), self._search_limit),
383407
)
408+
telemetry.record_search_tools(
409+
include_mutating=include_mutating, match_count=len(matches)
410+
)
384411

385412
lines: list[str] = []
386413
if not matches:
@@ -426,10 +453,13 @@ def _call_hidden_tool(self, raw_arguments: dict[str, Any]) -> list[ToolContent]:
426453
confirm_write = bool(
427454
raw_arguments.get("confirm_write", raw_arguments.get("confirmWrite", False))
428455
)
429-
if entry.classification != "read" and not confirm_write:
430-
raise RuntimeError(
431-
f"Tool {tool_name} is {entry.classification}. Re-run appwrite_call_tool with confirm_write=true if you intend to mutate Appwrite state."
432-
)
456+
if entry.classification != "read":
457+
if not confirm_write:
458+
telemetry.record_write_confirmation(entry.classification, "blocked")
459+
raise RuntimeError(
460+
f"Tool {tool_name} is {entry.classification}. Re-run appwrite_call_tool with confirm_write=true if you intend to mutate Appwrite state."
461+
)
462+
telemetry.record_write_confirmation(entry.classification, "confirmed")
433463

434464
project_id = raw_arguments.get("project_id", raw_arguments.get("projectId"))
435465
organization_id = raw_arguments.get(
@@ -454,6 +484,7 @@ def _preview_or_store_result(
454484
stored_result = self._result_store.save(
455485
tool_name, content, _serialize_content(content)
456486
)
487+
telemetry.record_result_stored(tool_name)
457488
preview = full_text[: self._preview_threshold]
458489
return [
459490
types.TextContent(
@@ -468,6 +499,7 @@ def _preview_or_store_result(
468499
stored_result = self._result_store.save(
469500
tool_name, content, _serialize_content(content)
470501
)
502+
telemetry.record_result_stored(tool_name)
471503
summary = ", ".join(_summarize_content_item(item) for item in content)
472504
return [
473505
types.TextContent(

0 commit comments

Comments
 (0)