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" },