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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 14 additions & 9 deletions src/mcp_server_appwrite/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1494,6 +1493,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,
Expand All @@ -1506,6 +1514,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,
)


Expand Down Expand Up @@ -1767,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()),
)


Expand Down
78 changes: 78 additions & 0 deletions tests/unit/test_http_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -337,6 +338,83 @@ 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"")

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
authorize proxy that sends login/consent to an alternative console."""
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/test_server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import base64
import contextlib
import io
import json
import os
Expand Down Expand Up @@ -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):
Expand Down
42 changes: 26 additions & 16 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading