From 3ac90cb3602fa855956338a577c822fb1a1a2075 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 9 Oct 2026 09:59:22 +0100 Subject: [PATCH 1/4] chore: bump mcp SDK to 2.3.0 Raise the floor to >=2.3 so installs can no longer resolve 2.0.0, which rejects notification POSTs on the 2026-07-28 HTTP entry with a 400 and lacks Server(get_tool_input_schema=...). Pulls httpx2/httpcore2 2.13.1 and mcp-types 2.3.0 transitively. --- pyproject.toml | 2 +- uv.lock | 42 ++++++++++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e1ea53a..cc8fd7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "anyio>=4.0.0", "appwrite-console>=0.10.0,<0.11", "docstring-parser>=0.16", - "mcp[cli]>=2,<3", + "mcp[cli]>=2.3,<3", "python-dotenv>=1.0.1", "starlette>=0.40.0", "uvicorn[standard]>=0.30.0", diff --git a/uv.lock b/uv.lock index ab785b8..63050ff 100644 --- a/uv.lock +++ b/uv.lock @@ -426,15 +426,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.9.1" +version = "2.13.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" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] [[package]] @@ -490,18 +490,28 @@ wheels = [ [[package]] name = "httpx2" -version = "2.9.1" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -626,7 +636,7 @@ wheels = [ [[package]] name = "mcp" -version = "2.0.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -644,9 +654,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [package.optional-dependencies] @@ -699,7 +709,7 @@ 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 = ">=2,<3" }, + { name = "mcp", extras = ["cli"], specifier = ">=2.3,<3" }, { name = "numpy", specifier = ">=2.0" }, { name = "openai", specifier = ">=1.40" }, { name = "opentelemetry-api", specifier = ">=1.28" }, @@ -724,15 +734,15 @@ dev = [ [[package]] name = "mcp-types" -version = "2.0.0" +version = "2.3.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" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, ] [[package]] From 1164fbaa2ddaff98931fe61bcba4deec5fd1e862 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 9 Oct 2026 09:59:40 +0100 Subject: [PATCH 2/4] test: acknowledge notification POSTs on the 2026-07-28 HTTP entry Drive the real hosted app and assert a POSTed notifications/cancelled is answered with 202 on both / and /mcp. mcp 2.0.0 rejected it with a 400 JSON-RPC error (python-sdk#3326). --- tests/unit/test_http_app.py | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/unit/test_http_app.py b/tests/unit/test_http_app.py index 2f126e4..194c0b7 100644 --- a/tests/unit/test_http_app.py +++ b/tests/unit/test_http_app.py @@ -6,6 +6,7 @@ import unittest from unittest import mock +from mcp.server.auth.provider import AccessToken from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader from starlette.testclient import TestClient @@ -337,6 +338,49 @@ def test_openai_challenge_is_public_plain_text(self): self.assertEqual(media_type, "text/plain") +class ModernHttpEntryTests(unittest.TestCase): + """The real hosted app on the stateless 2026-07-28 HTTP entry, with the + bearer check satisfied by a stub verifier.""" + + HEADERS = { + "Authorization": "Bearer test-token", + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2026-07-28", + } + + def setUp(self): + from mcp_server_appwrite import server as server_module + + original_transport = server_module._UPLOAD_TRANSPORT + self.addCleanup(setattr, server_module, "_UPLOAD_TRANSPORT", original_transport) + + async def verify_token(_verifier, token): + return AccessToken(token=token, client_id="test-client", scopes=[]) + + patcher = mock.patch.object( + auth.AppwriteTokenVerifier, "verify_token", verify_token + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_cancelled_notification_is_acknowledged_with_202(self): + # Clients still POST notifications such as ``notifications/cancelled`` + # on this entry; they must be acknowledged, not rejected with a 400. + body = { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": 1, "reason": "user aborted"}, + } + + with TestClient(build_app()) as client: + for path in ("/", "/mcp"): + with self.subTest(path=path): + response = client.post(path, json=body, headers=self.HEADERS) + self.assertEqual(response.status_code, 202, response.text) + self.assertEqual(response.content, b"") + + class ConsoleOverrideTests(unittest.TestCase): """The MCP_CONSOLE_URL tester flag: discovery rewrites plus the local authorize proxy that sends login/consent to an alternative console.""" From bdc9e078b9eaab8dad6ecb2aa703c4dce52e0062 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 9 Oct 2026 09:59:56 +0100 Subject: [PATCH 3/4] fix: look up tool schemas directly for Mcp-Param validation On the 2026-07-28 HTTP entry the SDK validates Mcp-Param-* headers on every tools/call. Without Server(get_tool_input_schema=...) it does that by running our tools/list handler, which recorded a phantom tools/list in telemetry for each tool call. mcp 2.3.0 (python-sdk#3630) lets the server answer the lookup by name instead. --- src/mcp_server_appwrite/server.py | 10 +++++++++ tests/unit/test_http_app.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index 84bbf6f..51a164c 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -1494,6 +1494,15 @@ async def handle_read_resource( ] ) + def tool_input_schema(name: str) -> dict[str, Any] | None: + # Mcp-Param-* header validation on the 2026-07-28 HTTP entry needs the + # called tool's schema. Without this lookup the SDK runs our tools/list + # handler on every tools/call, double-counting tools/list in telemetry. + for tool in operator.get_public_tools(): + if tool.name == name: + return tool.input_schema + return None + return Server( "Appwrite MCP Server", version=SERVER_VERSION, @@ -1506,6 +1515,7 @@ async def handle_read_resource( on_list_resource_templates=handle_list_resource_templates, on_read_resource=handle_read_resource, on_subscriptions_listen=subscriptions, + get_tool_input_schema=tool_input_schema, ) diff --git a/tests/unit/test_http_app.py b/tests/unit/test_http_app.py index 194c0b7..ef2bbd4 100644 --- a/tests/unit/test_http_app.py +++ b/tests/unit/test_http_app.py @@ -380,6 +380,40 @@ def test_cancelled_notification_is_acknowledged_with_202(self): self.assertEqual(response.status_code, 202, response.text) self.assertEqual(response.content, b"") + def test_tool_call_does_not_run_an_internal_tools_list(self): + # Mcp-Param-* header validation needs the called tool's input schema. + # Without a direct lookup the SDK runs our tools/list handler for every + # tools/call, which double-counts tools/list in telemetry. + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "appwrite_search_tools", + "arguments": {"query": "create row", "limit": 1}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + } + headers = { + **self.HEADERS, + "Mcp-Method": "tools/call", + "Mcp-Name": "appwrite_search_tools", + } + + with ( + mock.patch.object(telemetry, "record_message") as record_message, + TestClient(build_app()) as client, + ): + response = client.post("/", json=body, headers=headers) + + self.assertEqual(response.status_code, 200, response.text) + self.assertNotIn('"isError":true', response.text) + methods = [call.args[0] for call in record_message.call_args_list] + self.assertEqual(methods, ["tools/call"]) + class ConsoleOverrideTests(unittest.TestCase): """The MCP_CONSOLE_URL tester flag: discovery rewrites plus the local From 40a828090a9cf02416da243b7fb02f51041d5c3c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 9 Oct 2026 10:00:02 +0100 Subject: [PATCH 4/4] fix: build the stdio initialize result from the server run_stdio hand-built InitializationOptions, so stdio clients got a different serverInfo name than HTTP clients, no instructions, icons or website URL, and an empty experimental capability that mcp 2.3.0 now omits everywhere else (python-sdk#3614). Use Server.create_initialization_options() like the HTTP transport does. --- src/mcp_server_appwrite/server.py | 13 ++++-------- tests/unit/test_server.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index 51a164c..a927abd 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -37,7 +37,6 @@ 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.models import InitializationOptions from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler from mcp.types import CLIENT_INFO_META_KEY, INVALID_PARAMS @@ -1777,17 +1776,13 @@ async def run_stdio() -> None: async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): _log_startup("MCP transport: stdio") _log_startup("Appwrite MCP server ready") + # Derive the initialize result from the server itself so stdio reports + # the same identity, instructions and icons as the HTTP transport, and + # leaves out the empty ``experimental`` capability like mcp>=2.3 does. await server.run( read_stream, write_stream, - InitializationOptions( - server_name="appwrite", - server_version=SERVER_VERSION, - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), + server.create_initialization_options(NotificationOptions()), ) diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 676be81..4e5eec8 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -1,5 +1,6 @@ import asyncio import base64 +import contextlib import io import json import os @@ -225,6 +226,38 @@ def test_build_mcp_server_reports_appwrite_metadata(self): ], ) + def test_stdio_initialize_matches_server_metadata(self): + captured = [] + + async def capture_run(_server, _read, _write, options, *args, **kwargs): + captured.append(options) + + @contextlib.asynccontextmanager + async def fake_stdio_server(): + yield (Mock(), Mock()) + + with ( + patch.object(server_module, "load_appwrite_config", return_value=Mock()), + patch.object(server_module, "build_client", return_value=Mock()), + patch.object(server_module, "register_services", return_value=Mock()), + patch.object(server_module, "validate_services"), + patch.object(server_module, "build_operator", return_value=Mock()), + patch.object(server_module.Server, "run", capture_run), + patch("mcp.server.stdio.stdio_server", fake_stdio_server), + ): + asyncio.run(server_module.run_stdio()) + + (options,) = captured + reference = build_mcp_server(Mock(), transport="stdio") + self.assertEqual(options.server_name, reference.name) + self.assertEqual(options.instructions, reference.instructions) + self.assertEqual(options.website_url, server_module.SERVER_WEBSITE_URL) + self.assertIsNotNone(options.icons) + # mcp>=2.3 omits an unconfigured ``experimental`` capability; stdio must + # not reintroduce an empty one. + self.assertIsNone(options.capabilities.experimental) + self.assertIsNotNone(options.capabilities.tools) + def test_http_tool_execution_does_not_block_event_loop(self): class BlockingOperator: def execute_public_tool(self, name, arguments):