From ad133f923ec13fcb54994552eea91fd5face9299 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Jul 2026 19:13:25 +0530 Subject: [PATCH] Migrate to MCP Python SDK v2 with dual-era protocol support. Port the low-level Server handlers to v2's on_* constructor API, preserve model-visible tool errors, keep handshake metrics alive for 2026-07-28 clients, and fix integration tests for required variable_id. Co-authored-by: Cursor --- pyproject.toml | 8 +- src/mcp_server_appwrite/constants.py | 6 +- src/mcp_server_appwrite/docs_search.py | 2 +- src/mcp_server_appwrite/http_app.py | 62 ++++++- src/mcp_server_appwrite/operator.py | 27 ++- src/mcp_server_appwrite/server.py | 224 +++++++++++++++++-------- src/mcp_server_appwrite/service.py | 2 +- src/mcp_server_appwrite/telemetry.py | 33 +++- tests/integration/test_functions.py | 14 +- tests/integration/test_sites.py | 14 +- tests/unit/test_http_app.py | 144 +++++++++++++++- tests/unit/test_operator.py | 8 +- tests/unit/test_server.py | 120 ++++++++++++- tests/unit/test_service.py | 2 +- tests/unit/test_telemetry.py | 61 ++++++- uv.lock | 147 ++++++++-------- 16 files changed, 681 insertions(+), 193 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d873958..daac318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ 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", @@ -16,9 +16,9 @@ dependencies = [ "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", ] diff --git a/src/mcp_server_appwrite/constants.py b/src/mcp_server_appwrite/constants.py index 0c437be..7138ac3 100644 --- a/src/mcp_server_appwrite/constants.py +++ b/src/mcp_server_appwrite/constants.py @@ -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", } diff --git a/src/mcp_server_appwrite/docs_search.py b/src/mcp_server_appwrite/docs_search.py index 1b2735c..d608441 100644 --- a/src/mcp_server_appwrite/docs_search.py +++ b/src/mcp_server_appwrite/docs_search.py @@ -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": { diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index 7d0cd50..6a3bc5c 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -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 @@ -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 @@ -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, diff --git a/src/mcp_server_appwrite/operator.py b/src/mcp_server_appwrite/operator.py index f1647a7..ce89c9a 100644 --- a/src/mcp_server_appwrite/operator.py +++ b/src/mcp_server_appwrite/operator.py @@ -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 ( @@ -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": { @@ -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": { @@ -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": { @@ -319,10 +318,10 @@ 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")), ) ] @@ -330,10 +329,10 @@ def list_resources(self) -> list[types.Resource]: 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")), ) ) @@ -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", ) ] @@ -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"], @@ -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, ) @@ -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: diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index 9e9087d..acaf05a 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -21,7 +21,7 @@ from enum import Enum from pathlib import Path from types import UnionType -from typing import Any, Union, get_args, get_origin +from typing import Any, Union, cast, get_args, get_origin from urllib.parse import unquote, urlsplit, urlunsplit import httpx @@ -34,11 +34,11 @@ from appwrite.input_file import InputFile from appwrite.service import Service as _SdkService from dotenv import find_dotenv, load_dotenv -from mcp.server import NotificationOptions, Server +from mcp import MCPError +from mcp.server import NotificationOptions, Server, ServerRequestContext from mcp.server.auth.middleware.auth_context import get_access_token -from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.models import InitializationOptions -from pydantic import AnyUrl +from mcp.types import CLIENT_INFO_META_KEY, INVALID_PARAMS from . import error_monitoring, flags, telemetry from .constants import ( @@ -685,7 +685,7 @@ def _expected_argument_names(tool_info: dict) -> set[str]: return parameter_names definition = tool_info.get("definition") - input_schema = definition.inputSchema if definition is not None else None + input_schema = definition.input_schema if definition is not None else None properties = ( input_schema.get("properties", {}) if isinstance(input_schema, dict) else {} ) @@ -916,15 +916,15 @@ def _format_binary_result( mime_type = _guess_mime_type(data, tool_name, arguments) encoded = base64.b64encode(data).decode("ascii") if mime_type.startswith("image/"): - return [types.ImageContent(type="image", data=encoded, mimeType=mime_type)] + return [types.ImageContent(type="image", data=encoded, mime_type=mime_type)] return [ types.EmbeddedResource( type="resource", resource=types.BlobResourceContents( - uri=AnyUrl(f"appwrite://tool/{tool_name}"), + uri=f"appwrite://tool/{tool_name}", blob=encoded, - mimeType=mime_type, + mime_type=mime_type, ), ) ] @@ -1001,20 +1001,13 @@ def build_mcp_server(operator: Operator, *, transport: str = "http") -> Server: _configure_uploads(transport) instructions = build_instructions(transport) - server = Server( - "Appwrite MCP Server", - version=SERVER_VERSION, - instructions=instructions, - website_url=SERVER_WEBSITE_URL, - icons=[types.Icon(src=SERVER_ICON_URL, mimeType="image/svg+xml")], - ) - - @server.list_tools() - async def handle_list_tools() -> list[types.Tool]: - _emit_initialize(server) + async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + _emit_initialize(ctx) start = time.monotonic() try: - result = operator.get_public_tools() + tools = operator.get_public_tools() except Exception as exc: telemetry.record_message( "tools/list", @@ -1023,7 +1016,7 @@ async def handle_list_tools() -> list[types.Tool]: error_code=_jsonrpc_error_code(exc), error_message=type(exc).__name__, ) - mcp_context = _mcp_request_context(server) + mcp_context = _mcp_request_context(ctx) error_monitoring.capture_exception( exc, tags={ @@ -1042,19 +1035,20 @@ async def handle_list_tools() -> list[types.Tool]: ) raise telemetry.record_message("tools/list", "success", time.monotonic() - start) - return result + return types.ListToolsResult(tools=tools) - @server.call_tool() async def handle_call_tool( - name: str, arguments: dict | None - ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - _emit_initialize(server) + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> types.CallToolResult: + _emit_initialize(ctx) start = time.monotonic() + name = params.name + arguments = params.arguments or {} try: if not operator.has_public_tool(name): telemetry.record_hallucination(name) raise ValueError(f"Tool {name} not found") - result = await _execute_public_tool_for_transport( + content = await _execute_public_tool_for_transport( operator, name, arguments, transport ) except Exception as exc: @@ -1065,7 +1059,7 @@ async def handle_call_tool( error_code=_jsonrpc_error_code(exc), error_message=type(exc).__name__, ) - mcp_context = _mcp_request_context(server) + mcp_context = _mcp_request_context(ctx) error_monitoring.capture_exception( exc, tags={ @@ -1086,16 +1080,23 @@ async def handle_call_tool( }, transaction=f"mcp.tools/call:{name}", ) - raise + # v2 no longer wraps handler exceptions into is_error=True tool + # results. Return one explicitly so the model still sees refusals + # (confirm_write) and Appwrite API errors. + return types.CallToolResult( + content=[types.TextContent(type="text", text=str(exc))], + is_error=True, + ) telemetry.record_message("tools/call", "success", time.monotonic() - start) - return result + return types.CallToolResult(content=cast(list[types.ContentBlock], content)) - @server.list_resources() - async def handle_list_resources() -> list[types.Resource]: - _emit_initialize(server) + async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + _emit_initialize(ctx) start = time.monotonic() try: - result = operator.list_resources() + resources = operator.list_resources() except Exception as exc: telemetry.record_message( "resources/list", @@ -1104,7 +1105,7 @@ async def handle_list_resources() -> list[types.Resource]: error_code=_jsonrpc_error_code(exc), error_message=type(exc).__name__, ) - mcp_context = _mcp_request_context(server) + mcp_context = _mcp_request_context(ctx) error_monitoring.capture_exception( exc, tags={ @@ -1123,20 +1124,52 @@ async def handle_list_resources() -> list[types.Resource]: ) raise telemetry.record_message("resources/list", "success", time.monotonic() - start) - return result + return types.ListResourcesResult(resources=resources) - @server.list_resource_templates() - async def handle_list_resource_templates() -> list[types.ResourceTemplate]: - return operator.list_resource_templates() + async def handle_list_resource_templates( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListResourceTemplatesResult: + return types.ListResourceTemplatesResult( + resource_templates=operator.list_resource_templates() + ) - @server.read_resource() - async def handle_read_resource(uri) -> list[ReadResourceContents]: - _emit_initialize(server) + async def handle_read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + _emit_initialize(ctx) start = time.monotonic() - uri_str = str(uri) + uri_str = str(params.uri) resource_type = "catalog" if uri_str == CATALOG_URI else "result" try: - result = operator.read_resource(uri_str) + contents = operator.read_resource(uri_str) + except ValueError as exc: + telemetry.record_message( + "resources/read", + "error", + time.monotonic() - start, + error_code=_jsonrpc_error_code(exc), + error_message=type(exc).__name__, + ) + mcp_context = _mcp_request_context(ctx) + error_monitoring.capture_exception( + exc, + tags={ + "mcp.method": "resources/read", + "resource.type": resource_type, + "transport": transport, + **mcp_context.tags, + }, + context={ + "mcp": { + "method": "resources/read", + "resource_type": resource_type, + "transport": transport, + **mcp_context.context, + }, + }, + transaction=f"mcp.resources/read:{resource_type}", + ) + raise MCPError(INVALID_PARAMS, str(exc)) from exc except Exception as exc: telemetry.record_message( "resources/read", @@ -1145,7 +1178,7 @@ async def handle_read_resource(uri) -> list[ReadResourceContents]: error_code=_jsonrpc_error_code(exc), error_message=type(exc).__name__, ) - mcp_context = _mcp_request_context(server) + mcp_context = _mcp_request_context(ctx) error_monitoring.capture_exception( exc, tags={ @@ -1171,13 +1204,37 @@ async def handle_read_resource(uri) -> list[ReadResourceContents]: if isinstance(item.content, bytes) else len(str(item.content).encode("utf-8")) ) - for item in result + for item in contents ) telemetry.record_message_size("sent", size_bytes) telemetry.record_message("resources/read", "success", time.monotonic() - start) - return result + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=uri_str, + text=( + item.content.decode("utf-8") + if isinstance(item.content, bytes) + else str(item.content) + ), + mime_type=item.mime_type, + ) + for item in contents + ] + ) - return server + return Server( + "Appwrite MCP Server", + version=SERVER_VERSION, + instructions=instructions, + website_url=SERVER_WEBSITE_URL, + icons=[types.Icon(src=SERVER_ICON_URL, mime_type="image/svg+xml")], + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + on_list_resources=handle_list_resources, + on_list_resource_templates=handle_list_resource_templates, + on_read_resource=handle_read_resource, + ) def _jsonrpc_error_code(exc: Exception) -> int: @@ -1190,18 +1247,47 @@ class McpRequestContext: context: dict[str, Any] -def _mcp_request_context(server: Server) -> McpRequestContext: +def _client_identity_from_ctx( + ctx: ServerRequestContext, +) -> tuple[str | None, str | None, str | None]: + """Resolve client name/version/protocol from a request context. + + Prefers the 2025-era handshake ``client_params`` when present; falls back to + the 2026-era ``_meta`` clientInfo key. ``protocol_version`` always comes from + ``ctx.protocol_version``, which both eras populate. + """ + client_name: str | None = None + client_version: str | None = None + protocol_version: str | None = getattr(ctx, "protocol_version", None) or None + try: - params = server.request_context.session.client_params + params = ctx.session.client_params except Exception: - return McpRequestContext(tags={}, context={}) - if params is None: - return McpRequestContext(tags={}, context={}) + params = None + + if params is not None: + client_info = getattr(params, "client_info", None) + client_name = getattr(client_info, "name", None) + client_version = getattr(client_info, "version", None) + if not protocol_version: + protocol_version = getattr(params, "protocol_version", None) + + if client_name is None and isinstance(ctx.meta, dict): + meta_info = ctx.meta.get(CLIENT_INFO_META_KEY) + if isinstance(meta_info, dict): + name = meta_info.get("name") + version = meta_info.get("version") + client_name = name if isinstance(name, str) else None + client_version = version if isinstance(version, str) else None + + return client_name, client_version, protocol_version - client_info = getattr(params, "clientInfo", None) - client_name = getattr(client_info, "name", None) - client_version = getattr(client_info, "version", None) - protocol_version = getattr(params, "protocolVersion", None) + +def _mcp_request_context(ctx: ServerRequestContext) -> McpRequestContext: + try: + client_name, client_version, protocol_version = _client_identity_from_ctx(ctx) + except Exception: + return McpRequestContext(tags={}, context={}) tags = { key: value @@ -1273,21 +1359,19 @@ async def _execute_public_tool_for_transport( ) -def _emit_initialize(server: Server) -> None: - """Refine the request identity from the MCP session's negotiated client params - when they are available. On the hosted stateless transport they usually are - not — each POST is its own session, and connections/handshakes are counted by - ``MCPIdentityMiddleware`` in the HTTP layer instead. Best-effort: any failure - to read the request context is swallowed.""" +def _emit_initialize(ctx: ServerRequestContext) -> None: + """Refine the request identity from negotiated client params / ``_meta``. + + On the hosted stateless transport, 2025-era ``client_params`` are usually + absent — each POST is its own session, and connections/handshakes are counted + by ``MCPIdentityMiddleware`` in the HTTP layer instead. 2026-era clients carry + identity in ``_meta`` instead. Best-effort: any failure is swallowed. + """ try: - session = server.request_context.session - params = session.client_params + client_name, _client_version, protocol_version = _client_identity_from_ctx(ctx) except Exception: return - if params is None: - return - client_info = getattr(params, "clientInfo", None) subject = None try: access_token = get_access_token() @@ -1298,9 +1382,9 @@ def _emit_initialize(server: Server) -> None: pass telemetry.set_request_identity( - client_name=getattr(client_info, "name", None), + client_name=client_name, subject=subject, - protocol_version=getattr(params, "protocolVersion", None), + protocol_version=protocol_version, ) diff --git a/src/mcp_server_appwrite/service.py b/src/mcp_server_appwrite/service.py index cfcf8b1..478bfad 100644 --- a/src/mcp_server_appwrite/service.py +++ b/src/mcp_server_appwrite/service.py @@ -190,7 +190,7 @@ def list_tools(self) -> Dict[str, Dict]: tool_definition = Tool( name=tool_name, description=docstring.short_description or "No description available", - inputSchema={ + input_schema={ "type": "object", "properties": properties, "required": required, diff --git a/src/mcp_server_appwrite/telemetry.py b/src/mcp_server_appwrite/telemetry.py index 70d7388..8e3dd51 100644 --- a/src/mcp_server_appwrite/telemetry.py +++ b/src/mcp_server_appwrite/telemetry.py @@ -366,10 +366,14 @@ def set_request_identity( client_name: str | None, subject: str | None, protocol_version: str | None = None, -) -> None: +) -> bool: """Bind the current request's client/user identity to the calling context and refresh the rolling activity stores. Contextvars propagate into the worker - threads that execute tools, so record helpers can label by client.""" + threads that execute tools, so record helpers can label by client. + + Returns True only when a new ``(client, subject)`` activity window was + opened. Callers that ignore the return value are unaffected. + """ # Never downgrade an identity already bound for this request (e.g. by the # HTTP-layer middleware) to "unknown". client = _normalize_client_name(client_name) or _request_client.get() @@ -377,19 +381,22 @@ def set_request_identity( if not _enabled: # The rolling stores are only pruned by the gauge callbacks, which never # run while disabled — do not let them grow. - return + return False now = time.monotonic() expiry = now + ACTIVE_WINDOW_SECONDS + started = False with _active_lock: if subject: _active_users[subject] = expiry session = _active_sessions.get((client, subject)) if session is None: _active_sessions[(client, subject)] = [now, expiry] + started = True else: session[1] = expiry if protocol_version: _active_versions[(protocol_version, client, subject)] = expiry + return started def current_client_id() -> str: @@ -567,6 +574,26 @@ def record_connection( _safe_add("handshake", 1, {"status": "success", "client_id": client}) +def record_stateless_connection( + *, + client_name: str | None, + protocol_version: str | None, + subject: str | None, +) -> None: + """Modern (2026-07-28) clients never send ``initialize``, so there is no + handshake to count. Count one success per activity session instead — the + same window whose expiry emits the ``idle`` disconnect.""" + started = set_request_identity( + client_name=client_name, + subject=subject, + protocol_version=protocol_version, + ) + if not (_enabled and started): + return + client = _normalize_client_name(client_name) or "unknown" + _safe_add("handshake", 1, {"status": "success", "client_id": client}) + + def record_handshake_failure(reason: str | None = None) -> None: """A presented bearer token was rejected — the session never initialized.""" _safe_add( diff --git a/tests/integration/test_functions.py b/tests/integration/test_functions.py index 0501eea..c3c67b2 100644 --- a/tests/integration/test_functions.py +++ b/tests/integration/test_functions.py @@ -7,7 +7,7 @@ class FunctionsIntegrationTests(LiveIntegrationTestCase): def test_functions_smoke(self): runner = self.new_runner() function_id = runner.unique_id("fn") - variable_id: str | None = None + variable_id = runner.unique_id("var") try: runner.call("functions_list") @@ -25,11 +25,17 @@ def test_functions_smoke(self): }, ) runner.call("functions_get", {"function_id": function_id}) - variable = runner.call( + # SDK ≥18.1 requires a client-supplied variable_id (same pattern as + # function_id / document_id); pass unique() or a custom ID. + runner.call( "functions_create_variable", - {"function_id": function_id, "key": "GREETING", "value": "hello"}, + { + "function_id": function_id, + "variable_id": variable_id, + "key": "GREETING", + "value": "hello", + }, ) - variable_id = variable["$id"] runner.call( "functions_get_variable", {"function_id": function_id, "variable_id": variable_id}, diff --git a/tests/integration/test_sites.py b/tests/integration/test_sites.py index 3cfd3cf..c113700 100644 --- a/tests/integration/test_sites.py +++ b/tests/integration/test_sites.py @@ -8,7 +8,7 @@ class SitesIntegrationTests(LiveIntegrationTestCase): def test_sites_smoke(self): runner = self.new_runner() site_id = runner.unique_id("site") - variable_id: str | None = None + variable_id = runner.unique_id("var") try: runner.call("sites_list") @@ -25,11 +25,17 @@ def test_sites_smoke(self): }, ) runner.call("sites_get", {"site_id": site_id}) - variable = runner.call( + # SDK ≥18.1 requires a client-supplied variable_id (same pattern as + # site_id / document_id); pass unique() or a custom ID. + runner.call( "sites_create_variable", - {"site_id": site_id, "key": "SITE_ENV", "value": "smoke"}, + { + "site_id": site_id, + "variable_id": variable_id, + "key": "SITE_ENV", + "value": "smoke", + }, ) - variable_id = variable["$id"] runner.call( "sites_get_variable", {"site_id": site_id, "variable_id": variable_id} ) diff --git a/tests/unit/test_http_app.py b/tests/unit/test_http_app.py index 8c8617b..0d09129 100644 --- a/tests/unit/test_http_app.py +++ b/tests/unit/test_http_app.py @@ -16,6 +16,8 @@ RequireBearer, _client_from_user_agent, _find_initialize_params, + _find_request_meta, + _is_modern_request, _send_401, authorization_server_metadata_endpoint, build_app, @@ -492,6 +494,60 @@ def test_non_initialize_and_invalid(self): self.assertIsNone(_find_initialize_params(b"not json")) +class FindRequestMetaTests(unittest.TestCase): + def test_extracts_meta(self): + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "appwrite_get_context", + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "cursor"}, + }, + }, + } + ).encode() + meta = _find_request_meta(body) + assert meta is not None + self.assertEqual(meta["io.modelcontextprotocol/protocolVersion"], "2026-07-28") + + def test_missing_or_invalid(self): + self.assertIsNone( + _find_request_meta(b'{"jsonrpc":"2.0","method":"tools/call"}') + ) + self.assertIsNone( + _find_request_meta(b'{"jsonrpc":"2.0","method":"tools/call","params":[]}') + ) + self.assertIsNone(_find_request_meta(b"[{}]")) + self.assertIsNone(_find_request_meta(b"not json")) + + +class IsModernRequestTests(unittest.TestCase): + def test_modern_version(self): + self.assertTrue( + _is_modern_request( + { + "type": "http", + "headers": [(b"mcp-protocol-version", b"2026-07-28")], + } + ) + ) + + def test_handshake_and_missing(self): + self.assertFalse( + _is_modern_request( + { + "type": "http", + "headers": [(b"mcp-protocol-version", b"2025-06-18")], + } + ) + ) + self.assertFalse(_is_modern_request({"type": "http", "headers": []})) + + class MCPIdentityMiddlewareTests(unittest.TestCase): def setUp(self): self.reader = InMemoryMetricReader() @@ -508,8 +564,15 @@ def tearDown(self): telemetry._active_users.clear() telemetry._active_sessions.clear() telemetry._active_versions.clear() + telemetry._seen_sessions.clear() - def _run(self, body: bytes, headers: list[tuple[bytes, bytes]]): + def _run( + self, + body: bytes, + headers: list[tuple[bytes, bytes]], + *, + subject: str | None = None, + ): downstream_bodies: list[bytes] = [] async def app(scope, receive, send): @@ -517,7 +580,19 @@ async def app(scope, receive, send): message = await receive() downstream_bodies.append(message.get("body", b"")) - scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": headers} + scope: dict = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": headers, + } + if subject is not None: + token = mock.Mock() + token.subject = subject + token.claims = {"sub": subject} + user = mock.Mock() + user.access_token = token + scope["user"] = user received = [{"type": "http.request", "body": body, "more_body": False}] async def receive(): @@ -570,6 +645,71 @@ def test_tool_call_binds_identity_from_headers(self): # No handshake counted for non-initialize requests. self.assertEqual(self._points("mcp.handshake"), []) + def test_modern_request_records_handshake_from_meta(self): + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "appwrite_get_context", + "arguments": {}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "claude-code", + "version": "2.0", + }, + }, + }, + } + ).encode() + headers = [ + (b"user-agent", b"other-agent/1.0"), + (b"mcp-protocol-version", b"2026-07-28"), + (b"mcp-method", b"tools/call"), + ] + self._run(body, headers, subject="user-a") + handshakes = self._points("mcp.handshake") + self.assertEqual(len(handshakes), 1) + self.assertEqual(handshakes[0].attributes.get("client_id"), "claude-code") + self.assertEqual(handshakes[0].attributes.get("status"), "success") + self.assertEqual(self.seen_client, ["claude-code"]) + + # Same activity window — no second handshake. + self._run(body, headers, subject="user-a") + handshakes = self._points("mcp.handshake") + self.assertEqual(sum(p.value for p in handshakes), 1) + + def test_modern_request_falls_back_to_user_agent(self): + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + } + ).encode() + self._run( + body, + [ + (b"user-agent", b"cursor/1.5 (linux)"), + (b"mcp-protocol-version", b"2026-07-28"), + (b"mcp-method", b"tools/list"), + ], + subject="user-b", + ) + handshakes = self._points("mcp.handshake") + self.assertEqual(len(handshakes), 1) + self.assertEqual(handshakes[0].attributes.get("client_id"), "cursor") + self.assertEqual(self.seen_client, ["cursor"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_operator.py b/tests/unit/test_operator.py index dcbadd5..af3de8d 100644 --- a/tests/unit/test_operator.py +++ b/tests/unit/test_operator.py @@ -13,7 +13,7 @@ def make_tool( return types.Tool( name=name, description=description, - inputSchema={ + input_schema={ "type": "object", "properties": { "parameter": {"type": "string"}, @@ -33,7 +33,7 @@ def get_tool(self) -> types.Tool: return types.Tool( name="appwrite_search_docs", description="Search the Appwrite documentation.", - inputSchema={ + input_schema={ "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], @@ -315,7 +315,7 @@ def test_store_results_false_returns_image_inline(self): runtime = Operator( manager, lambda name, arguments, *_: [ - types.ImageContent(type="image", data="aW1hZ2U=", mimeType="image/png") + types.ImageContent(type="image", data="aW1hZ2U=", mime_type="image/png") ], store_results=False, ) @@ -327,7 +327,7 @@ def test_store_results_false_returns_image_inline(self): self.assertEqual(len(result), 1) self.assertIsInstance(result[0], types.ImageContent) - self.assertEqual(result[0].mimeType, "image/png") + self.assertEqual(result[0].mime_type, "image/png") class ResultStoreTests(unittest.TestCase): diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index e229dc8..97504aa 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -414,7 +414,7 @@ def test_format_tool_result_returns_binary_resource(self): self.assertEqual(len(result), 1) self.assertIsInstance(result[0], types.EmbeddedResource) - self.assertEqual(result[0].resource.mimeType, "application/octet-stream") + self.assertEqual(result[0].resource.mime_type, "application/octet-stream") def test_format_appwrite_error_truncates_large_html_body(self): exc = AppwriteException("" + ("x" * 1000), 404, None) @@ -426,21 +426,23 @@ def test_format_appwrite_error_truncates_large_html_body(self): self.assertTrue(message.endswith("...")) def test_mcp_request_context_extracts_client_metadata(self): - server = Mock() - server.request_context.session.client_params = type( + ctx = Mock() + ctx.protocol_version = "2025-06-18" + ctx.meta = None + ctx.session.client_params = type( "Params", (), { - "clientInfo": type( + "client_info": type( "ClientInfo", (), {"name": "codex", "version": "1.2.3"}, )(), - "protocolVersion": "2025-06-18", + "protocol_version": "2025-06-18", }, )() - context = _mcp_request_context(server) + context = _mcp_request_context(ctx) self.assertEqual( context.tags, @@ -461,15 +463,115 @@ def test_mcp_request_context_extracts_client_metadata(self): }, ) + def test_mcp_request_context_falls_back_to_meta_client_info(self): + from mcp.types import CLIENT_INFO_META_KEY + + ctx = Mock() + ctx.protocol_version = "2026-07-28" + ctx.session.client_params = None + ctx.meta = { + CLIENT_INFO_META_KEY: {"name": "cursor", "version": "2.0"}, + } + + context = _mcp_request_context(ctx) + + self.assertEqual( + context.tags, + { + "mcp.client.name": "cursor", + "mcp.client.version": "2.0", + "mcp.protocol_version": "2026-07-28", + }, + ) + def test_mcp_request_context_tolerates_missing_client_metadata(self): - server = Mock() - server.request_context.session.client_params = None + ctx = Mock() + ctx.protocol_version = None + ctx.meta = None + ctx.session.client_params = None - context = _mcp_request_context(server) + context = _mcp_request_context(ctx) self.assertEqual(context.tags, {}) self.assertEqual(context.context, {}) + def test_call_tool_handler_returns_is_error_for_confirm_write_refusal(self): + """v2 no longer wraps exceptions; the handler must return is_error=True.""" + + class RefusingOperator: + def has_public_tool(self, name): + return True + + def execute_public_tool(self, name, arguments): + raise RuntimeError( + "Tool tables_db_create is write. Re-run appwrite_call_tool " + "with confirm_write=true if you intend to mutate Appwrite state." + ) + + def get_public_tools(self): + return [] + + def list_resources(self): + return [] + + def list_resource_templates(self): + return [] + + def read_resource(self, uri): + raise ValueError(f"Unknown resource URI: {uri}") + + server = build_mcp_server(RefusingOperator(), transport="stdio") + entry = server.get_request_handler("tools/call") + self.assertIsNotNone(entry) + + async def run_check(): + ctx = Mock() + ctx.protocol_version = "2025-11-25" + ctx.meta = None + ctx.session.client_params = None + params = types.CallToolRequestParams( + name="appwrite_call_tool", + arguments={"tool_name": "tables_db_create"}, + ) + result = await entry.handler(ctx, params) + self.assertIsInstance(result, types.CallToolResult) + self.assertTrue(result.is_error) + self.assertIn("confirm_write=true", result.content[0].text) + + asyncio.run(run_check()) + + def test_call_tool_handler_returns_is_error_for_unknown_tool(self): + class EmptyOperator: + def has_public_tool(self, name): + return False + + def get_public_tools(self): + return [] + + def list_resources(self): + return [] + + def list_resource_templates(self): + return [] + + server = build_mcp_server(EmptyOperator(), transport="stdio") + entry = server.get_request_handler("tools/call") + + async def run_check(): + ctx = Mock() + ctx.protocol_version = "2025-11-25" + ctx.meta = None + ctx.session.client_params = None + params = types.CallToolRequestParams( + name="made_up_tool", + arguments={}, + ) + result = await entry.handler(ctx, params) + self.assertTrue(result.is_error) + self.assertIn("Tool made_up_tool not found", result.content[0].text) + + asyncio.run(run_check()) + def test_register_services_returns_fresh_manager(self): manager_a = register_services(object()) manager_b = register_services(object()) diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 34d4870..6f3c56b 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -51,7 +51,7 @@ class ServiceSchemaTests(unittest.TestCase): def test_generates_enum_and_input_file_schema(self): tools = Service(ExampleService(), "example").list_tools() tool = tools["example_create"] - schema = tool["definition"].inputSchema + schema = tool["definition"].input_schema self.assertEqual(tool["definition"].description, "Create example resource.") self.assertNotIn("on_progress", schema["properties"]) diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index d676212..bc87af5 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -17,7 +17,7 @@ def make_tool( return types.Tool( name=name, description=description, - inputSchema={ + input_schema={ "type": "object", "properties": {"parameter": {"type": "string"}}, "required": required or [], @@ -376,5 +376,64 @@ def test_session_window_constant_positive(self): self.assertGreater(ACTIVE_WINDOW_SECONDS, 0) +class StatelessConnectionTests(TelemetryHarness): + def test_set_request_identity_returns_true_only_on_new_window(self): + self.assertTrue( + telemetry.set_request_identity( + client_name="cursor", + subject="user-a", + protocol_version="2026-07-28", + ) + ) + self.assertFalse( + telemetry.set_request_identity( + client_name="cursor", + subject="user-a", + protocol_version="2026-07-28", + ) + ) + self.assertTrue( + telemetry.set_request_identity( + client_name="claude-code", + subject="user-a", + protocol_version="2026-07-28", + ) + ) + self.assertFalse( + telemetry.set_request_identity(client_name="cursor", subject=None) + ) + + def test_record_stateless_connection_counts_once_per_window(self): + telemetry.record_stateless_connection( + client_name="cursor", + protocol_version="2026-07-28", + subject="user-a", + ) + telemetry.record_stateless_connection( + client_name="cursor", + protocol_version="2026-07-28", + subject="user-a", + ) + handshakes = self.points("mcp.handshake") + self.assertEqual(sum(p.value for p in handshakes), 1) + self.assertAttr(handshakes[0], "status", "success") + self.assertAttr(handshakes[0], "client_id", "cursor") + + def test_record_stateless_connection_noop_when_disabled(self): + telemetry._enabled = False + with telemetry._active_lock: + telemetry._active_users.clear() + telemetry._active_sessions.clear() + telemetry._active_versions.clear() + telemetry.record_stateless_connection( + client_name="cursor", + protocol_version="2026-07-28", + subject="user-a", + ) + self.assertEqual(len(telemetry._active_users), 0) + self.assertEqual(len(telemetry._active_sessions), 0) + self.assertEqual(self.points("mcp.handshake"), []) + + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index 83701a8..d7c194b 100644 --- a/uv.lock +++ b/uv.lock @@ -26,16 +26,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.8.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126, upload-time = "2025-01-05T13:13:11.095Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041, upload-time = "2025-01-05T13:13:07.985Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -405,24 +404,37 @@ wheels = [ [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, ] [[package]] @@ -477,21 +489,28 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -607,28 +626,27 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, - { name = "starlette", version = "0.46.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "starlette" }, { name = "typing-extensions" }, { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, ] [package.optional-dependencies] @@ -654,8 +672,7 @@ dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, { name = "sentry-sdk", extra = ["starlette"] }, - { name = "starlette", version = "0.46.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "starlette" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -683,12 +700,12 @@ requires-dist = [ { name = "bcrypt", marker = "extra == 'integration'", specifier = ">=4.1.2" }, { name = "docstring-parser", specifier = ">=0.16" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.12.0,<2" }, + { name = "mcp", extras = ["cli"], specifier = ">=2,<3" }, { name = "numpy", specifier = ">=2.0" }, { name = "openai", specifier = ">=1.40" }, - { name = "opentelemetry-api", specifier = ">=1.27" }, - { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.27" }, - { name = "opentelemetry-sdk", specifier = ">=1.27" }, + { name = "opentelemetry-api", specifier = ">=1.28" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.28" }, + { name = "opentelemetry-sdk", specifier = ">=1.28" }, { name = "passlib", marker = "extra == 'integration'", specifier = ">=1.7.4" }, { name = "pycryptodome", marker = "extra == 'integration'", specifier = ">=3.20.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9.0" }, @@ -707,6 +724,19 @@ dev = [ { name = "ruff", specifier = ">=0.10.0" }, ] +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1065,19 +1095,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/a2/ad2511ede77bb424f3939e5148a56d968cdc6b1462620d24b2a1f4ab65b4/pydantic_settings-2.8.0.tar.gz", hash = "sha256:88e2ca28f6e68ea102c99c3c401d6c9078e68a5df600e97b43891c34e089500a", size = 83347, upload-time = "2025-02-21T08:04:52.046Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/a9/3b9642025174bbe67e900785fb99c9bfe91ea584b0b7126ff99945c24a0e/pydantic_settings-2.8.0-py3-none-any.whl", hash = "sha256:c782c7dc3fb40e97b238e713c25d26f64314aece2e91abcff592fcac15f71820", size = 30746, upload-time = "2025-02-21T08:04:50.49Z" }, -] - [[package]] name = "pygments" version = "2.19.1" @@ -1418,8 +1435,7 @@ wheels = [ [package.optional-dependencies] starlette = [ - { name = "starlette", version = "0.46.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "starlette" }, ] [[package]] @@ -1442,42 +1458,24 @@ wheels = [ [[package]] name = "sse-starlette" -version = "2.2.1" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "starlette", version = "0.46.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376, upload-time = "2024-12-25T09:09:30.616Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120, upload-time = "2024-12-25T09:09:26.761Z" }, -] - -[[package]] -name = "starlette" -version = "0.46.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.14'", -] -dependencies = [ - { name = "anyio", marker = "python_full_version < '3.14'" }, + { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/b6/fb9a32e3c5d59b1e383c357534c63c2d3caa6f25bf3c59dd89d296ecbaec/starlette-0.46.0.tar.gz", hash = "sha256:b359e4567456b28d473d0193f34c0de0ed49710d75ef183a74a5ce0499324f50", size = 2575568, upload-time = "2025-02-22T17:34:45.949Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/94/8af675a62e3c91c2dee47cf92e602cfac86e8767b1a1ac3caf1b327c2ab0/starlette-0.46.0-py3-none-any.whl", hash = "sha256:913f0798bd90ba90a9156383bcf1350a17d6259451d0d8ee27fc0cf2db609038", size = 71991, upload-time = "2025-02-22T17:34:43.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, ] [[package]] name = "starlette" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", -] dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.14'" }, + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ @@ -1496,6 +1494,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.26.7"