diff --git a/pyproject.toml b/pyproject.toml index 1ff2303..28adf7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "MCP (Model Context Protocol) server for Appwrite" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "anyio>=4.0.0", "appwrite>=21.0.0,<22", "docstring-parser>=0.16", "mcp[cli]>=1.12.0", diff --git a/src/mcp_server_appwrite/operator.py b/src/mcp_server_appwrite/operator.py index 681c059..db277af 100644 --- a/src/mcp_server_appwrite/operator.py +++ b/src/mcp_server_appwrite/operator.py @@ -2,6 +2,7 @@ import json import re +import threading import time from collections import OrderedDict from dataclasses import dataclass @@ -74,12 +75,15 @@ class ResultStore: def __init__(self, max_size: int = RESULT_STORE_SIZE): self._entries: OrderedDict[str, StoredResult] = OrderedDict() self._max_size = max_size + self._lock = threading.Lock() def get(self, result_id: str) -> StoredResult | None: - return self._entries.get(result_id) + with self._lock: + return self._entries.get(result_id) def list(self) -> list[StoredResult]: - return list(self._entries.values()) + with self._lock: + return list(self._entries.values()) def save( self, tool_name: str, content: list[ToolContent], text: str @@ -91,9 +95,10 @@ def save( text=text, tool_name=tool_name, ) - self._entries[result.result_id] = result - while len(self._entries) > self._max_size: - self._entries.popitem(last=False) + with self._lock: + self._entries[result.result_id] = result + while len(self._entries) > self._max_size: + self._entries.popitem(last=False) return result diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index c6ded89..71ae1a9 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -27,6 +27,7 @@ import httpx import mcp.server.stdio import mcp.types as types +from anyio import to_thread from appwrite.client import Client from appwrite.enums.browser import Browser from appwrite.exception import AppwriteException @@ -988,7 +989,9 @@ async def handle_call_tool( try: if not operator.has_public_tool(name): raise ValueError(f"Tool {name} not found") - result = operator.execute_public_tool(name, arguments) + result = await _execute_public_tool_for_transport( + operator, name, arguments, transport + ) except Exception: telemetry.record_request("tools/call", "error", time.monotonic() - start) raise @@ -1031,6 +1034,23 @@ async def handle_read_resource(uri) -> list[ReadResourceContents]: return server +async def _execute_public_tool_for_transport( + operator: Operator, + name: str, + arguments: dict | None, + transport: str, +) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: + if transport != "http": + return operator.execute_public_tool(name, arguments) + + # The Appwrite Python SDK, docs embedding client, context discovery, and URL + # upload fetches are synchronous. Running them on the ASGI event-loop thread + # can make even /healthz stop responding while a tool call is slow or stuck. + return await to_thread.run_sync( + operator.execute_public_tool, name, arguments, abandon_on_cancel=True + ) + + def _emit_initialize(server: Server) -> None: """Emit an ``mcp.initializations`` event and refresh active-user/client tracking for the current session. Deduped per session in the telemetry layer. Best-effort: diff --git a/tests/unit/test_operator.py b/tests/unit/test_operator.py index e20d4d7..dcbadd5 100644 --- a/tests/unit/test_operator.py +++ b/tests/unit/test_operator.py @@ -1,8 +1,9 @@ import unittest +from concurrent.futures import ThreadPoolExecutor import mcp.types as types -from mcp_server_appwrite.operator import CATALOG_URI, Operator +from mcp_server_appwrite.operator import CATALOG_URI, Operator, ResultStore from mcp_server_appwrite.tool_manager import ToolManager @@ -329,5 +330,31 @@ def test_store_results_false_returns_image_inline(self): self.assertEqual(result[0].mimeType, "image/png") +class ResultStoreTests(unittest.TestCase): + def test_concurrent_save_and_list_are_thread_safe(self): + store = ResultStore(max_size=50) + content = [types.TextContent(type="text", text="ok")] + + def save_many(): + for index in range(500): + store.save("tables_db_list", content, f"result {index}") + + def list_many(): + for _ in range(500): + store.list() + + with ThreadPoolExecutor(max_workers=4) as executor: + futures = [ + executor.submit(save_many), + executor.submit(save_many), + executor.submit(list_many), + executor.submit(list_many), + ] + for future in futures: + future.result() + + self.assertLessEqual(len(store.list()), 50) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 364b3af..0b646a5 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -1,8 +1,10 @@ +import asyncio import base64 import io import os import sys import tempfile +import time import unittest from pathlib import Path from unittest.mock import Mock, patch @@ -15,6 +17,7 @@ from mcp_server_appwrite.server import ( _coerce_argument, _configure_uploads, + _execute_public_tool_for_transport, _format_tool_result, _prepare_arguments, _validate_service, @@ -107,6 +110,29 @@ def test_build_instructions_are_transport_specific(self): self.assertIn("Large results are stored as resources", stdio) self.assertIn("returns tool results inline", http) + def test_http_tool_execution_does_not_block_event_loop(self): + class BlockingOperator: + def execute_public_tool(self, name, arguments): + time.sleep(0.2) + return [types.TextContent(type="text", text="ok")] + + async def run_check(): + start = time.monotonic() + task = asyncio.create_task( + _execute_public_tool_for_transport( + BlockingOperator(), "appwrite_call_tool", {}, "http" + ) + ) + + await asyncio.sleep(0.01) + + self.assertLess(time.monotonic() - start, 0.1) + self.assertFalse(task.done()) + result = await task + self.assertEqual(result[0].text, "ok") + + asyncio.run(run_check()) + def test_coerce_input_file_from_path(self): with tempfile.NamedTemporaryFile(suffix=".txt") as handle: coerced = _coerce_argument("file", handle.name, InputFile) diff --git a/uv.lock b/uv.lock index c90d1aa..f7a58b8 100644 --- a/uv.lock +++ b/uv.lock @@ -642,6 +642,7 @@ name = "mcp-server-appwrite" version = "0.8.6" source = { editable = "." } dependencies = [ + { name = "anyio" }, { name = "appwrite" }, { name = "docstring-parser" }, { name = "httpx" }, @@ -676,6 +677,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anyio", specifier = ">=4.0.0" }, { name = "appwrite", specifier = ">=21.0.0,<22" }, { name = "argon2-cffi", marker = "extra == 'integration'", specifier = ">=23.1.0" }, { name = "bcrypt", marker = "extra == 'integration'", specifier = ">=4.1.2" },