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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 10 additions & 5 deletions src/mcp_server_appwrite/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import re
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down
22 changes: 21 additions & 1 deletion src/mcp_server_appwrite/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 28 additions & 1 deletion tests/unit/test_operator.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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()
26 changes: 26 additions & 0 deletions tests/unit/test_server.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions uv.lock

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

Loading