diff --git a/AGENTS.md b/AGENTS.md
index 9b48f63..9425162 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,8 +49,9 @@ needs no credentials. Dashboards live in the `dashboards` repo under `MCP/`.
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
+optionally `appwrite_search_docs`), while the full OAuth Appwrite catalog (38
+services) stays internal and is searched at runtime. API-key stdio uses a filtered
+26-service catalog. Mutating hidden tools require
`confirm_write=true`. Large outputs are stored as MCP resources and returned as a
preview + resource URI.
diff --git a/docs/appwrite-mcp-flow.png b/docs/appwrite-mcp-flow.png
index 465e444..b3e6fd7 100644
Binary files a/docs/appwrite-mcp-flow.png and b/docs/appwrite-mcp-flow.png differ
diff --git a/docs/appwrite-mcp-flow.svg b/docs/appwrite-mcp-flow.svg
index fcaaaa9..87e35f1 100644
--- a/docs/appwrite-mcp-flow.svg
+++ b/docs/appwrite-mcp-flow.svg
@@ -115,7 +115,7 @@
search_docs
- hidden catalog · 25 services · 100s of tools
+ hosted catalog · 38 services · 981 tools
writes need confirm_write: true
diff --git a/docs/self-hosted.md b/docs/self-hosted.md
index 6fe4cd3..d28ae67 100644
--- a/docs/self-hosted.md
+++ b/docs/self-hosted.md
@@ -3,6 +3,13 @@
Running your own Appwrite instance? Run the MCP server locally over `stdio` and
authenticate with a project API key instead of OAuth.
+The local API-key catalog is intentionally smaller than the hosted OAuth catalog:
+it exposes 647 project-key-compatible methods across 26 services. DocumentsDB,
+VectorsDB, and text embeddings are available, but console control-plane services
+such as organizations, domains, projects, billing, migrations, dedicated
+databases, usage administration, VCS administration, and WAF administration are
+hidden because project API keys cannot authenticate those routes.
+
## Setup
1. In your Appwrite Console, create a project API key with the scopes you want the
diff --git a/docs/tool-surface.md b/docs/tool-surface.md
index 517c5f7..824d586 100644
--- a/docs/tool-surface.md
+++ b/docs/tool-surface.md
@@ -18,9 +18,9 @@ flowchart LR
ST -.searches.-> CAT
CT -.invokes.-> CAT
- subgraph CAT[Internal catalog — 25 services]
+ subgraph CAT[Internal catalog — authentication-aware]
direction LR
- K[account · databases · functions
storage · teams · users · …]
+ K[OAuth: 38 services / 981 tools
API key: 26 services / 647 tools]
end
CT -->|large output| R[(MCP resource
preview + URI)]
@@ -43,7 +43,16 @@ flowchart LR
- **Large outputs** are stored as an MCP resource and returned as preview text
plus a resource URI.
- **Writes** through hidden mutating tools require `confirm_write=true`.
-- **Access** is gated per-route by the scopes the OAuth token was granted, not by
- the catalog.
-- **Registration** is automatic — every service the installed SDK ships becomes a
- catalog entry.
+- **Target context** is included in search results as `context=console`,
+ `context=organization`, or `context=project`. Hosted calls enforce the required
+ top-level `organization_id` or `project_id` before making a request.
+- **Access** is still gated per-route by the scopes granted to the OAuth token.
+- **Hosted OAuth** registers all 38 services and 981 methods shipped by
+ `appwrite-console` 0.2.1. This adds console control-plane services including
+ projects, organizations, domains, migrations, dedicated databases, usage, VCS,
+ vectors, WAF, notifications, and regions.
+- **API-key stdio** deliberately registers only the 647 project-key-compatible
+ methods across 26 services. It includes the new DocumentsDB, VectorsDB, and
+ text-embeddings APIs, while console administration methods remain hidden.
+- **Registration** remains SDK-driven, with the authentication profile policy
+ applied while the internal catalog is built.
diff --git a/pyproject.toml b/pyproject.toml
index daac318..5fe4b93 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"anyio>=4.0.0",
- "appwrite>=22.2.0,<23",
+ "appwrite-console>=0.2.1,<0.3",
"docstring-parser>=0.16",
"mcp[cli]>=2,<3",
"python-dotenv>=1.0.1",
diff --git a/src/mcp_server_appwrite/catalog_policy.py b/src/mcp_server_appwrite/catalog_policy.py
new file mode 100644
index 0000000..fcb68c9
--- /dev/null
+++ b/src/mcp_server_appwrite/catalog_policy.py
@@ -0,0 +1,169 @@
+"""Catalog profiles and target-context metadata for Appwrite SDK services.
+
+The console SDK contains both project APIs and console control-plane APIs. Hosted
+OAuth sessions may use the complete catalog, while local API-key sessions must
+only advertise endpoints that accept project API keys. Keeping this policy in one
+module makes that trust boundary explicit and independently testable.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+CatalogProfile = Literal["oauth", "api_key"]
+ContextScope = Literal["console", "organization", "project"]
+
+OAUTH_PROFILE: CatalogProfile = "oauth"
+API_KEY_PROFILE: CatalogProfile = "api_key"
+
+# Services present in the former server SDK, plus the server-capable document and
+# vector APIs introduced by appwrite-console. Any future console SDK service stays
+# hidden from API-key mode until it is deliberately reviewed and added here.
+API_KEY_SERVICES: frozenset[str] = frozenset(
+ {
+ "account",
+ "activities",
+ "advisor",
+ "apps",
+ "avatars",
+ "backups",
+ "databases",
+ "documents_db",
+ "embeddings",
+ "functions",
+ "graphql",
+ "locale",
+ "messaging",
+ "oauth2",
+ "organization",
+ "presences",
+ "project",
+ "proxy",
+ "sites",
+ "storage",
+ "tables_db",
+ "teams",
+ "tokens",
+ "users",
+ "vectors_db",
+ "webhooks",
+ }
+)
+
+# Methods added to existing service modules by the console SDK that are not
+# available to project API keys. The documents/vectors exclusions are console
+# administration operations; their remaining methods are server endpoints.
+API_KEY_EXCLUDED_METHODS: dict[str, frozenset[str]] = {
+ "account": frozenset(
+ {
+ "create_billing_address",
+ "create_key",
+ "create_o_auth2_session",
+ "create_payment_method",
+ "create_push_target",
+ "delete",
+ "delete_billing_address",
+ "delete_key",
+ "delete_payment_method",
+ "delete_push_target",
+ "get_billing_address",
+ "get_coupon",
+ "get_key",
+ "get_payment_method",
+ "list_billing_addresses",
+ "list_invoices",
+ "list_keys",
+ "list_payment_methods",
+ "update_billing_address",
+ "update_key",
+ "update_payment_method",
+ "update_payment_method_mandate_options",
+ "update_payment_method_provider",
+ "update_push_target",
+ }
+ ),
+ "apps": frozenset({"delete_installation"}),
+ "documents_db": frozenset(
+ {
+ "create_documents",
+ "create_failover",
+ "get_replicas",
+ "get_status",
+ "list_operations",
+ "list_specifications",
+ }
+ ),
+ "functions": frozenset({"get_template", "list_templates"}),
+ "oauth2": frozenset({"logout", "logout_post"}),
+ "presences": frozenset({"get_usage"}),
+ "project": frozenset({"get_usage"}),
+ "sites": frozenset({"get_template", "list_templates"}),
+ "tables_db": frozenset(
+ {
+ "create_migration",
+ "delete_migration",
+ "get_migration",
+ "list_migrations",
+ "list_operations",
+ }
+ ),
+ "teams": frozenset({"list_logs"}),
+ "users": frozenset({"get_usage"}),
+ "vectors_db": frozenset(
+ {
+ "create_documents",
+ "create_failover",
+ "create_query",
+ "get_replicas",
+ "get_status",
+ "list_operations",
+ "list_specifications",
+ }
+ ),
+}
+
+# Target metadata is surfaced in search results and enforced by hosted OAuth
+# calls. API-key mode already has a fixed project on its configured client.
+PROJECT_CONTEXT_SERVICES: frozenset[str] = frozenset(
+ {
+ "databases",
+ "documents_db",
+ "embeddings",
+ "functions",
+ "messaging",
+ "migrations",
+ "mongo",
+ "mysql",
+ "postgresql",
+ "sites",
+ "storage",
+ "tables_db",
+ "teams",
+ "usage",
+ "users",
+ "vcs",
+ "vectors_db",
+ "waf",
+ }
+)
+ORGANIZATION_CONTEXT_SERVICES: frozenset[str] = frozenset({"domains"})
+
+
+def method_allowed(
+ profile: CatalogProfile, service_name: str, method_name: str
+) -> bool:
+ """Return whether a method belongs in the selected authentication profile."""
+ if profile == OAUTH_PROFILE:
+ return True
+ if service_name not in API_KEY_SERVICES:
+ return False
+ return method_name not in API_KEY_EXCLUDED_METHODS.get(service_name, ())
+
+
+def context_scope(service_name: str) -> ContextScope:
+ """Return the target context a hosted OAuth call must provide."""
+ if service_name in PROJECT_CONTEXT_SERVICES:
+ return "project"
+ if service_name in ORGANIZATION_CONTEXT_SERVICES:
+ return "organization"
+ return "console"
diff --git a/src/mcp_server_appwrite/constants.py b/src/mcp_server_appwrite/constants.py
index 7138ac3..700f633 100644
--- a/src/mcp_server_appwrite/constants.py
+++ b/src/mcp_server_appwrite/constants.py
@@ -5,13 +5,13 @@
from importlib import metadata as importlib_metadata
from pathlib import Path
-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.site import Site
-from appwrite.models.team import Team
-from appwrite.models.user import User
+from appwrite_console.models.bucket import Bucket
+from appwrite_console.models.database import Database
+from appwrite_console.models.function import Function
+from appwrite_console.models.message import Message
+from appwrite_console.models.site import Site
+from appwrite_console.models.team import Team
+from appwrite_console.models.user import User
# --- server ---------------------------------------------------------------
diff --git a/src/mcp_server_appwrite/context.py b/src/mcp_server_appwrite/context.py
index dc2637d..d3b58fd 100644
--- a/src/mcp_server_appwrite/context.py
+++ b/src/mcp_server_appwrite/context.py
@@ -3,12 +3,12 @@
from collections.abc import Callable
from typing import Any
-from appwrite.client import Client
-from appwrite.exception import AppwriteException
-from appwrite.models.project import Project
-from appwrite.models.team import Team
-from appwrite.models.user import User
-from appwrite.query import Query
+from appwrite_console.client import Client
+from appwrite_console.exception import AppwriteException
+from appwrite_console.models.project import Project
+from appwrite_console.models.team import Team
+from appwrite_console.models.user import User
+from appwrite_console.query import Query
from .constants import REDACTED_KEYS, SERVICE_PROBES
diff --git a/src/mcp_server_appwrite/error_monitoring.py b/src/mcp_server_appwrite/error_monitoring.py
index d9c78f9..4ce6476 100644
--- a/src/mcp_server_appwrite/error_monitoring.py
+++ b/src/mcp_server_appwrite/error_monitoring.py
@@ -13,7 +13,7 @@
from collections.abc import Mapping
from typing import Any
-from appwrite.exception import AppwriteException
+from appwrite_console.exception import AppwriteException
_enabled = False
diff --git a/src/mcp_server_appwrite/operator.py b/src/mcp_server_appwrite/operator.py
index f69ba95..8367b08 100644
--- a/src/mcp_server_appwrite/operator.py
+++ b/src/mcp_server_appwrite/operator.py
@@ -42,6 +42,7 @@
class CatalogEntry:
action_verb: str
classification: str
+ context_scope: str
description: str
input_schema: dict[str, Any]
required: list[str]
@@ -110,6 +111,7 @@ def __init__(
docs_search: DocsSearch | None = None,
context_provider: ContextProvider | None = None,
preview_threshold: int = PREVIEW_THRESHOLD,
+ require_target_context: bool = False,
store_results: bool = True,
search_limit: int = SEARCH_LIMIT,
):
@@ -118,6 +120,7 @@ def __init__(
self._docs_search = docs_search
self._context_provider = context_provider
self._preview_threshold = preview_threshold
+ self._require_target_context = require_target_context
self._store_results = store_results
self._search_limit = search_limit
self._result_store = ResultStore()
@@ -240,8 +243,9 @@ 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 (TablesDB, tables, users, storage, "
- "functions, messaging, sites) require this. Discover a project "
+ "project-scoped tools (databases, documents, vectors, users, "
+ "storage, functions, messaging, sites, usage, VCS, and WAF) "
+ "require this. Search results identify each tool's context. Discover a project "
"first, then pass its id. Omit for console/account-level tools."
),
},
@@ -375,10 +379,12 @@ def _build_catalog(self) -> list[CatalogEntry]:
for tool in self._tools_manager.get_all_tools():
parsed = _parse_tool_name(tool.name)
input_schema = tool.input_schema or {}
+ tool_info = self._tools_manager.get_tool(tool.name) or {}
entries.append(
CatalogEntry(
action_verb=parsed["action_verb"],
classification=parsed["classification"],
+ context_scope=str(tool_info.get("context_scope", "console")),
description=tool.description or "",
input_schema=input_schema,
required=list(input_schema.get("required", [])),
@@ -395,6 +401,7 @@ def _catalog_json(self) -> str:
{
"action_verb": entry.action_verb,
"classification": entry.classification,
+ "context_scope": entry.context_scope,
"description": entry.description,
"required": entry.required,
"resource_name": entry.resource_name,
@@ -449,7 +456,8 @@ def _search_tools(self, arguments: dict[str, Any]) -> list[ToolContent]:
params = _format_params_block(match.entry)
lines.append(
f"{index}. tool={match.entry.tool_name} service={match.entry.service_name} "
- f"class={match.entry.classification} required={required}{missing} "
+ f"class={match.entry.classification} context={match.entry.context_scope} "
+ f"required={required}{missing} "
f"score={match.score}{description}{params}"
)
lines.append("")
@@ -484,6 +492,17 @@ def _call_hidden_tool(self, raw_arguments: dict[str, Any]) -> list[ToolContent]:
organization_id = raw_arguments.get(
"organization_id", raw_arguments.get("organizationId")
)
+ if self._require_target_context:
+ if entry.context_scope == "project" and not project_id:
+ raise ValueError(
+ f"Tool {tool_name} requires project_id. Use appwrite_get_context "
+ "to select a project, then retry with that project ID."
+ )
+ if entry.context_scope == "organization" and not organization_id:
+ raise ValueError(
+ f"Tool {tool_name} requires organization_id. Use appwrite_get_context "
+ "to select an organization, then retry with that organization ID."
+ )
arguments_object = _normalize_arguments(raw_arguments)
result_content = self._execute_tool(
tool_name, arguments_object, project_id, organization_id
diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py
index acaf05a..5eccc4b 100644
--- a/src/mcp_server_appwrite/server.py
+++ b/src/mcp_server_appwrite/server.py
@@ -28,11 +28,11 @@
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
-from appwrite.input_file import InputFile
-from appwrite.service import Service as _SdkService
+from appwrite_console.client import Client
+from appwrite_console.enums.browser import Browser
+from appwrite_console.exception import AppwriteException
+from appwrite_console.input_file import InputFile
+from appwrite_console.service import Service as _SdkService
from dotenv import find_dotenv, load_dotenv
from mcp import MCPError
from mcp.server import NotificationOptions, Server, ServerRequestContext
@@ -41,6 +41,13 @@
from mcp.types import CLIENT_INFO_META_KEY, INVALID_PARAMS
from . import error_monitoring, flags, telemetry
+from .catalog_policy import (
+ API_KEY_PROFILE,
+ OAUTH_PROFILE,
+ CatalogProfile,
+ context_scope,
+ method_allowed,
+)
from .constants import (
CACHE_TTL_SECONDS,
CATALOG_URI,
@@ -76,14 +83,14 @@ def _discover_service_classes() -> dict[str, type]:
prefix. The catalog/schema is built once from these classes; at execution time
the matching class is re-instantiated on a per-request client (see
``resolve_client``)."""
- import appwrite.services as services_pkg
+ import appwrite_console.services as services_pkg
discovered: dict[str, type] = {}
for module_info in pkgutil.iter_modules(services_pkg.__path__):
name = module_info.name
if name in EXCLUDED_SERVICES:
continue
- module = importlib.import_module(f"appwrite.services.{name}")
+ module = importlib.import_module(f"appwrite_console.services.{name}")
for _, cls in inspect.getmembers(module, inspect.isclass):
if (
issubclass(cls, _SdkService)
@@ -239,17 +246,19 @@ def build_client_for_request(
client = Client()
client.set_endpoint(endpoint or os.getenv("APPWRITE_ENDPOINT", DEFAULT_ENDPOINT))
client.set_project(target_project or project_id)
- client.add_header("Authorization", f"Bearer {bearer_token}")
+ client.set_bearer(bearer_token)
client = _configure_mcp_client_headers(client)
if target_project:
+ # Generated service methods read the project from client config. Keep the
+ # global header too for raw client.call() paths used by this server.
client.add_header("x-appwrite-project", target_project)
# Admin mode lets the console-issued token be recognized on another project
# (as the owner) instead of falling back to guest. It is only valid when
# targeting a real project — the API rejects admin mode on the console
# project itself — so it is gated on target_project, not organization_id.
- client.add_header("x-appwrite-mode", "admin")
+ client.set_mode("admin")
if organization_id:
- client.add_header("x-appwrite-organization", organization_id)
+ client.set_organization(organization_id)
return client
@@ -349,10 +358,29 @@ def resolve_client(
)
-def register_services(client: Client) -> ToolManager:
+def register_services(
+ client: Client, *, profile: CatalogProfile = OAUTH_PROFILE
+) -> ToolManager:
tools_manager = ToolManager()
for name, service_cls in SERVICE_CLASSES.items():
- tools_manager.register_service(Service(service_cls(client), name))
+ service_instance = service_cls(client)
+ allowed_methods = frozenset(
+ method_name
+ for method_name, method in inspect.getmembers(
+ service_instance, predicate=inspect.ismethod
+ )
+ if method_allowed(profile, name, method_name)
+ )
+ if not allowed_methods:
+ continue
+ tools_manager.register_service(
+ Service(
+ service_instance,
+ name,
+ allowed_methods=allowed_methods,
+ context_scope=context_scope(name),
+ )
+ )
return tools_manager
@@ -1421,6 +1449,7 @@ def build_operator(
),
context_provider=lambda arguments: _get_context_for_request(arguments, client),
docs_search=docs_search,
+ require_target_context=client is None,
store_results=store_results,
)
@@ -1472,7 +1501,7 @@ def client_factory(
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."""
- return register_services(build_introspection_client())
+ return register_services(build_introspection_client(), profile=OAUTH_PROFILE)
async def run_stdio() -> None:
@@ -1482,7 +1511,7 @@ async def run_stdio() -> None:
client = build_client(config)
_log_startup(f"Using Appwrite endpoint: {config.endpoint}")
_log_startup("Registering Appwrite services")
- tools_manager = register_services(client)
+ tools_manager = register_services(client, profile=API_KEY_PROFILE)
_log_startup("Starting Appwrite service validation")
validate_services(tools_manager)
_log_startup("Building Appwrite operator surface")
diff --git a/src/mcp_server_appwrite/service.py b/src/mcp_server_appwrite/service.py
index 478bfad..a1bc285 100644
--- a/src/mcp_server_appwrite/service.py
+++ b/src/mcp_server_appwrite/service.py
@@ -4,7 +4,7 @@
from types import UnionType
from typing import Any, Dict, List, Union, get_args, get_origin, get_type_hints
-from appwrite.input_file import InputFile
+from appwrite_console.input_file import InputFile
from docstring_parser import parse
from mcp.types import Tool
@@ -12,11 +12,20 @@
class Service:
"""Base class for all Appwrite services"""
- _IGNORED_PARAMETERS = {"on_progress"}
+ _IGNORED_PARAMETERS = {"model_type", "on_progress"}
- def __init__(self, service_instance, service_name: str):
+ def __init__(
+ self,
+ service_instance,
+ service_name: str,
+ *,
+ allowed_methods: frozenset[str] | None = None,
+ context_scope: str = "console",
+ ):
self.service = service_instance
self.service_name = service_name
+ self.allowed_methods = allowed_methods
+ self.context_scope = context_scope
self._method_name_overrides = self.get_method_name_overrides()
def get_method_name_overrides(self) -> Dict[str, str]:
@@ -151,6 +160,8 @@ def list_tools(self) -> Dict[str, Dict]:
for name, func in inspect.getmembers(self.service, predicate=inspect.ismethod):
if name.startswith("_"): # Skip private methods
continue
+ if self.allowed_methods is not None and name not in self.allowed_methods:
+ continue
original_func = func.__func__
@@ -205,6 +216,7 @@ def list_tools(self) -> Dict[str, Dict]:
# mode) instead of the credential-less client used for introspection.
"service_name": self.service_name,
"method_name": name,
+ "context_scope": self.context_scope,
"parameter_types": {
param_name: type_hints[param_name]
for param_name in signature.parameters
diff --git a/tests/integration/support.py b/tests/integration/support.py
index 9c5e20f..9b53f79 100644
--- a/tests/integration/support.py
+++ b/tests/integration/support.py
@@ -11,6 +11,7 @@
from typing import Any
from uuid import uuid4
+from mcp_server_appwrite.catalog_policy import API_KEY_PROFILE
from mcp_server_appwrite.docs_search import DocsSearch
from mcp_server_appwrite.operator import Operator
from mcp_server_appwrite.server import (
@@ -49,7 +50,7 @@ class ToolOutcome:
class LiveSurfaceRunner:
def __init__(self):
self.client = build_client()
- self.manager = register_services(self.client)
+ self.manager = register_services(self.client, profile=API_KEY_PROFILE)
docs_search = DocsSearch()
self.docs_available = docs_search.available
self.runtime = Operator(
diff --git a/tests/integration/test_console_sdk_oauth.py b/tests/integration/test_console_sdk_oauth.py
new file mode 100644
index 0000000..516e466
--- /dev/null
+++ b/tests/integration/test_console_sdk_oauth.py
@@ -0,0 +1,114 @@
+"""Read-only live coverage for every service added by appwrite-console.
+
+These tests use a console OAuth token and are skipped unless the explicit OAuth
+environment is present. Embeddings are billable and require a separate opt-in.
+"""
+
+from __future__ import annotations
+
+import os
+import unittest
+
+from mcp_server_appwrite.catalog_policy import OAUTH_PROFILE
+from mcp_server_appwrite.constants import DEFAULT_ENDPOINT
+from mcp_server_appwrite.server import (
+ _lookup_project_region,
+ build_client_for_request,
+ build_introspection_client,
+ execute_registered_tool,
+ register_services,
+ resolve_region_endpoint,
+)
+
+TOKEN = os.getenv("APPWRITE_OAUTH_ACCESS_TOKEN")
+ORGANIZATION_ID = os.getenv("APPWRITE_OAUTH_ORGANIZATION_ID")
+PROJECT_ID = os.getenv("APPWRITE_OAUTH_PROJECT_ID")
+
+
+@unittest.skipUnless(
+ TOKEN and ORGANIZATION_ID and PROJECT_ID,
+ "Console OAuth token, organization ID, and project ID are required.",
+)
+class ConsoleSdkOAuthIntegrationTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ assert TOKEN is not None
+ assert ORGANIZATION_ID is not None
+ assert PROJECT_ID is not None
+
+ cls.manager = register_services(
+ build_introspection_client(), profile=OAUTH_PROFILE
+ )
+ base_endpoint = os.getenv("APPWRITE_ENDPOINT", DEFAULT_ENDPOINT)
+ console_project_id = os.getenv("APPWRITE_CONSOLE_PROJECT_ID", "console")
+ cls.console_client = build_client_for_request(
+ console_project_id, TOKEN, endpoint=base_endpoint
+ )
+ cls.organization_client = build_client_for_request(
+ console_project_id,
+ TOKEN,
+ endpoint=base_endpoint,
+ organization_id=ORGANIZATION_ID,
+ )
+
+ project_endpoint = os.getenv("APPWRITE_OAUTH_PROJECT_ENDPOINT")
+ if not project_endpoint:
+ region = _lookup_project_region(console_project_id, TOKEN, PROJECT_ID)
+ project_endpoint = resolve_region_endpoint(base_endpoint, region)
+ cls.project_client = build_client_for_request(
+ console_project_id,
+ TOKEN,
+ endpoint=project_endpoint,
+ target_project=PROJECT_ID,
+ )
+
+ def _call(self, tool_name: str, arguments: dict | None, client) -> None:
+ result = execute_registered_tool(
+ self.manager, tool_name, arguments or {}, client=client
+ )
+ self.assertTrue(result, tool_name)
+
+ def test_new_console_services_are_usable(self):
+ probes = (
+ ("console_list_regions", {}, self.console_client),
+ ("organizations_list", {}, self.console_client),
+ ("notifications_list", {}, self.console_client),
+ (
+ "projects_list_stages",
+ {"project_id": PROJECT_ID},
+ self.console_client,
+ ),
+ ("domains_list", {}, self.organization_client),
+ ("documents_db_list", {}, self.project_client),
+ ("migrations_list", {}, self.project_client),
+ ("mongo_list", {}, self.project_client),
+ ("mysql_list", {}, self.project_client),
+ ("postgresql_list", {}, self.project_client),
+ (
+ "usage_list_events",
+ {"metrics": ["executions"], "limit": 1},
+ self.project_client,
+ ),
+ ("vcs_list_installations", {}, self.project_client),
+ ("vectors_db_list", {}, self.project_client),
+ ("waf_list_rules", {}, self.project_client),
+ )
+
+ for tool_name, arguments, client in probes:
+ with self.subTest(tool_name=tool_name):
+ self._call(tool_name, arguments, client)
+
+ @unittest.skipUnless(
+ os.getenv("APPWRITE_TEST_BILLABLE_EMBEDDINGS") == "1",
+ "Set APPWRITE_TEST_BILLABLE_EMBEDDINGS=1 to run the billable probe.",
+ )
+ def test_embeddings_are_usable_when_billable_probe_is_enabled(self):
+ self._call(
+ "embeddings_create_text_embeddings",
+ {"texts": ["Appwrite MCP integration probe"]},
+ self.project_client,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/integration/test_functions.py b/tests/integration/test_functions.py
index c3c67b2..87c0f17 100644
--- a/tests/integration/test_functions.py
+++ b/tests/integration/test_functions.py
@@ -1,4 +1,4 @@
-from appwrite.enums.runtime import Runtime
+from appwrite_console.enums.runtime import Runtime
from support import LiveIntegrationTestCase, requires_live_integration
diff --git a/tests/integration/test_sites.py b/tests/integration/test_sites.py
index c113700..4e25980 100644
--- a/tests/integration/test_sites.py
+++ b/tests/integration/test_sites.py
@@ -1,5 +1,5 @@
-from appwrite.enums.build_runtime import BuildRuntime
-from appwrite.enums.framework import Framework
+from appwrite_console.enums.build_runtime import BuildRuntime
+from appwrite_console.enums.framework import Framework
from support import LiveIntegrationTestCase, requires_live_integration
diff --git a/tests/integration/test_storage.py b/tests/integration/test_storage.py
index eae99d6..81921bd 100644
--- a/tests/integration/test_storage.py
+++ b/tests/integration/test_storage.py
@@ -1,4 +1,4 @@
-from appwrite.enums.compression import Compression
+from appwrite_console.enums.compression import Compression
from support import LiveIntegrationTestCase, requires_live_integration
diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py
index 7da3b49..44ae566 100644
--- a/tests/unit/test_context.py
+++ b/tests/unit/test_context.py
@@ -1,7 +1,7 @@
import unittest
-from appwrite.client import Client
-from appwrite.exception import AppwriteException
+from appwrite_console.client import Client
+from appwrite_console.exception import AppwriteException
from mcp_server_appwrite.context import get_appwrite_context
from mcp_server_appwrite.server import _get_context_for_request
diff --git a/tests/unit/test_error_monitoring.py b/tests/unit/test_error_monitoring.py
index e76c632..b52dc0f 100644
--- a/tests/unit/test_error_monitoring.py
+++ b/tests/unit/test_error_monitoring.py
@@ -2,7 +2,7 @@
import unittest
from unittest.mock import patch
-from appwrite.exception import AppwriteException
+from appwrite_console.exception import AppwriteException
from mcp_server_appwrite import error_monitoring
diff --git a/tests/unit/test_operator.py b/tests/unit/test_operator.py
index 9def5b2..d369dfa 100644
--- a/tests/unit/test_operator.py
+++ b/tests/unit/test_operator.py
@@ -228,8 +228,67 @@ def test_search_tools_returns_ranked_match(self):
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], types.TextContent)
self.assertIn("tables_db_list", result[0].text)
+ self.assertIn("context=console", result[0].text)
self.assertIn(CATALOG_URI, result[0].text)
+ def test_catalog_and_search_surface_target_context(self):
+ manager = ToolManager()
+ manager.tools_registry = {
+ "domains_list": {
+ "definition": make_tool("domains_list", "List domains."),
+ "context_scope": "organization",
+ }
+ }
+ runtime = Operator(manager, lambda *_: [])
+
+ result = runtime.execute_public_tool(
+ "appwrite_search_tools", {"query": "list domains"}
+ )
+ self.assertIn("context=organization", result[0].text)
+
+ catalog = runtime.read_resource(CATALOG_URI)[0].content
+ self.assertIn('"context_scope": "organization"', catalog)
+
+ def test_hosted_calls_require_the_catalog_target_context(self):
+ manager = ToolManager()
+ manager.tools_registry = {
+ "tables_db_list": {
+ "definition": make_tool("tables_db_list", "List databases."),
+ "context_scope": "project",
+ },
+ "domains_list": {
+ "definition": make_tool("domains_list", "List domains."),
+ "context_scope": "organization",
+ },
+ }
+ runtime = Operator(manager, lambda *_: [], require_target_context=True)
+
+ with self.assertRaisesRegex(ValueError, "requires project_id"):
+ runtime.execute_public_tool(
+ "appwrite_call_tool", {"tool_name": "tables_db_list"}
+ )
+ with self.assertRaisesRegex(ValueError, "requires organization_id"):
+ runtime.execute_public_tool(
+ "appwrite_call_tool", {"tool_name": "domains_list"}
+ )
+
+ def test_stdio_calls_use_configured_project_without_target_argument(self):
+ manager = ToolManager()
+ manager.tools_registry = {
+ "tables_db_list": {
+ "definition": make_tool("tables_db_list", "List databases."),
+ "context_scope": "project",
+ }
+ }
+ runtime = Operator(manager, lambda *_: [], require_target_context=False)
+
+ self.assertEqual(
+ runtime.execute_public_tool(
+ "appwrite_call_tool", {"tool_name": "tables_db_list"}
+ ),
+ [],
+ )
+
def test_get_context_dispatches_provider(self):
runtime = Operator(
ToolManager(),
diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py
index 97504aa..97962c5 100644
--- a/tests/unit/test_server.py
+++ b/tests/unit/test_server.py
@@ -10,11 +10,12 @@
from unittest.mock import Mock, patch
import mcp.types as types
-from appwrite.enums.browser import Browser
-from appwrite.exception import AppwriteException
-from appwrite.input_file import InputFile
+from appwrite_console.enums.browser import Browser
+from appwrite_console.exception import AppwriteException
+from appwrite_console.input_file import InputFile
from mcp_server_appwrite import server as server_module
+from mcp_server_appwrite.catalog_policy import API_KEY_PROFILE, OAUTH_PROFILE
from mcp_server_appwrite.server import (
_coerce_argument,
_configure_uploads,
@@ -573,8 +574,8 @@ async def run_check():
asyncio.run(run_check())
def test_register_services_returns_fresh_manager(self):
- manager_a = register_services(object())
- manager_b = register_services(object())
+ manager_a = register_services(object(), profile=OAUTH_PROFILE)
+ manager_b = register_services(object(), profile=OAUTH_PROFILE)
self.assertIsNot(manager_a, manager_b)
self.assertEqual(len(manager_a.get_all_tools()), len(manager_b.get_all_tools()))
@@ -584,8 +585,28 @@ def test_register_services_returns_fresh_manager(self):
{service.service_name for service in manager_a.services},
set(SERVICE_CLASSES),
)
- # Every advertised service is registered (the SDK currently ships 14).
- self.assertGreaterEqual(len(manager_a.services), 14)
+ self.assertEqual(len(manager_a.services), 38)
+ self.assertEqual(len(manager_a.get_all_tools()), 981)
+
+ def test_api_key_profile_only_advertises_server_capabilities(self):
+ manager = register_services(object(), profile=API_KEY_PROFILE)
+ service_names = {service.service_name for service in manager.services}
+ tool_names = {tool.name for tool in manager.get_all_tools()}
+
+ self.assertEqual(len(manager.services), 26)
+ self.assertEqual(len(tool_names), 647)
+ self.assertIn("documents_db_list", tool_names)
+ self.assertIn("vectors_db_list", tool_names)
+ self.assertIn("embeddings_create_text_embeddings", tool_names)
+ self.assertNotIn("domains", service_names)
+ self.assertNotIn("organizations", service_names)
+ self.assertNotIn("documents_db_list_operations", tool_names)
+ self.assertNotIn("account_list_invoices", tool_names)
+
+ def test_console_sdk_internal_model_type_is_never_advertised(self):
+ manager = register_services(object(), profile=OAUTH_PROFILE)
+ for tool in manager.get_all_tools():
+ self.assertNotIn("model_type", tool.input_schema["properties"])
def test_validate_services_raises_with_service_name(self):
class FailingSdkService:
diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py
index 6f3c56b..1be3bb3 100644
--- a/tests/unit/test_service.py
+++ b/tests/unit/test_service.py
@@ -2,7 +2,7 @@
from enum import Enum
from typing import Any, Dict, List
-from appwrite.input_file import InputFile
+from appwrite_console.input_file import InputFile
from mcp_server_appwrite.service import Service
@@ -21,6 +21,7 @@ def create(
points: List[Any],
file: InputFile,
optional_flag: bool = False,
+ model_type=None,
on_progress=None,
) -> Dict[str, Any]:
"""
@@ -40,6 +41,8 @@ def create(
File input.
optional_flag : bool
Optional boolean flag.
+ model_type : type, optional
+ Internal response model selector.
on_progress : callable, optional
Ignored callback.
"""
@@ -55,6 +58,7 @@ def test_generates_enum_and_input_file_schema(self):
self.assertEqual(tool["definition"].description, "Create example resource.")
self.assertNotIn("on_progress", schema["properties"])
+ self.assertNotIn("model_type", schema["properties"])
self.assertEqual(schema["properties"]["mode"]["enum"], ["first", "second"])
self.assertEqual(schema["properties"]["mode"]["type"], "string")
self.assertEqual(schema["properties"]["points"]["type"], "array")
@@ -63,6 +67,23 @@ def test_generates_enum_and_input_file_schema(self):
self.assertIn("file", schema["required"])
self.assertTrue(schema["additionalProperties"] is False)
+ def test_filters_methods_and_carries_context_metadata(self):
+ tools = Service(
+ ExampleService(),
+ "example",
+ allowed_methods=frozenset(),
+ context_scope="project",
+ ).list_tools()
+ self.assertEqual(tools, {})
+
+ tools = Service(
+ ExampleService(),
+ "example",
+ allowed_methods=frozenset({"create"}),
+ context_scope="project",
+ ).list_tools()
+ self.assertEqual(tools["example_create"]["context_scope"], "project")
+
if __name__ == "__main__":
unittest.main()
diff --git a/uv.lock b/uv.lock
index d7c194b..e535617 100644
--- a/uv.lock
+++ b/uv.lock
@@ -38,16 +38,16 @@ wheels = [
]
[[package]]
-name = "appwrite"
-version = "22.2.0"
+name = "appwrite-console"
+version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/58/8f/3e28094d410d1b7c6ff1d5182ee1f5f1b9157e1e695666593e1ba31564cc/appwrite-22.2.0.tar.gz", hash = "sha256:ef08d16a6b8669405e8a44218cca167a24838ca419efe2c104b2461b0438a042", size = 188505, upload-time = "2026-07-24T06:15:08.007Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/40/b2/36866a4356930ea66cd9209060a43a7a4b775840788c493fa76ff6e4e7fd/appwrite_console-0.2.1.tar.gz", hash = "sha256:a3892c0e94ed211260f404646f7b82f9d8ae596a4a428590be63bc9fff476585", size = 293961, upload-time = "2026-08-03T17:01:20.336Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/b6/ec1c14d80fc324c432742b10f586041915eb6e0cd78111a05a8cd96a6e54/appwrite-22.2.0-py3-none-any.whl", hash = "sha256:12cc8c41e0f59e33ae5b2fcee04f06fe933ff977e2258148b4745e6bbd274379", size = 332277, upload-time = "2026-07-24T06:15:06.593Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/16/70b34ba7fe38a158c9d9879d88118f1dcf9ba900c70d58b1a9587c82ca76/appwrite_console-0.2.1-py3-none-any.whl", hash = "sha256:6aa4f5d32eadf0ecf3c98b2ef78b628443c8f01f58bc7e0855263c8af82a5b86", size = 515596, upload-time = "2026-08-03T17:01:18.886Z" },
]
[[package]]
@@ -660,7 +660,7 @@ name = "mcp-server-appwrite"
source = { editable = "." }
dependencies = [
{ name = "anyio" },
- { name = "appwrite" },
+ { name = "appwrite-console" },
{ name = "docstring-parser" },
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
@@ -695,7 +695,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = ">=4.0.0" },
- { name = "appwrite", specifier = ">=22.2.0,<23" },
+ { name = "appwrite-console", specifier = ">=0.2.1,<0.3" },
{ name = "argon2-cffi", marker = "extra == 'integration'", specifier = ">=23.1.0" },
{ name = "bcrypt", marker = "extra == 'integration'", specifier = ">=4.1.2" },
{ name = "docstring-parser", specifier = ">=0.16" },