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
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ dependencies = [
"anyio>=4.0.0",
"appwrite>=22.2.0,<23",
"docstring-parser>=0.16",
"mcp[cli]>=1.12.0,<2",
"mcp[cli]>=2,<3",
"python-dotenv>=1.0.1",
"starlette>=0.40.0",
"uvicorn[standard]>=0.30.0",
"httpx>=0.27.0",
"pyjwt[crypto]>=2.9.0",
"numpy>=2.0",
"openai>=1.40",
"opentelemetry-api>=1.27",
"opentelemetry-sdk>=1.27",
"opentelemetry-exporter-otlp-proto-http>=1.27",
"opentelemetry-api>=1.28",
"opentelemetry-sdk>=1.28",
"opentelemetry-exporter-otlp-proto-http>=1.28",
"sentry-sdk[starlette]>=2.0",
]

Expand Down
6 changes: 5 additions & 1 deletion src/mcp_server_appwrite/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ def _resolve_server_version() -> str:
CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
# Mcp-Method / Mcp-Name are required on 2026-07-28 Streamable HTTP (SEP-2243).
"Access-Control-Allow-Headers": (
"Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, "
"Mcp-Method, Mcp-Name"
),
"Access-Control-Expose-Headers": "Mcp-Session-Id, WWW-Authenticate, Link",
}

Expand Down
2 changes: 1 addition & 1 deletion src/mcp_server_appwrite/docs_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def get_tool(self) -> types.Tool:
"(databases, auth, storage, functions, messaging, sites, and more). "
"This does not require a project_id."
),
inputSchema={
input_schema={
"type": "object",
"properties": {
"query": {
Expand Down
62 changes: 58 additions & 4 deletions src/mcp_server_appwrite/http_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, BearerAuthBackend
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.types import CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
Expand Down Expand Up @@ -241,16 +243,47 @@ def _find_initialize_params(body: bytes) -> dict | None:
return None


def _find_request_meta(body: bytes) -> dict | None:
"""Return ``params._meta`` from a single JSON-RPC request object, if present."""
try:
payload = json.loads(body)
except Exception:
return None
if not isinstance(payload, dict):
return None
params = payload.get("params")
if not isinstance(params, dict):
return None
meta = params.get("_meta")
return meta if isinstance(meta, dict) else None


def _is_modern_request(scope: Scope) -> bool:
"""Match the SDK's era-routing predicate: a present ``MCP-Protocol-Version``
that is not a handshake-era revision goes to the modern path."""
version = _header(scope, b"mcp-protocol-version")
return version is not None and version not in HANDSHAKE_PROTOCOL_VERSIONS


class MCPIdentityMiddleware:
"""Bind client/user identity for every authenticated MCP request.

The hosted transport is stateless: each POST is its own MCP session, so
``session.client_params`` is only populated on the request that carries the
``initialize`` message — which the SDK answers internally, before any of our
handlers run. This middleware peeks at the JSON-RPC body instead: an
``initialize`` request is counted as a connection/handshake (with clientInfo),
and every other request binds identity from the ``mcp-protocol-version``
header and User-Agent so tool metrics carry a real ``client_id``."""
handlers run. This middleware peeks at the JSON-RPC body instead:

* An ``initialize`` request (2025-era) is counted as a connection/handshake
with ``clientInfo``.
* A modern (2026-07-28) request — protocol version not in the handshake set —
has no ``initialize``, so one handshake is counted per newly-opened
activity session (the same ``(client, subject)`` window whose idle expiry
emits the disconnect). Client identity comes from ``params._meta`` when
present, else User-Agent. Resuming after the idle window, or landing on a
different replica, counts again.
* Every other request binds identity from the ``mcp-protocol-version``
header and User-Agent so tool metrics carry a real ``client_id``.
"""

def __init__(self, app: ASGIApp) -> None:
self.app = app
Expand Down Expand Up @@ -299,6 +332,27 @@ def _bind_identity(self, scope: Scope, body: bytes) -> None:
subject=subject,
)
return
if _is_modern_request(scope):
meta = _find_request_meta(body) if body else None
client_info = (
meta.get(CLIENT_INFO_META_KEY) if isinstance(meta, dict) else None
)
client_name = None
if isinstance(client_info, dict):
name = client_info.get("name")
client_name = name if isinstance(name, str) else None
protocol_version = None
if isinstance(meta, dict):
version = meta.get(PROTOCOL_VERSION_META_KEY)
protocol_version = version if isinstance(version, str) else None
telemetry.record_stateless_connection(
client_name=client_name
or _client_from_user_agent(_header(scope, b"user-agent")),
protocol_version=protocol_version
or _header(scope, b"mcp-protocol-version"),
subject=subject,
)
return
telemetry.set_request_identity(
client_name=_client_from_user_agent(_header(scope, b"user-agent")),
subject=subject,
Expand Down
27 changes: 13 additions & 14 deletions src/mcp_server_appwrite/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import mcp.types as types
from mcp.server.lowlevel.helper_types import ReadResourceContents
from pydantic import AnyUrl

from . import telemetry
from .constants import (
Expand Down Expand Up @@ -139,7 +138,7 @@ def get_public_tools(self) -> list[types.Tool]:
"connection can read them. Use this before searching the hidden catalog "
"when orienting to a user's Appwrite workspace."
),
inputSchema={
input_schema={
"type": "object",
"properties": {
"project_id": {
Expand Down Expand Up @@ -178,7 +177,7 @@ def get_public_tools(self) -> list[types.Tool]:
"Search the hidden Appwrite tool catalog by natural language query. "
"Use this before appwrite_call_tool when using the Appwrite operator surface."
),
inputSchema={
input_schema={
"type": "object",
"properties": {
"query": {
Expand Down Expand Up @@ -218,7 +217,7 @@ def get_public_tools(self) -> list[types.Tool]:
"Mutating tools require confirm_write=true. Hidden Appwrite parameters accept "
"canonical snake_case names and common camelCase aliases."
),
inputSchema={
input_schema={
"type": "object",
"properties": {
"tool_name": {
Expand Down Expand Up @@ -319,21 +318,21 @@ def _get_context(self, arguments: dict[str, Any]) -> list[ToolContent]:
def list_resources(self) -> list[types.Resource]:
resources = [
types.Resource(
uri=AnyUrl(CATALOG_URI),
uri=CATALOG_URI,
name="Appwrite Hidden Tool Catalog",
description="Full internal Appwrite tool catalog used by the Appwrite operator surface.",
mimeType="application/json",
mime_type="application/json",
size=len(self._cached_catalog_json.encode("utf-8")),
)
]

for stored_result in self._result_store.list():
resources.append(
types.Resource(
uri=AnyUrl(stored_result.uri),
uri=stored_result.uri,
name=f"{stored_result.tool_name} result",
description="Stored Appwrite tool result. Read this resource to inspect the full output.",
mimeType="application/json",
mime_type="application/json",
size=len(stored_result.text.encode("utf-8")),
)
)
Expand All @@ -343,10 +342,10 @@ def list_resources(self) -> list[types.Resource]:
def list_resource_templates(self) -> list[types.ResourceTemplate]:
return [
types.ResourceTemplate(
uriTemplate=RESULT_URI_TEMPLATE,
uri_template=RESULT_URI_TEMPLATE,
name="Stored Appwrite Tool Result",
description="Stored result payloads created by appwrite_call_tool.",
mimeType="application/json",
mime_type="application/json",
)
]

Expand All @@ -373,7 +372,7 @@ def _build_catalog(self) -> list[CatalogEntry]:
entries: list[CatalogEntry] = []
for tool in self._tools_manager.get_all_tools():
parsed = _parse_tool_name(tool.name)
input_schema = tool.inputSchema or {}
input_schema = tool.input_schema or {}
entries.append(
CatalogEntry(
action_verb=parsed["action_verb"],
Expand Down Expand Up @@ -788,7 +787,7 @@ def _content_size(content: list[ToolContent]) -> int:

def _serialize_content(content: list[ToolContent]) -> str:
return json.dumps(
[item.model_dump(mode="json") for item in content],
[item.model_dump(mode="json", by_alias=True) for item in content],
indent=2,
ensure_ascii=False,
)
Expand All @@ -799,8 +798,8 @@ def _summarize_content_item(item: ToolContent) -> str:
preview = item.text.strip().splitlines()[0] if item.text.strip() else "text"
return f"text:{preview[:60]}"
if isinstance(item, types.ImageContent):
return f"image:{item.mimeType}"
return f"resource:{item.resource.mimeType or 'application/octet-stream'}"
return f"image:{item.mime_type}"
return f"resource:{item.resource.mime_type or 'application/octet-stream'}"


def _now_iso() -> str:
Expand Down
Loading
Loading