Skip to content
Merged
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
17 changes: 0 additions & 17 deletions src/mcp_server_appwrite/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
from jwt import PyJWKClient
from mcp.server.auth.provider import AccessToken, TokenVerifier

from . import telemetry
from .constants import (
CACHE_TTL_SECONDS,
DEFAULT_ENDPOINT,
Expand Down Expand Up @@ -361,15 +360,13 @@ def _verify_sync(self, token: str) -> AccessToken | None:
try:
unverified = jwt.decode(token, options={"verify_signature": False})
except jwt.PyJWTError:
telemetry.record_auth(outcome="rejected", reason="malformed")
return None

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

expected_issuer = metadata["issuer"]
Expand All @@ -378,20 +375,17 @@ def _verify_sync(self, token: str) -> AccessToken | None:
f"Rejecting token: issuer {issuer!r} does not match discovered "
f"issuer {expected_issuer!r}."
)
telemetry.record_auth(outcome="rejected", reason="issuer_mismatch")
return None

project_id = project_id_from_issuer(issuer)
if not project_id:
_log("Rejecting token: issuer is not an Appwrite OAuth issuer.")
telemetry.record_auth(outcome="rejected", reason="issuer_mismatch")
return None
if project_id != configured_project_id():
_log(
f"Rejecting token: issuer project {project_id!r} is not the served "
f"project {configured_project_id()!r}."
)
telemetry.record_auth(outcome="rejected", reason="project_mismatch")
return None

try:
Expand All @@ -406,12 +400,10 @@ def _verify_sync(self, token: str) -> AccessToken | None:
)
except jwt.PyJWTError as exc:
_log(f"Rejecting token: verification failed ({exc}).")
telemetry.record_auth(outcome="rejected", reason="signature")
return None

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

scope_claim = claims.get("scope") or claims.get("scp") or ""
Expand Down Expand Up @@ -446,18 +438,9 @@ def _audience_ok(self, aud, expected_resource: str) -> bool:
return False

async def verify_token(self, token: str) -> AccessToken | None:
start = time.monotonic()
access_token = await to_thread.run_sync(self._verify_sync, token)
duration = time.monotonic() - start
if access_token is None:
# The specific rejection reason was already counted in _verify_sync;
# here we only attach the duration to the rejected outcome.
telemetry.record_auth(outcome="rejected", duration_s=duration, count=False)
return None
if access_token.expires_at and access_token.expires_at < int(time.time()):
telemetry.record_auth(
outcome="rejected", reason="expired", duration_s=duration
)
return None
telemetry.record_auth(outcome="success", duration_s=duration)
return access_token
51 changes: 51 additions & 0 deletions src/mcp_server_appwrite/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,54 @@ def _resolve_server_version() -> str:
# --- telemetry ------------------------------------------------------------

ACTIVE_WINDOW_SECONDS = 300.0 # rolling window for "active users/clients" gauges

# Known MCP clients (normalized: lowercase, whitespace -> "-"). Client names are
# client-controlled input; anything not matching becomes "other" to bound the
# client_id label cardinality.
KNOWN_MCP_CLIENTS = (
"5ire",
"amp",
"bolt",
"chatgpt",
"cherry-studio",
"claude",
"claude-ai",
"claude-code",
"claude-desktop",
"cline",
"codex",
"codex-cli",
"continue",
"copilot",
"crush",
"cursor",
"deepchat",
"fast-agent",
"gemini",
"gemini-cli",
"github-copilot",
"goose",
"jetbrains",
"kilo-code",
"kiro",
"langchain",
"librechat",
"lm-studio",
"mcp-inspector",
"mcphub",
"n8n",
"opencode",
"openai",
"raycast",
"roo-cline",
"roo-code",
"tome",
"trae",
"visual-studio-code",
"void",
"vscode",
"warp",
"windsurf",
"witsy",
"zed",
)
9 changes: 1 addition & 8 deletions src/mcp_server_appwrite/docs_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@

import mcp.types as types

from . import telemetry
from .constants import (
DATA_DIR,
DOCS_DEFAULT_LIMIT,
Expand Down Expand Up @@ -162,18 +161,12 @@ def search(self, arguments: dict[str, Any] | None) -> list[ToolContent]:

limit = _clamp_limit(arguments.get("limit"), self._default_limit)
try:
results, embedding_duration_s = self._rank(query, limit)
results, _embedding_duration_s = self._rank(query, limit)
except Exception as exc:
telemetry.record_search_docs(outcome="error", match_count=0)
raise RuntimeError(
"Documentation search embedding request failed. "
"Check OPENAI_API_KEY and outbound connectivity to OpenAI."
) from exc
telemetry.record_search_docs(
outcome="success",
match_count=len(results),
embedding_duration_s=embedding_duration_s,
)

if not results:
return [
Expand Down
61 changes: 5 additions & 56 deletions src/mcp_server_appwrite/http_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import logging
import re
import sys
import time
from importlib.resources import files

from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
Expand Down Expand Up @@ -155,16 +154,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:

user = scope.get("user")
if not isinstance(user, AuthenticatedUser):
# A presented-but-invalid token was already counted (with its specific
# reason) by the token verifier. Only count the no-token case here so we
# don't double-count rejections.
if not _has_authorization_header(scope):
telemetry.record_auth(outcome="rejected", reason="missing")
else:
# A token was presented and rejected — this session never
# completes its MCP handshake. Unauthenticated discovery probes
# (no token at all) are part of the normal OAuth flow and are
# not counted as handshake failures.
# A token was presented and rejected — this session never completes
# its MCP handshake. Unauthenticated discovery probes (no token at
# all) are part of the normal OAuth flow and are not counted as
# handshake failures.
if _has_authorization_header(scope):
telemetry.record_handshake_failure(reason="invalid_token")
await _send_401(send)
return
Expand Down Expand Up @@ -280,7 +274,6 @@ def _bind_identity(self, scope: Scope, body: bytes) -> None:
session_id=next(_connection_counter),
client_name=client_info.get("name")
or _client_from_user_agent(_header(scope, b"user-agent")),
client_version=client_info.get("version"),
protocol_version=params.get("protocolVersion"),
subject=subject,
)
Expand All @@ -292,49 +285,6 @@ def _bind_identity(self, scope: Scope, body: bytes) -> None:
)


_KNOWN_HANDLERS = {
"/mcp",
"/healthz",
"/favicon.svg",
"/favicon.ico",
"/.well-known/oauth-protected-resource/mcp",
}


class HTTPMetricsMiddleware:
"""Outermost ASGI wrapper emitting ``http.requests`` / ``http.request.duration``.

The handler label is the raw path for the fixed route table above and
``other`` for everything else, so unmatched scanner paths cannot explode
label cardinality."""

def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http": # pragma: no cover
await self.app(scope, receive, send)
return

path = scope.get("path", "")
handler = path if path in _KNOWN_HANDLERS else "other"
method = scope.get("method", "GET")
start = time.monotonic()
status_holder = {"status": 500}

async def send_wrapper(message) -> None:
if message["type"] == "http.response.start":
status_holder["status"] = message["status"]
await send(message)

try:
await self.app(scope, receive, send_wrapper)
finally:
telemetry.record_http_request(
handler, method, status_holder["status"], time.monotonic() - start
)


async def protected_resource_metadata_endpoint(request: Request) -> JSONResponse:
metadata = await protected_resource_metadata()
headers = {**CORS_HEADERS, "Link": _icon_link_header()}
Expand Down Expand Up @@ -400,7 +350,6 @@ async def lifespan(app: Starlette):
]

middleware = [
Middleware(HTTPMetricsMiddleware),
Middleware(
AuthenticationMiddleware, backend=BearerAuthBackend(AppwriteTokenVerifier())
),
Expand Down
23 changes: 4 additions & 19 deletions src/mcp_server_appwrite/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,15 +314,6 @@ def _get_context(self, arguments: dict[str, Any]) -> list[ToolContent]:
if self._context_provider is None:
raise RuntimeError("Appwrite context provider is not configured.")
context = self._context_provider(arguments)
mode = "unknown"
if isinstance(context, dict):
connection = context.get("connection")
if isinstance(connection, dict):
mode = str(connection.get("mode", "unknown"))
include_services = bool(
arguments.get("include_services", arguments.get("includeServices", True))
)
telemetry.record_context_request(mode=mode, include_services=include_services)
return [types.TextContent(type="text", text=json.dumps(context, indent=2))]

def list_resources(self) -> list[types.Resource]:
Expand Down Expand Up @@ -435,9 +426,6 @@ def _search_tools(self, arguments: dict[str, Any]) -> list[ToolContent]:
include_mutating=include_mutating,
limit=_normalize_limit(arguments.get("limit"), self._search_limit),
)
telemetry.record_search_tools(
include_mutating=include_mutating, match_count=len(matches)
)

lines: list[str] = []
if not matches:
Expand Down Expand Up @@ -484,13 +472,10 @@ def _call_hidden_tool(self, raw_arguments: dict[str, Any]) -> list[ToolContent]:
confirm_write = bool(
raw_arguments.get("confirm_write", raw_arguments.get("confirmWrite", False))
)
if entry.classification != "read":
if not confirm_write:
telemetry.record_write_confirmation(entry.classification, "blocked")
raise RuntimeError(
f"Tool {tool_name} is {entry.classification}. Re-run appwrite_call_tool with confirm_write=true if you intend to mutate Appwrite state."
)
telemetry.record_write_confirmation(entry.classification, "confirmed")
if entry.classification != "read" and not confirm_write:
raise RuntimeError(
f"Tool {tool_name} is {entry.classification}. Re-run appwrite_call_tool with confirm_write=true if you intend to mutate Appwrite state."
)

project_id = raw_arguments.get("project_id", raw_arguments.get("projectId"))
organization_id = raw_arguments.get(
Expand Down
Loading
Loading