From 8c50a03029c325377b26f97e887d5c11c290ce88 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 24 Jun 2026 16:53:28 +0530 Subject: [PATCH 1/4] Add Appwrite context overview tool --- README.md | 4 +- src/mcp_server_appwrite/context.py | 319 ++++++++++++++++++++++++++++ src/mcp_server_appwrite/operator.py | 51 ++++- src/mcp_server_appwrite/server.py | 49 ++++- tests/unit/test_context.py | 120 +++++++++++ tests/unit/test_operator.py | 24 ++- 6 files changed, 558 insertions(+), 9 deletions(-) create mode 100644 src/mcp_server_appwrite/context.py create mode 100644 tests/unit/test_context.py diff --git a/README.md b/README.md index 29f3250..eb5a4bd 100644 --- a/README.md +++ b/README.md @@ -92,11 +92,13 @@ The MCP server validates the bearer access token on every request and forwards i The server starts in a compact workflow so the MCP client only sees a small operator-style surface while the full Appwrite catalog stays internal. -- Up to 3 MCP tools are exposed to the model: +- Up to 4 MCP tools are exposed to the model: + - `appwrite_get_context` - `appwrite_search_tools` - `appwrite_call_tool` - `appwrite_search_docs` — semantic search over the Appwrite documentation (only registered when the docs index and `OPENAI_API_KEY` are present; see [Documentation search](#documentation-search)). - The full Appwrite tool catalog stays internal and is searched at runtime. +- `appwrite_get_context` gives the client a quick workspace summary. With a local project API key it returns the configured project and readable service totals/samples. With hosted OAuth it also includes account, organization, and discovered project context. - Large tool outputs are stored as MCP resources and returned as preview text plus a resource URI. - Mutating hidden tools require `confirm_write=true`. - Every Appwrite service the installed SDK ships is registered automatically — 25 in total, each becoming a tool-name prefix: `account`, `activities`, `advisor`, `apps`, `avatars`, `backups`, `databases`, `functions`, `graphql`, `health`, `locale`, `messaging`, `oauth2`, `organization`, `presences`, `project`, `proxy`, `sites`, `storage`, `tables_db`, `teams`, `tokens`, `usage`, `users`, and `webhooks`. Which ones a given user can actually call is gated by the scopes their OAuth token was granted (enforced per-route by the Appwrite API), not by the catalog. diff --git a/src/mcp_server_appwrite/context.py b/src/mcp_server_appwrite/context.py new file mode 100644 index 0000000..915c9ca --- /dev/null +++ b/src/mcp_server_appwrite/context.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from appwrite.client import Client +from appwrite.exception import AppwriteException +from appwrite.models.bucket import Bucket +from appwrite.models.database import Database +from appwrite.models.function import Function +from appwrite.models.message import Message +from appwrite.models.project import Project +from appwrite.models.site import Site +from appwrite.models.team import Team +from appwrite.models.user import User +from appwrite.query import Query + +ContextClientFactory = Callable[[str | None, str | None], Client] + +SERVICE_PROBES = { + "tablesdb": { + "path": "/tablesdb", + "items_key": "databases", + "model": Database, + }, + "users": { + "path": "/users", + "items_key": "users", + "model": User, + }, + "storage": { + "path": "/storage/buckets", + "items_key": "buckets", + "model": Bucket, + }, + "functions": { + "path": "/functions", + "items_key": "functions", + "model": Function, + }, + "sites": { + "path": "/sites", + "items_key": "sites", + "model": Site, + }, + "messaging": { + "path": "/messaging/messages", + "items_key": "messages", + "model": Message, + }, + "teams": { + "path": "/teams", + "items_key": "teams", + "model": Team, + }, +} + +REDACTED_KEYS = {"password", "secret", "key", "token", "otp", "cookie", "session"} + + +def get_appwrite_context( + base_client: Client, + *, + mode: str, + client_factory: ContextClientFactory | None = None, + project_id: str | None = None, + organization_id: str | None = None, + include_services: bool = True, + sample_limit: int = 5, +) -> dict[str, Any]: + sample_limit = max(1, min(int(sample_limit), 25)) + client_factory = client_factory or (lambda _project_id, _organization_id: base_client) + + context: dict[str, Any] = { + "connection": { + "mode": mode, + "endpoint": getattr(base_client, "_endpoint", ""), + }, + "projects": [], + } + + if mode == "api_key_project": + configured_project_id = project_id or base_client.get_config("project") + context["connection"]["projectId"] = configured_project_id + project_client = client_factory(configured_project_id, None) + project = _get_current_project(project_client, configured_project_id) + if project is None: + project = {"$id": configured_project_id} + context["projects"] = [ + _project_context( + project, + project_client, + include_services=include_services, + sample_limit=sample_limit, + ) + ] + return context + + console_client = client_factory(None, None) + account = _safe_call(console_client, "get", "/account") + if account.ok and isinstance(account.value, dict): + context["account"] = _compact_document(_model_dict(account.value, User)) + elif not account.ok: + context["account"] = {"error": account.error} + + organizations = _list_organizations(console_client, organization_id) + context["organizations"] = organizations + + projects: list[dict[str, Any]] = [] + if organization_id and not organizations: + organizations = [{"$id": organization_id}] + + for organization in organizations: + org_id = organization.get("$id") + if not isinstance(org_id, str) or not org_id: + continue + org_client = client_factory(None, org_id) + org_projects = _list_projects_for_organization(org_client) + for project in org_projects: + if not isinstance(project, dict): + continue + discovered_project_id = project.get("$id") + if project_id and discovered_project_id != project_id: + continue + project_client = client_factory(discovered_project_id, org_id) + projects.append( + _project_context( + project, + project_client, + organization_id=org_id, + include_services=include_services, + sample_limit=sample_limit, + ) + ) + + if project_id and not projects: + project_client = client_factory(project_id, organization_id) + project = _get_current_project(project_client, project_id) or {"$id": project_id} + projects.append( + _project_context( + project, + project_client, + organization_id=organization_id, + include_services=include_services, + sample_limit=sample_limit, + ) + ) + + context["projects"] = projects + return context + + +class _CallResult: + def __init__(self, ok: bool, value: Any = None, error: str | None = None) -> None: + self.ok = ok + self.value = value + self.error = error + + +def _safe_call( + client: Client, method: str, path: str, params: dict[str, Any] | None = None +) -> _CallResult: + try: + headers = {"accept": "application/json"} + project_id = client.get_config("project") + if project_id: + headers["X-Appwrite-Project"] = project_id + return _CallResult( + True, client.call(method, path, headers=headers, params=params or {}) + ) + except AppwriteException as exc: + details = [] + if getattr(exc, "code", None): + details.append(f"code={exc.code}") + if getattr(exc, "type", None): + details.append(f"type={exc.type}") + suffix = f" ({', '.join(details)})" if details else "" + return _CallResult(False, error=f"{exc}{suffix}") + + +def _get_current_project(client: Client, project_id: str) -> dict[str, Any] | None: + result = _safe_call( + client, + "get", + "/project", + params={}, + ) + if result.ok and isinstance(result.value, dict): + return result.value + return {"$id": project_id, "error": result.error} if not result.ok else None + + +def _list_organizations( + client: Client, organization_id: str | None +) -> list[dict[str, Any]]: + result = _safe_call(client, "get", "/organizations") + if not result.ok or not isinstance(result.value, dict): + return [{"$id": organization_id, "error": result.error}] if organization_id else [] + + teams = result.value.get("teams") + if not isinstance(teams, list): + return [] + organizations = [ + _compact_document(_model_dict(team, Team)) for team in teams if isinstance(team, dict) + ] + if organization_id: + organizations = [ + organization + for organization in organizations + if organization.get("$id") == organization_id + ] + return organizations + + +def _list_projects_for_organization(client: Client) -> list[dict[str, Any]]: + result = _safe_call( + client, + "get", + "/organization/projects", + {"queries": [Query.limit(100)]}, + ) + if not result.ok or not isinstance(result.value, dict): + return [] + projects = result.value.get("projects") + return projects if isinstance(projects, list) else [] + + +def _project_context( + project: dict[str, Any], + client: Client, + *, + organization_id: str | None = None, + include_services: bool, + sample_limit: int, +) -> dict[str, Any]: + project_summary = _compact_document(_model_dict(project, Project)) + if organization_id: + project_summary["organizationId"] = organization_id + if "error" in project: + project_summary["error"] = project["error"] + if include_services: + project_summary["services"] = _summarize_services(client, sample_limit) + return project_summary + + +def _summarize_services(client: Client, sample_limit: int) -> dict[str, Any]: + services: dict[str, Any] = {} + params = {"queries": [Query.limit(sample_limit)]} + for service_name, probe in SERVICE_PROBES.items(): + result = _safe_call(client, "get", str(probe["path"]), params) + if not result.ok: + services[service_name] = {"error": result.error} + continue + payload = result.value if isinstance(result.value, dict) else {} + items = payload.get(str(probe["items_key"]), []) + if not isinstance(items, list): + items = [] + services[service_name] = { + "total": payload.get("total", len(items)), + "items": [ + _compact_document(_model_dict(item, probe["model"])) + for item in items + if isinstance(item, dict) + ], + } + return services + + +def _model_dict(source: dict[str, Any], model_type: type) -> dict[str, Any]: + try: + if model_type is User: + model = User.with_data(source) + elif model_type is Team: + model = Team.with_data(source) + else: + model = model_type.from_dict(source) + if model is not None and hasattr(model, "to_dict"): + return model.to_dict() + except Exception: + pass + return source + + +def _compact_document(source: dict[str, Any]) -> dict[str, Any]: + candidates: dict[str, Any] = {} + for key, value in source.items(): + if _is_sensitive_key(key): + continue + if value is None or value == "" or value is False or value == 0: + continue + if isinstance(value, (str, int, float, bool)): + candidates[key] = value + elif key in {"prefs"} and isinstance(value, dict): + candidates[key] = value + + compact: dict[str, Any] = {} + for key in sorted(candidates, key=_summary_key_rank): + compact[key] = candidates[key] + if len(compact) >= 12: + break + return compact + + +def _is_sensitive_key(key: str) -> bool: + normalized = key.lower() + return any(marker in normalized for marker in REDACTED_KEYS) + + +def _summary_key_rank(key: str) -> tuple[int, str]: + normalized = key.lower().replace("$", "") + if normalized in {"id", "name", "email", "status", "region", "total"}: + return (0, key) + if normalized.endswith("id"): + return (1, key) + if normalized in {"createdat", "updatedat", "accessedat"}: + return (2, key) + if normalized in {"enabled", "runtime", "framework", "type"}: + return (3, key) + return (4, key) diff --git a/src/mcp_server_appwrite/operator.py b/src/mcp_server_appwrite/operator.py index 76a0402..b61981a 100644 --- a/src/mcp_server_appwrite/operator.py +++ b/src/mcp_server_appwrite/operator.py @@ -31,6 +31,7 @@ ToolExecutor = Callable[ [str, dict[str, Any], str | None, str | None], list[ToolContent] ] +ContextProvider = Callable[[dict[str, Any]], dict[str, Any]] @dataclass(frozen=True) @@ -99,12 +100,14 @@ def __init__( execute_tool: ToolExecutor, *, docs_search: DocsSearch | None = None, + context_provider: ContextProvider | None = None, preview_threshold: int = PREVIEW_THRESHOLD, search_limit: int = SEARCH_LIMIT, ): self._tools_manager = tools_manager self._execute_tool = execute_tool self._docs_search = docs_search + self._context_provider = context_provider self._preview_threshold = preview_threshold self._search_limit = search_limit self._result_store = ResultStore() @@ -117,6 +120,39 @@ def get_catalog_resource_uri(self) -> str: def get_public_tools(self) -> list[types.Tool]: tools = [ + types.Tool( + name="appwrite_get_context", + description=( + "Get an adaptive Appwrite account/project context summary, including " + "available projects and per-project service counts where the current " + "connection can read them. Use this before searching the hidden catalog " + "when orienting to a user's Appwrite workspace." + ), + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Optional project ID to focus the context summary.", + }, + "organization_id": { + "type": "string", + "description": "Optional organization ID to focus project discovery.", + }, + "include_services": { + "type": "boolean", + "description": "Include per-project service totals and small item samples. Defaults to true.", + }, + "sample_limit": { + "type": "integer", + "minimum": 1, + "maximum": 25, + "description": "Maximum sample items per service. Defaults to 5.", + }, + }, + "additionalProperties": False, + }, + ), types.Tool( name="appwrite_search_tools", description=( @@ -184,7 +220,7 @@ def get_public_tools(self) -> list[types.Tool]: "Appwrite project ID to act on (sent as X-Appwrite-Project). " "The connection authenticates against the Appwrite console, which " "can list your projects/organizations but holds no data — so " - "project-scoped tools (databases, tables, users, storage, " + "project-scoped tools (TablesDB, tables, users, storage, " "functions, messaging, sites) require this. Discover a project " "first, then pass its id. Omit for console/account-level tools." ), @@ -210,7 +246,7 @@ def get_public_tools(self) -> list[types.Tool]: return tools def has_public_tool(self, name: str) -> bool: - names = {"appwrite_search_tools", "appwrite_call_tool"} + names = {"appwrite_get_context", "appwrite_search_tools", "appwrite_call_tool"} if self._docs_search is not None: names.add(self._docs_search.get_tool().name) return name in names @@ -218,6 +254,8 @@ def has_public_tool(self, name: str) -> bool: def execute_public_tool( self, name: str, arguments: dict[str, Any] | None ) -> list[ToolContent]: + if name == "appwrite_get_context": + return self._get_context(arguments or {}) if name == "appwrite_search_tools": return self._search_tools(arguments or {}) if name == "appwrite_call_tool": @@ -227,6 +265,15 @@ def execute_public_tool( return self._preview_or_store_result(name, content) raise ValueError(f"Unknown public Appwrite tool {name}") + def _get_context(self, arguments: dict[str, Any]) -> list[ToolContent]: + if self._context_provider is None: + raise RuntimeError("Appwrite context provider is not configured.") + context = self._context_provider(arguments) + return self._preview_or_store_result( + "appwrite_get_context", + [types.TextContent(type="text", text=json.dumps(context, indent=2))], + ) + def list_resources(self) -> list[types.Resource]: resources = [ types.Resource( diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index e2b76d6..e9bf3a4 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -32,6 +32,7 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.models import InitializationOptions +from .context import get_appwrite_context from .docs_search import DocsSearch from .operator import Operator from .service import Service @@ -655,7 +656,9 @@ def _format_appwrite_error(exc: AppwriteException) -> str: def build_instructions(transport: str = "http") -> str: common = ( - "Appwrite workflow: use appwrite_search_tools first, then appwrite_call_tool. " + "Appwrite workflow: use appwrite_get_context to understand the current " + "connection and available project resources, then use appwrite_search_tools " + "and appwrite_call_tool for specific operations. " "Mutating hidden tools require confirm_write=true. " "For questions about Appwrite concepts, products, or guides, use " "appwrite_search_docs to search the documentation when available. " @@ -673,9 +676,9 @@ def build_instructions(transport: str = "http") -> str: return ( "You authenticate against the Appwrite console, which can list your " "organizations and projects but stores no project data itself. Project-scoped " - "tools (databases, tables, users, storage, functions, messaging, sites) need a " - "target project: first discover one (search for and call a tool that lists " - "projects), then pass its id as project_id to appwrite_call_tool. " + "tools (TablesDB, tables, users, storage, functions, messaging, sites) need a " + "target project: use appwrite_get_context first, then pass the selected " + "project id as project_id to appwrite_call_tool. " "Organization-scoped console tools (e.g. creating a project) need organization_id. " f"{common}" ) @@ -742,10 +745,48 @@ def build_operator( target_project=target_project, organization_id=organization_id, ), + context_provider=lambda arguments: _get_context_for_request(arguments, client), docs_search=docs_search, ) +def _get_context_for_request( + arguments: dict[str, Any], client: Client | None = None +) -> dict[str, Any]: + project_id = arguments.get("project_id", arguments.get("projectId")) + organization_id = arguments.get("organization_id", arguments.get("organizationId")) + include_services = bool( + arguments.get("include_services", arguments.get("includeServices", True)) + ) + sample_limit = int(arguments.get("sample_limit", arguments.get("sampleLimit", 5))) + + if client is not None: + return get_appwrite_context( + client, + mode="api_key_project", + project_id=project_id, + include_services=include_services, + sample_limit=sample_limit, + ) + + base_client = resolve_client() + + def client_factory( + target_project: str | None, target_organization: str | None + ) -> Client: + return resolve_client(target_project, target_organization) + + return get_appwrite_context( + base_client, + mode="oauth_console", + client_factory=client_factory, + project_id=project_id, + organization_id=organization_id, + include_services=include_services, + sample_limit=sample_limit, + ) + + def build_catalog_tools_manager() -> ToolManager: """Build the tool catalog/schema once from SDK introspection. Credentials arrive per request (OAuth) rather than at startup, so a credential-less client suffices.""" diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py new file mode 100644 index 0000000..84d7a29 --- /dev/null +++ b/tests/unit/test_context.py @@ -0,0 +1,120 @@ +import unittest + +from appwrite.client import Client + +from mcp_server_appwrite.context import get_appwrite_context + + +class FakeClient(Client): + def __init__(self, responses, *, endpoint="https://example.test/v1", project=""): + super().__init__() + self._responses = responses + self._endpoint = endpoint + self.set_project(project) + self.calls = [] + + def call(self, method, path="", headers=None, params=None, response_type="json"): + self.calls.append((method, path, params or {})) + response = self._responses.get((method, path)) + if callable(response): + return response(params or {}) + if response is None: + return {"total": 0} + return response + + +class AppwriteContextTests(unittest.TestCase): + def test_api_key_project_context_summarizes_configured_project(self): + client = FakeClient( + { + ("get", "/project"): { + "$id": "project-1", + "name": "Project One", + "teamId": "team-1", + }, + ("get", "/tablesdb"): { + "total": 1, + "databases": [{"$id": "db-1", "name": "Main"}], + }, + ("get", "/users"): {"total": 2, "users": []}, + }, + project="project-1", + ) + + context = get_appwrite_context(client, mode="api_key_project") + + self.assertEqual(context["connection"]["mode"], "api_key_project") + self.assertEqual(context["connection"]["projectId"], "project-1") + self.assertNotIn("organizations", context) + self.assertNotIn("account", context) + self.assertEqual(context["projects"][0]["$id"], "project-1") + self.assertEqual(context["projects"][0]["services"]["tablesdb"]["total"], 1) + self.assertEqual(context["projects"][0]["services"]["users"]["total"], 2) + + def test_oauth_context_discovers_organizations_projects_and_services(self): + console = FakeClient( + { + ("get", "/account"): { + "$id": "user-1", + "name": "Ada", + "email": "ada@example.test", + }, + ("get", "/organizations"): { + "total": 1, + "teams": [{"$id": "org-1", "name": "Org One"}], + }, + } + ) + org = FakeClient( + { + ("get", "/organization/projects"): { + "total": 1, + "projects": [ + { + "$id": "project-1", + "name": "Project One", + "teamId": "org-1", + } + ], + } + } + ) + project = FakeClient( + { + ("get", "/tablesdb"): {"total": 0, "databases": []}, + ("get", "/functions"): { + "total": 1, + "functions": [{"$id": "fn-1", "name": "Worker"}], + }, + }, + project="project-1", + ) + seen = [] + + def factory(project_id, organization_id): + seen.append((project_id, organization_id)) + if project_id: + return project + if organization_id: + return org + return console + + context = get_appwrite_context( + console, + mode="oauth_console", + client_factory=factory, + sample_limit=3, + ) + + self.assertEqual(context["account"]["email"], "ada@example.test") + self.assertEqual(context["organizations"][0]["$id"], "org-1") + self.assertEqual(context["projects"][0]["$id"], "project-1") + self.assertEqual(context["projects"][0]["organizationId"], "org-1") + self.assertEqual(context["projects"][0]["services"]["functions"]["total"], 1) + self.assertIn((None, None), seen) + self.assertIn((None, "org-1"), seen) + self.assertIn(("project-1", "org-1"), seen) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_operator.py b/tests/unit/test_operator.py index d540245..5241073 100644 --- a/tests/unit/test_operator.py +++ b/tests/unit/test_operator.py @@ -114,7 +114,10 @@ def make_runtime_with_docs(self, docs_search): def test_docs_tool_absent_without_docs_search(self): runtime = self.make_runtime(lambda name, arguments, *_: []) names = {tool.name for tool in runtime.get_public_tools()} - self.assertEqual(names, {"appwrite_search_tools", "appwrite_call_tool"}) + self.assertEqual( + names, + {"appwrite_get_context", "appwrite_search_tools", "appwrite_call_tool"}, + ) self.assertFalse(runtime.has_public_tool("appwrite_search_docs")) def test_docs_tool_listed_and_dispatched(self): @@ -122,7 +125,7 @@ def test_docs_tool_listed_and_dispatched(self): runtime = self.make_runtime_with_docs(docs) tools = runtime.get_public_tools() - self.assertEqual(len(tools), 3) + self.assertEqual(len(tools), 4) self.assertIn("appwrite_search_docs", {tool.name for tool in tools}) self.assertTrue(runtime.has_public_tool("appwrite_search_docs")) @@ -153,6 +156,23 @@ def test_search_tools_returns_ranked_match(self): self.assertIn("tables_db_list", result[0].text) self.assertIn(CATALOG_URI, result[0].text) + def test_get_context_dispatches_provider(self): + runtime = Operator( + ToolManager(), + lambda name, arguments, *_: [], + context_provider=lambda arguments: { + "connection": {"mode": "api_key_project"}, + "projects": [{"$id": arguments["project_id"]}], + }, + ) + + result = runtime.execute_public_tool( + "appwrite_get_context", {"project_id": "project-1"} + ) + + self.assertIn('"mode": "api_key_project"', result[0].text) + self.assertIn('"$id": "project-1"', result[0].text) + def test_search_tools_infers_mutating_search_for_create_query(self): runtime = self.make_runtime(lambda name, arguments, *_: []) From f4b029d5db3dc98cb8247c6260c3ad0ac43afb19 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 24 Jun 2026 17:00:33 +0530 Subject: [PATCH 2/4] (docs): add AGENTS.md and CLAUDE.md contributor guide --- AGENTS.md | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 141 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e5adf9d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,140 @@ +# AGENTS.md + +Guidance for AI agents and human contributors working in this repository. + +## What this repo is + +`mcp-server-appwrite` is a [Model Context Protocol](https://modelcontextprotocol.io) +server for Appwrite. It exposes Appwrite's API to MCP clients as a small set of +operator-style tools, supporting two deployments from one codebase: + +- **Cloud (hosted HTTP):** a Starlette ASGI app that acts as an OAuth 2.1 + Resource Server. It validates the client's bearer token and forwards it to the + Appwrite REST API. Served at `mcp.appwrite.io/mcp`. +- **Self-hosted (`stdio`):** runs locally and authenticates with a project API + key (`APPWRITE_PROJECT_ID`, `APPWRITE_API_KEY`, `APPWRITE_ENDPOINT`). + +Python ≥ 3.12, packaged with `hatchling`, managed with `uv`. + +## Architecture + +Source lives in `src/mcp_server_appwrite/`: + +| File | Responsibility | +| --- | --- | +| `__main__.py` / `server.py` | Entry point, CLI args, transport selection (`--transport stdio\|http`), service registration, low-level MCP server. | +| `http_app.py` | Hosted Streamable-HTTP transport: `/mcp`, RFC 9728 protected-resource metadata, `/healthz`. | +| `auth.py` | OAuth 2.1 resource-server layer — bearer-token validation against the project's Appwrite authorization server. | +| `service.py` | `Service` base class: introspects an Appwrite SDK service and turns its methods into MCP tool definitions. | +| `tool_manager.py` | Registry of all services and their generated tools. | +| `operator.py` | The compact "operator" surface — `appwrite_search_tools`, `appwrite_call_tool`, result/resource storage, write confirmation. | +| `context.py` | `appwrite_get_context` — workspace summary (project, services, account/org for OAuth). | +| `docs_search.py` | In-process semantic docs search (`appwrite_search_docs`) over a prebuilt index. | +| `data/` | Committed docs index artifact (`docs_index.npz`, `docs_index_meta.json`), shipped in the wheel/image. | + +`scripts/build_docs_index.py` rebuilds the docs index (requires `OPENAI_API_KEY`). + +### Tool surface (key design point) + +The server boots in a compact workflow: the client sees up to 4 tools +(`appwrite_get_context`, `appwrite_search_tools`, `appwrite_call_tool`, and +optionally `appwrite_search_docs`), while the full Appwrite catalog (25 services) +stays internal and is searched at runtime. Mutating hidden tools require +`confirm_write=true`. Large outputs are stored as MCP resources and returned as a +preview + resource URI. + +## Local development + +```bash +# Install uv, then sync deps +uv sync # runtime deps +uv sync --group dev # + black, ruff (lint/format) +uv sync --extra integration # + integration-test deps + +# Run hosted HTTP transport +MCP_PUBLIC_URL=http://localhost:8000 APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 \ + uv run mcp-server-appwrite --transport http + +# Run self-hosted stdio transport +APPWRITE_ENDPOINT=http://localhost:9501/v1 \ +APPWRITE_PROJECT_ID= APPWRITE_API_KEY= \ + uv run mcp-server-appwrite + +# Or via Docker (hosted HTTP/OAuth) +docker compose up --build # compose.yaml; endpoint at http://localhost:8000/mcp +``` + +## Pre-PR checklist + +Run these locally before opening a PR. They mirror the `CI` workflow +(`.github/workflows/ci.yml`), which runs on every pull request and on pushes to +`main`. **All four jobs must pass.** + +1. **Lint** (`lint` job) + ```bash + uv sync --group dev + uv run --group dev ruff check src tests + ``` + Ruff config: `target-version = py312`, rules `E`, `F`, `W`, `I` (import + sorting), with `E501` (line length) delegated to black. + +2. **Format** (`lint` job) + ```bash + uv run --group dev black --check src tests + ``` + Run `uv run --group dev black src tests` (without `--check`) to auto-fix. + +3. **Unit tests** (`unit` job) + ```bash + uv sync + uv run python -m unittest discover -s tests/unit -v + ``` + Fast, no external services or credentials required. + +4. **Docker build** (`docker` job) + ```bash + docker build -t appwrite-mcp:ci . + ``` + The hosted HTTP image must build cleanly. + +5. **Integration tests** (`integration` job) — *CI runs these only for pushes and + for PRs from branches on the same repo (not forks).* They create and delete + **real** Appwrite resources, so they need live credentials and are skipped + when absent: + ```bash + uv sync --extra integration + APPWRITE_PROJECT_ID= APPWRITE_API_KEY= APPWRITE_ENDPOINT= \ + uv run --extra integration python -m unittest discover -s tests/integration -v + ``` + +### CI environment versions + +CI pins Python `3.12` and `uv` `0.11.22`. Match these locally if you hit +version-specific differences. + +### If you change the docs index + +Rebuilding `src/mcp_server_appwrite/data/` requires `OPENAI_API_KEY`. Re-run the +build script and commit the refreshed artifact: +```bash +OPENAI_API_KEY=sk-... uv run python scripts/build_docs_index.py +``` + +## Other workflows + +- `publish.yml` — package publishing. +- `staging.yml` / `production.yml` — deployment (publishes images to Docker Hub). + +These are not gated on PRs the way `ci.yml` is, but be mindful when touching the +`Dockerfile`, `pyproject.toml` version, or deployment config. + +## Conventions + +- Keep the exposed tool surface small — new Appwrite capabilities should flow + through the operator/catalog mechanism, not become top-level tools. +- New SDK services are registered automatically; you generally don't hand-write + tool definitions. +- Match existing style: black formatting, ruff-clean imports, type hints, module + docstrings explaining intent (see `auth.py`, `http_app.py`, `docs_search.py`). +- Add unit tests under `tests/unit/` for any non-trivial logic; add integration + coverage under `tests/integration/` when touching real API behavior. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 463017ad64da035d0198552ab924673de5b2feca Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 24 Jun 2026 17:02:46 +0530 Subject: [PATCH 3/4] Address context overview review feedback --- src/mcp_server_appwrite/context.py | 32 +++++++++++--- src/mcp_server_appwrite/server.py | 6 ++- tests/unit/test_context.py | 71 ++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/mcp_server_appwrite/context.py b/src/mcp_server_appwrite/context.py index 915c9ca..b48afec 100644 --- a/src/mcp_server_appwrite/context.py +++ b/src/mcp_server_appwrite/context.py @@ -68,8 +68,10 @@ def get_appwrite_context( include_services: bool = True, sample_limit: int = 5, ) -> dict[str, Any]: - sample_limit = max(1, min(int(sample_limit), 25)) - client_factory = client_factory or (lambda _project_id, _organization_id: base_client) + sample_limit = _normalize_sample_limit(sample_limit) + client_factory = client_factory or ( + lambda _project_id, _organization_id: base_client + ) context: dict[str, Any] = { "connection": { @@ -135,7 +137,9 @@ def get_appwrite_context( if project_id and not projects: project_client = client_factory(project_id, organization_id) - project = _get_current_project(project_client, project_id) or {"$id": project_id} + project = _get_current_project(project_client, project_id) or { + "$id": project_id + } projects.append( _project_context( project, @@ -176,6 +180,8 @@ def _safe_call( details.append(f"type={exc.type}") suffix = f" ({', '.join(details)})" if details else "" return _CallResult(False, error=f"{exc}{suffix}") + except Exception as exc: + return _CallResult(False, error=str(exc)) def _get_current_project(client: Client, project_id: str) -> dict[str, Any] | None: @@ -195,13 +201,17 @@ def _list_organizations( ) -> list[dict[str, Any]]: result = _safe_call(client, "get", "/organizations") if not result.ok or not isinstance(result.value, dict): - return [{"$id": organization_id, "error": result.error}] if organization_id else [] + return ( + [{"$id": organization_id, "error": result.error}] if organization_id else [] + ) teams = result.value.get("teams") if not isinstance(teams, list): return [] organizations = [ - _compact_document(_model_dict(team, Team)) for team in teams if isinstance(team, dict) + _compact_document(_model_dict(team, Team)) + for team in teams + if isinstance(team, dict) ] if organization_id: organizations = [ @@ -286,7 +296,9 @@ def _compact_document(source: dict[str, Any]) -> dict[str, Any]: for key, value in source.items(): if _is_sensitive_key(key): continue - if value is None or value == "" or value is False or value == 0: + if value is None or value == "": + continue + if not isinstance(value, bool) and value == 0: continue if isinstance(value, (str, int, float, bool)): candidates[key] = value @@ -317,3 +329,11 @@ def _summary_key_rank(key: str) -> tuple[int, str]: if normalized in {"enabled", "runtime", "framework", "type"}: return (3, key) return (4, key) + + +def _normalize_sample_limit(value: Any) -> int: + try: + limit = int(value) + except (TypeError, ValueError): + limit = 5 + return max(1, min(limit, 25)) diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index e9bf3a4..bec3537 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -32,7 +32,7 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.models import InitializationOptions -from .context import get_appwrite_context +from .context import _normalize_sample_limit, get_appwrite_context from .docs_search import DocsSearch from .operator import Operator from .service import Service @@ -758,7 +758,9 @@ def _get_context_for_request( include_services = bool( arguments.get("include_services", arguments.get("includeServices", True)) ) - sample_limit = int(arguments.get("sample_limit", arguments.get("sampleLimit", 5))) + sample_limit = _normalize_sample_limit( + arguments.get("sample_limit", arguments.get("sampleLimit", 5)) + ) if client is not None: return get_appwrite_context( diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index 84d7a29..ac65c81 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -3,6 +3,7 @@ from appwrite.client import Client from mcp_server_appwrite.context import get_appwrite_context +from mcp_server_appwrite.server import _get_context_for_request class FakeClient(Client): @@ -115,6 +116,76 @@ def factory(project_id, organization_id): self.assertIn((None, "org-1"), seen) self.assertIn(("project-1", "org-1"), seen) + def test_compact_output_preserves_false_resource_state(self): + client = FakeClient( + { + ("get", "/project"): {"$id": "project-1", "name": "Project One"}, + ("get", "/functions"): { + "total": 1, + "functions": [ + { + "$id": "fn-1", + "name": "Disabled Function", + "enabled": False, + } + ], + }, + ("get", "/users"): { + "total": 1, + "users": [ + { + "$id": "user-1", + "email": "user@example.test", + "emailVerification": False, + } + ], + }, + }, + project="project-1", + ) + + context = get_appwrite_context(client, mode="api_key_project") + services = context["projects"][0]["services"] + + self.assertIs(services["functions"]["items"][0]["enabled"], False) + self.assertIs(services["users"]["items"][0]["emailVerification"], False) + + def test_service_probe_errors_are_returned_without_failing_context(self): + def fail(_params): + raise OSError("connection refused") + + client = FakeClient( + { + ("get", "/project"): {"$id": "project-1", "name": "Project One"}, + ("get", "/functions"): fail, + }, + project="project-1", + ) + + context = get_appwrite_context(client, mode="api_key_project") + + self.assertEqual(context["projects"][0]["$id"], "project-1") + self.assertEqual( + context["projects"][0]["services"]["functions"]["error"], + "connection refused", + ) + + def test_invalid_sample_limit_falls_back_to_default(self): + client = FakeClient( + { + ("get", "/project"): {"$id": "project-1", "name": "Project One"}, + }, + project="project-1", + ) + + context = _get_context_for_request({"sample_limit": "five"}, client) + + self.assertEqual(context["projects"][0]["$id"], "project-1") + self.assertIn( + ("get", "/tablesdb", {"queries": ['{"method":"limit","values":[5]}']}), + client.calls, + ) + if __name__ == "__main__": unittest.main() From f50d5bd178d6be42b41c736e261a9fb8e1859e21 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 24 Jun 2026 17:04:06 +0530 Subject: [PATCH 4/4] Return context overview inline --- src/mcp_server_appwrite/operator.py | 5 +---- tests/unit/test_operator.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/mcp_server_appwrite/operator.py b/src/mcp_server_appwrite/operator.py index b61981a..ce960f0 100644 --- a/src/mcp_server_appwrite/operator.py +++ b/src/mcp_server_appwrite/operator.py @@ -269,10 +269,7 @@ def _get_context(self, arguments: dict[str, Any]) -> list[ToolContent]: if self._context_provider is None: raise RuntimeError("Appwrite context provider is not configured.") context = self._context_provider(arguments) - return self._preview_or_store_result( - "appwrite_get_context", - [types.TextContent(type="text", text=json.dumps(context, indent=2))], - ) + return [types.TextContent(type="text", text=json.dumps(context, indent=2))] def list_resources(self) -> list[types.Resource]: resources = [ diff --git a/tests/unit/test_operator.py b/tests/unit/test_operator.py index 5241073..3c69e5a 100644 --- a/tests/unit/test_operator.py +++ b/tests/unit/test_operator.py @@ -173,6 +173,21 @@ def test_get_context_dispatches_provider(self): self.assertIn('"mode": "api_key_project"', result[0].text) self.assertIn('"$id": "project-1"', result[0].text) + def test_get_context_returns_large_payload_inline(self): + runtime = Operator( + ToolManager(), + lambda name, arguments, *_: [], + context_provider=lambda arguments: { + "connection": {"mode": "api_key_project"}, + "projects": [{"$id": "project-1", "description": "x" * 1200}], + }, + ) + + result = runtime.execute_public_tool("appwrite_get_context", {}) + + self.assertNotIn("appwrite://operator/results/", result[0].text) + self.assertIn("x" * 1200, result[0].text) + def test_search_tools_infers_mutating_search_for_create_query(self): runtime = self.make_runtime(lambda name, arguments, *_: [])