Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Binary file modified docs/appwrite-mcp-flow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/appwrite-mcp-flow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions docs/self-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions docs/tool-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>storage · teams · users · …]
K[OAuth: 38 services / 981 tools<br/>API key: 26 services / 647 tools]
end

CT -->|large output| R[(MCP resource<br/>preview + URI)]
Expand All @@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
169 changes: 169 additions & 0 deletions src/mcp_server_appwrite/catalog_policy.py
Original file line number Diff line number Diff line change
@@ -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"
14 changes: 7 additions & 7 deletions src/mcp_server_appwrite/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------------------

Expand Down
12 changes: 6 additions & 6 deletions src/mcp_server_appwrite/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/mcp_server_appwrite/error_monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 22 additions & 3 deletions src/mcp_server_appwrite/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
class CatalogEntry:
action_verb: str
classification: str
context_scope: str
description: str
input_schema: dict[str, Any]
required: list[str]
Expand Down Expand Up @@ -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,
):
Expand All @@ -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()
Expand Down Expand Up @@ -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."
),
},
Expand Down Expand Up @@ -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", [])),
Expand All @@ -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,
Expand Down Expand Up @@ -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("")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading